From 71515ef517ad07089ffebbec4ac3d6442b3ffdeb Mon Sep 17 00:00:00 2001 From: Salty Date: Wed, 1 Apr 2026 17:46:23 -0400 Subject: [PATCH] Initial: panel docs, BLE tools, glove firmware notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LED panel BLE protocol (partial RE from glove firmware) - Panel MAC: E0:6E:41:94:39:70, BLE name: YS623B101069L - led-panel-ble.py: scan, slot switch, cycle tool - ble-capture-only.py: GATT server capture proxy - Glove firmware docs (ESP32 WROOM, 4-button controller) - Slot switch command doesn't work — capture needed --- .gitignore | 4 + README.md | 168 ++++++++++++++++++++++++++++++++++++ docs/glove-firmware.md | 74 ++++++++++++++++ scripts/ble-capture-only.py | 121 ++++++++++++++++++++++++++ scripts/led-panel-ble.py | 112 ++++++++++++++++++++++++ 5 files changed, 479 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/glove-firmware.md create mode 100644 scripts/ble-capture-only.py create mode 100644 scripts/led-panel-ble.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55eb280 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.pio/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d81c936 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# LED Panel — BLE Protocol Reverse Engineering & Turn Signal Controller + +## Goal + +Build a BLE-controlled turn signal system using a cheap "Amusing LED" backpack panel. The panel displays saved animations (arrows, brake lights, etc.) and is switched via BLE commands from an ESP32 glove controller worn while riding an EUC (electric unicycle). + +## The Panel + +- **Brand:** "Amusing LED" (generic Chinese LED backpack panel) +- **BLE Name:** `YS623B101069L` +- **BLE MAC:** `E0:6E:41:94:39:70` (address type: LE Random) +- **BLE Service UUID:** `49535343-fe7d-4ae5-8fa9-9fafd205e455` (Microchip Transparent UART) +- **Companion App:** "Amusing LED" (Android/iOS) + +### BLE Characteristics + +| UUID | Properties | Purpose | +|------|-----------|---------| +| `49535343-8841-43f4-a8d4-ecbe34729bb3` | write-without-response, write | **Main command channel** | +| `49535343-6daa-4d02-abf6-19569aca69fe` | write | Secondary write | +| `49535343-1e4d-4bd9-ba61-23c647249616` | notify | Panel → phone notifications | +| `49535343-aca3-481c-91ec-d85e28a60318` | write, notify | Bidirectional channel | + +### Known Protocol (Partial — from glove firmware) + +**Slot switch command** (20 bytes): +``` +AA 55 FF FF 0E 00 01 00 C1 02 18 06 02 XX 00 01 FF 7F YY 05 +``` + +Where: +- Byte 13 (`XX`): `0x3C + (slot_id - 61)` +- Byte 18 (`YY`): `0xAA + (slot_id - 61)` +- Base slot_id = 61 + +**⚠️ STATUS: This command connects successfully but does NOT switch animations on the current panel. The slot numbering or command format may have changed between firmware versions. Protocol capture needed.** + +### Current Saved Animations + +Uploaded via the Amusing LED app: +1. **Slot 1:** Left turn arrow +2. **Slot 2:** Right turn arrow +3. **Slot 3:** Pace/running animation + +(Internal slot IDs unknown — need protocol capture to determine) + +## The Glove Controller + +An ESP32 WROOM-based controller with 4 buttons, originally built for Inmotion V13 EUC integration. Source code in the `glove/` directory on Orin at `/home/seb/glove/`. + +### Architecture + +``` +┌─────────────────┐ BLE ┌──────────────┐ +│ ESP32 Glove │─────────────▶│ LED Panel │ +│ (4 buttons) │ │ (backpack) │ +│ │ WiFi ┌──────────────┐ +│ │─────────────▶│ WLED Strip 1│ +│ │─────────────▶│ WLED Strip 2│ +│ │ BLE ┌──────────────┐ +│ │─────────────▶│ Inmotion V13│ +└─────────────────┘ │ (EUC) │ + └──────────────┘ +``` + +### Button Mapping + +| Button | Single Press | Double Tap | +|--------|-------------|------------| +| 1 (GPIO 32) | Toggle LEFT signal | V13 Horn | +| 2 (GPIO 33) | Toggle RIGHT signal | V13 Headlight | +| 3 (GPIO 25) | Toggle BRAKE | — | +| 4 (GPIO 26) | (unused) | — | + +### States + +- **NEUTRAL** — default, no signals +- **LEFT** — left turn signal active +- **RIGHT** — right turn signal active +- **BRAKE** — brake light active + +Each button toggles its state; pressing again returns to NEUTRAL. + +### WLED Integration + +The glove sends commands to two WLED strips via WiFi HTTP API: +- `wled1` at IP `4.3.2.1` (hardcoded) +- `wled2` at mDNS name `tangerine-disk` +- Uses WLED presets named: `LEFT`, `RIGHT`, `BRAKE`, `NEUTRAL` + +### V13 EUC Integration + +Connects via BLE to Inmotion V13 (`V13-3002563C`): +- Horn command +- Headlight toggle + +## What Needs To Be Done + +### Phase 1: Protocol Capture (PRIORITY) + +The slot-switch command from the glove firmware doesn't work on this panel. We need to capture what the Amusing LED app actually sends. + +**Tools ready on Orin (`saltylab-orin` / `192.168.86.158`):** + +1. **`~/ble-capture-only.py`** — BLE GATT server that impersonates the panel, logs all writes from the app. Run with `sudo python3 ~/ble-capture-only.py`. Requires real panel to be OFF/disconnected. + +2. **`~/led-panel-ble.py`** — BLE client for testing commands against the real panel: + ```bash + python3 ~/led-panel-ble.py scan # find devices + python3 ~/led-panel-ble.py slot E0:6E:41:94:39:70 61 # switch slot + python3 ~/led-panel-ble.py cycle E0:6E:41:94:39:70 1 10 3 # cycle slots + ``` + +3. **Alternative:** Android HCI snoop log (Developer Options → Enable Bluetooth HCI snoop log) + +**Capture process:** +1. Turn off real panel +2. `sudo python3 ~/ble-capture-only.py` on Orin +3. Open Amusing LED app, connect to fake `YS623B101069L` +4. Perform actions: switch between 3 saved animations, upload a new image +5. Ctrl+C, examine `~/ble-capture-log.jsonl` + +### Phase 2: Protocol Decode + +From captures, determine: +- **Slot switch command format** (which bytes, which slot IDs) +- **Image upload protocol** (pixel format, chunking, headers) +- **Any handshake/init** the app sends on connect +- **Brightness, speed, mode commands** + +### Phase 3: Update Glove Firmware + +Once protocol is known: +1. Update `Led.cpp` with correct command format +2. Set MAC to `E0:6E:41:94:39:70` +3. Wire buttons: 1=left arrow, 2=right arrow, 3=brake/pace +4. Build & flash ESP32 + +### Phase 4: Direct Pixel Control (Stretch) + +If we decode the image upload protocol, we can push custom animations directly from the ESP32 or Orin without using the app at all. + +## Environment + +- **Orin:** `seb@192.168.86.158` (saltylab-orin, Jetson Orin Nano) +- **Glove source:** `/home/seb/glove/` on Orin (PlatformIO, ESP32 WROOM) +- **Python deps:** `bleak` 3.0.1, `bless` 0.3.0 (both installed for user + root) +- **BLE adapter:** hci0, `F8:3D:C6:56:E6:FC` +- **Panel is nearby** the Orin in the lab + +## Files + +``` +scripts/ + led-panel-ble.py — BLE client: scan, slot switch, cycle + ble-capture-only.py — GATT server capture proxy +captures/ + (place .jsonl capture files here) +docs/ + glove-firmware.md — notes on the existing glove codebase +``` + +## References + +- Microchip Transparent UART BLE service: `49535343-*` +- WLED HTTP API: https://kno.wled.ge/interfaces/http-api/ +- bleak (BLE client): https://github.com/hbldh/bleak +- bless (BLE server): https://github.com/kevincar/bless diff --git a/docs/glove-firmware.md b/docs/glove-firmware.md new file mode 100644 index 0000000..fd30c05 --- /dev/null +++ b/docs/glove-firmware.md @@ -0,0 +1,74 @@ +# Glove Firmware Notes + +Source: `/home/seb/glove/` on saltylab-orin + +## Project Structure + +``` +glove/ +├── platformio.ini # ESP32 WROOM (upesy_wroom), espressif32 6.7.0 +├── partitions.csv # Custom partition table +├── src/ +│ ├── main.cpp # Current main (V13 + WLED + LED panel) +│ ├── main.cpp.good # Known working version +│ ├── main.cpp.v14 # V14 variant? +│ ├── main.cpp.test # Test version +│ └── main.cpp.inmotion # Inmotion-only version +├── lib/ +│ ├── Device/ +│ │ ├── Device.h # Base device interface +│ │ ├── common.h # Globals (mutex) +│ │ ├── led/ +│ │ │ ├── Led.h # LED panel BLE client +│ │ │ └── Led.cpp # Slot switch implementation +│ │ ├── wled/ +│ │ │ ├── Wled.h # WLED HTTP client +│ │ │ └── Wled.cpp # Preset switching via /json/state +│ │ └── EUC/Inmotion/ +│ │ ├── Inmotion.h # V13 BLE client +│ │ ├── Inmotion.cpp # Speed/brake event parsing +│ │ └── InmotionUtils.h +│ └── InputManager/ +│ ├── InputManager.h # 4-button interrupt handler +│ └── InputManager.cpp # Debounce + sequence detection +``` + +## Key Implementation Details + +### InputManager +- 4 GPIO inputs (32, 33, 25, 26) with pull-up, interrupt on CHANGE +- Debounce: 175ms +- Sequence detection: 700ms timeout timer — accumulates button presses into a string +- "1" = button 1 single, "11" = button 1 double-tap, etc. + +### Led (BLE Panel Client) +- Connects by MAC address (passed as constructor string) +- Uses `BLE_ADDR_TYPE_RANDOM` for connection +- Single write characteristic for commands +- Slot command: 20-byte packet with offset calculation from slot_id 61 +- **The slot command doesn't work on current panel — needs re-reverse-engineering** + +### Wled (WiFi WLED Client) +- Resolves via mDNS or direct IP +- Fetches `/presets.json` on connect, builds name→ID map +- Switches presets via POST to `/json/state` with `{"ps":""}` +- Actions mapped by preset name: "LEFT", "RIGHT", "BRAKE", "NEUTRAL" + +### Inmotion V13 +- BLE connection to EUC +- Parses speed data from notifications +- Fires BRAKE event when decelerating, NEUTRAL when stable +- Also supports HORN and TOGGLE_LIGHT commands + +### WiFi +- SSID: SASI, password: h3ll0w1f1 +- Hostname: "controller" +- mDNS enabled +- BLE/WiFi coexistence: `ESP_COEX_PREFER_BALANCE` + +### Build +```bash +cd /home/seb/glove +pio run -e upesy_wroom # build +pio run -e upesy_wroom -t upload # flash +``` diff --git a/scripts/ble-capture-only.py b/scripts/ble-capture-only.py new file mode 100644 index 0000000..bce53c9 --- /dev/null +++ b/scripts/ble-capture-only.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +BLE Capture Proxy — capture only, no forwarding. +Advertises as fake LED panel, logs all writes from phone app. +No bleak (client) used — avoids adapter conflicts. + +Usage: sudo python3 ble-capture-only.py +""" + +import asyncio +import json +import signal +from datetime import datetime +from bless import BlessServer, BlessGATTCharacteristic, GATTCharacteristicProperties, GATTAttributePermissions + +SERVICE_UUID = "49535343-fe7d-4ae5-8fa9-9fafd205e455" +CHAR_WRITE_WR = "49535343-8841-43f4-a8d4-ecbe34729bb3" +CHAR_WRITE2 = "49535343-6daa-4d02-abf6-19569aca69fe" +CHAR_NOTIFY = "49535343-1e4d-4bd9-ba61-23c647249616" +CHAR_WR_NOTIFY = "49535343-aca3-481c-91ec-d85e28a60318" + +LOG_FILE = "/home/seb/ble-capture-log.jsonl" +msg_counter = 0 +log_fh = None + +def log_msg(char_uuid, data): + global msg_counter + msg_counter += 1 + short = char_uuid[-4:] + hex_str = data.hex(' ') if data else "" + entry = {"n": msg_counter, "ts": datetime.now().isoformat(), + "char": short, "uuid": char_uuid, "hex": hex_str, "len": len(data) if data else 0} + + # Try to decode as ASCII if it looks like text + try: + text = data.decode('ascii') + if text.isprintable(): + entry["ascii"] = text + except: + pass + + line = json.dumps(entry) + print(f"[{msg_counter:04d}] ...{short} len={entry['len']:4d} | {hex_str[:120]}") + if entry.get("ascii"): + print(f" ASCII: {entry['ascii']}") + + if log_fh: + log_fh.write(line + "\n") + log_fh.flush() + +def on_read(char: BlessGATTCharacteristic, **kwargs) -> bytearray: + print(f" READ request on ...{str(char.uuid)[-4:]}") + return char.value or bytearray() + +def on_write(char: BlessGATTCharacteristic, value: bytearray, **kwargs): + uuid = str(char.uuid) + log_msg(uuid, bytearray(value)) + +async def main(): + global log_fh + + log_fh = open(LOG_FILE, "w") + print(f"Logging to {LOG_FILE}") + + server = BlessServer(name="YS623B101069L") + server.read_request_func = on_read + server.write_request_func = on_write + + await server.add_new_service(SERVICE_UUID) + + await server.add_new_characteristic( + SERVICE_UUID, CHAR_WRITE_WR, + GATTCharacteristicProperties.write_without_response | GATTCharacteristicProperties.write, + bytearray(b'\x00'), GATTAttributePermissions.writeable) + + await server.add_new_characteristic( + SERVICE_UUID, CHAR_WRITE2, + GATTCharacteristicProperties.write, + bytearray(b'\x00'), GATTAttributePermissions.writeable) + + await server.add_new_characteristic( + SERVICE_UUID, CHAR_NOTIFY, + GATTCharacteristicProperties.notify, + bytearray(b'\x00'), GATTAttributePermissions.readable) + + await server.add_new_characteristic( + SERVICE_UUID, CHAR_WR_NOTIFY, + GATTCharacteristicProperties.write | GATTCharacteristicProperties.notify, + bytearray(b'\x00'), GATTAttributePermissions.writeable | GATTAttributePermissions.readable) + + await server.start() + + print("\n" + "=" * 60) + print("CAPTURE PROXY RUNNING") + print("=" * 60) + print("Advertising as: YS623B101069L") + print() + print("1. Make sure real panel is OFF or out of range") + print("2. Open Amusing LED app on phone") + print("3. Connect to 'YS623B101069L'") + print("4. Switch animations, upload images, etc.") + print("5. All writes logged here + " + LOG_FILE) + print("6. Ctrl+C to stop") + print("=" * 60 + "\n") + print("Waiting for connections...\n") + + stop = asyncio.Event() + loop = asyncio.get_event_loop() + loop.add_signal_handler(signal.SIGINT, stop.set) + loop.add_signal_handler(signal.SIGTERM, stop.set) + + await stop.wait() + + print("\nShutting down...") + await server.stop() + if log_fh: + log_fh.close() + print(f"Captured {msg_counter} messages. Log: {LOG_FILE}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/led-panel-ble.py b/scripts/led-panel-ble.py new file mode 100644 index 0000000..d312634 --- /dev/null +++ b/scripts/led-panel-ble.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +LED Panel BLE Slot Switcher +Connects to Amusing LED panel via BLE and switches between saved program slots. +Protocol reverse-engineered from /home/seb/glove/lib/Device/led/Led.cpp +""" + +import asyncio +import sys +from bleak import BleakScanner, BleakClient + +# BLE UUIDs (Microchip transparent UART) +SERVICE_UUID = "49535343-fe7d-4ae5-8fa9-9fafd205e455" +WRITE_UUID = "49535343-8841-43f4-a8d4-ecbe34729bb3" + +# Base command for slot switching +BASE_CMD = bytearray([ + 0xAA, 0x55, 0xFF, 0xFF, 0x0E, 0x00, 0x01, 0x00, + 0xC1, 0x02, 0x18, 0x06, 0x02, 0x3C, 0x00, 0x01, + 0xFF, 0x7F, 0xAA, 0x05 +]) + +def make_slot_cmd(slot_id): + """Build slot switch command. slot_id starts at 61 (base).""" + cmd = bytearray(BASE_CMD) + offset = slot_id - 61 + cmd[13] = 0x3C + offset # byte 13 + cmd[18] = 0xAA + offset # byte 18 + return cmd + +async def scan(): + """Scan for BLE devices, look for LED panels.""" + print("Scanning for BLE devices (10s)...") + devices = await BleakScanner.discover(timeout=10) + candidates = [] + for d in devices: + name = d.name or "" + # Show everything with a name, highlight likely LED panels + if name: + marker = " <<<" if any(k in name.lower() for k in ["led", "amus", "lux", "panel", "pixel"]) else "" + rssi = getattr(d, 'rssi', None) or (d.details.get('props', {}).get('RSSI', '?') if hasattr(d, 'details') and isinstance(d.details, dict) else '?') + print(f" {d.address} RSSI:{rssi} {name}{marker}") + candidates.append(d) + if not candidates: + print("No named devices found. Panel might need to be in pairing mode.") + return candidates + +async def connect_and_switch(address, slot_id): + """Connect to panel and switch to given slot.""" + print(f"Connecting to {address}...") + async with BleakClient(address, timeout=15) as client: + print(f"Connected: {client.is_connected}") + + # List services for debugging + for svc in client.services: + if svc.uuid == SERVICE_UUID: + print(f" Found LED service: {svc.uuid}") + for char in svc.characteristics: + props = ",".join(char.properties) + print(f" Char {char.uuid} [{props}]") + + cmd = make_slot_cmd(slot_id) + print(f"Switching to slot {slot_id}: {cmd.hex(' ')}") + await client.write_gatt_char(WRITE_UUID, cmd, response=False) + print("Done!") + +async def cycle_slots(address, start=61, end=65, delay_sec=3): + """Cycle through slots to identify which animation is where.""" + print(f"Connecting to {address} for slot cycling...") + async with BleakClient(address, timeout=15) as client: + print(f"Connected: {client.is_connected}") + for slot in range(start, end + 1): + cmd = make_slot_cmd(slot) + print(f" Slot {slot}: {cmd.hex(' ')}") + await client.write_gatt_char(WRITE_UUID, cmd, response=False) + if slot < end: + print(f" Waiting {delay_sec}s...") + await asyncio.sleep(delay_sec) + print("Cycle complete!") + +async def main(): + if len(sys.argv) < 2: + print("Usage:") + print(" python3 led-panel-ble.py scan") + print(" python3 led-panel-ble.py slot
") + print(" python3 led-panel-ble.py cycle
[start] [end] [delay]") + print() + print("Examples:") + print(" python3 led-panel-ble.py scan") + print(" python3 led-panel-ble.py slot AA:BB:CC:DD:EE:FF 61") + print(" python3 led-panel-ble.py cycle AA:BB:CC:DD:EE:FF 61 65 3") + return + + cmd = sys.argv[1] + + if cmd == "scan": + await scan() + elif cmd == "slot" and len(sys.argv) >= 4: + addr = sys.argv[2] + slot = int(sys.argv[3]) + await connect_and_switch(addr, slot) + elif cmd == "cycle" and len(sys.argv) >= 3: + addr = sys.argv[2] + start = int(sys.argv[3]) if len(sys.argv) > 3 else 61 + end = int(sys.argv[4]) if len(sys.argv) > 4 else 65 + delay = int(sys.argv[5]) if len(sys.argv) > 5 else 3 + await cycle_slots(addr, start, end, delay) + else: + print("Invalid args. Run without args for usage.") + +if __name__ == "__main__": + asyncio.run(main())