An Android app that remote-controls an LED message panel mounted on a car, over Bluetooth. The panel itself is driven by a Raspberry Pi (3B/3B+).
Note: this app is also maintained as
android/inside romainhedouin/tesla-panel (a fork ofhzeller/rpi-rgb-led-matrix), alongside the Pi-side receiver in that repo'stesla/directory — seetesla/README.mdthere for deployment and a documented gotcha around Bluetooth pairing on a headless Pi. This standalone repo may drift from that copy; treattesla-panelas the actively-maintained one if the two disagree.
- The phone pairs with the Raspberry Pi over classic Bluetooth (SPP, RFCOMM),
connecting to a hardcoded MAC address (
BluetoothClient.findDevice()). - Everything the panel can show is a
PanelMessage(model/PanelMessage.java): a label, a category (Greetings/Courtesy/Traffic-safety/Fun/Data/Custom), an optional language tag (fr/en/none), and either a fixed list ofFrames (a ready-to-send 64×32 PPM + a display duration) or aLiveDataSourcefor the Data category. A single frame is a static image; multiple frames are a slideshow — there's no separate "animation" concept, it's just aPanelMessagewith more than one frame. - The Data category (
data/TimeDataSource,data/SpeedDataSource) shows the current time or GPS speed, re-rendered and re-sent once a second until stopped — no fixed duration, no frame list. Labels ("Heure:"/"Time:", "Vitesse:"/"Speed:") follow the app's display language, set via the language menu (see below), not the phone's locale. storage/MessageStoreloads the bundled defaults (assets/default_messages.json- the
.ppmassets it references) and any user-created messages (persisted under the app's internal files dir), filterable by category/language. A message with no language tag is treated as language-neutral and matches every language filter, not just "All" — a message meant for everyone shouldn't disappear because a specific language is selected.
- the
MainActivityshows those messages as a thumbnail grid (MessageAdapter), filterable by category chips and an app-bar language popup (next to a brightness popup, 25/50/75/90/100%). A "now showing" bar at the top reads "Connected"/"Not connected" (polled fromBluetoothClient.isConnected()) when idle and "Displaying..." with a live thumbnail while something's active, plus a Stop button — all accurate for free, since the app is the only thing that ever tells the panel what to display or when to stop. Long-press a custom message's card to delete it (built-in messages can't be deleted).CreateMessageActivity(the "+" button) is how you make a custom message: a label, category, language, and a list of frames. Each frame is just typed text —render/PixelFontRendererrasterizes it using a bitmap (BDF) font bundled from therpi-rgb-led-matrixlibrary (assets/fonts/9x18B.bdf; Data's two-line time/speed layout uses the smaller6x13B.bdf), blitting glyph pixels directly with no anti-aliasing, so panel text is always crisp. A frame whose text doesn't fit the panel width is auto-paginated into multiple frames byrender/TextChunker(word-boundary aware) — long messages split into readable chunks rather than scrolling, since scrolling text is hard to read from a following car.MessageSenderruns all Bluetooth I/O (which blocks - a connect can take several seconds) on a dedicatedHandlerThread, never the caller's thread, and postsListenercallbacks back to the main thread for UI updates. It sends a fixedPanelMessage's frames in order, timing each client-side, or for a live-data message re-renders and re-sends every second untilstop(). A monotonicgenerationcounter (touched only on the I/O thread) lets astop()/newsend()invalidate any in-flight timers/ticks without locks.- Messages are framed as
[1 byte command type][4 bytes big-endian payload length][payload], then a single-byte status response.BluetoothClientwrites the whole thing in one shot — no manual chunking or sentinel values, since RFCOMM is a reliable ordered stream that already handles fragmentation/reassembly transparently. Command types: image, kill, and set-brightness (seeSettings— brightness is a persisted app setting, changeable from the app-bar dropdown, synced to the Pi before every message and pushed immediately on change if already connected). - Traffic-safety includes generated EU-style (red circle) and US-style
(portrait "SPEED LIMIT" rectangle) speed-limit sign images — see
scripts/generate_speed_signs.pyto regenerate or add more.
- Android Studio (or just the command-line SDK tools + a JDK) with SDK Platform 32 installed.
- minSdk 23, targetSdk/compileSdk 32.
- A phone with Bluetooth, paired with the Pi ahead of time (pairing happens
outside the app, at the OS level — see
tesla-panel'stesla/README.mdif pairing silently fails, since a headless Pi needs a pairing agent running to accept it).
./gradlew assembleDebug
The APK lands in app/build/outputs/apk/debug/app-debug.apk. Sideload it to a
phone directly (adb install, AirDrop, etc.) — it isn't published anywhere.
- The target device's Bluetooth MAC address is hardcoded in
BluetoothClient.findDevice(). Point it at a different Pi and you'll need to edit and rebuild. - No error surfacing beyond
System.out.println/Toast— failures don't produce a real diagnostic message. - On Android 12+ (targetSdk 31+),
BLUETOOTH_CONNECTis a runtime permission, andACCESS_FINE_LOCATIONis required for the Data category's speed reading.MainActivityrequests both on launch; if you see the app silently no-op on button taps (a toast, no crash) or Speed always show "--", one of these was denied — check phone Settings → Apps → TeslaLED → Permissions. - Multi-frame sequences are "slideshow" pacing (each frame shown for at least ~0.5-1s+), not smooth high-fps animation — each frame costs a Bluetooth round trip. Fine for paginated text, a deliberate slideshow, or the Data category's 1Hz updates; not meant for anything resembling video.
- Custom messages currently support text only (no photo import, no freehand
drawing) — both would fit the same
Framemodel later without changing anything else.