Initial: panel docs, BLE tools, glove firmware notes

- 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
This commit is contained in:
Salty 2026-04-01 17:46:23 -04:00
commit 71515ef517
5 changed files with 479 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
*.pyc
.pio/
.vscode/

168
README.md Normal file
View File

@ -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

74
docs/glove-firmware.md Normal file
View File

@ -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":"<id>"}`
- 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
```

121
scripts/ble-capture-only.py Normal file
View File

@ -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())

112
scripts/led-panel-ble.py Normal file
View File

@ -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 <address> <slot_id>")
print(" python3 led-panel-ble.py cycle <address> [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())