Add USB CDC transport variant (alternative to BLE GATT)#26
Conversation
Replaces the BLE GATT data link with a wired USB serial channel.
Designed for desk-side use where the device is already cabled for power
and BLE pairing/bonding flakiness is unwelcome.
Firmware:
- New serial_link.{h,cpp} owns all Serial I/O including the legacy
`screenshot` debug command. Line-delimited protocol:
host -> dev : {"s":..,...} usage payload | "screenshot"
dev -> host : ACK | NACK | REQ | READY
- main.cpp: ble_* -> serial_link_*; BLE HID button bindings stubbed
with TODO for future USB HID composite.
- ui.{h,cpp}: SCREEN_BLUETOOTH -> SCREEN_LINK, "USB Link" status screen.
- platformio.ini: NimBLE dependency and build flags removed.
- ble.{cpp,h} deleted (-310 lines).
Daemon:
- claude-usage-daemon.sh full rewrite: stty + printf over /dev/ttyACM0,
-hupcl to suppress the DTR-toggle reset that fires on port open with
default Arduino USB CDC config.
- Background reader tails device output and flags REQ refresh requests.
- Two consecutive write failures recycle the port to recover from USB
re-enumeration.
- Service unit no longer depends on bluetooth.target.
Docs:
- CLAUDE.md describes the wire protocol, -hupcl gotcha, and port
resilience. install.sh checks for stty instead of bluetoothctl.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reflect the wire change made in the parent commit: USB-only Linux install flow, /dev/ttyACM0 setup, dialout group, wire-protocol table, and the -hupcl gotcha. macOS install section dropped since this branch hasn't ported the macOS Python daemon yet (see main for that). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the bash daemon's USB rewrite on the macOS side: - daemon/claude_usage_daemon.py: drop bleak, use pyserial. Port discovery globs /dev/cu.usbmodem* (the callout node — does NOT assert DTR on open, unlike /dev/tty.usbmodem*). HUPCL cleared explicitly via termios as belt-and-braces. Background reader_task tails the port and sets a refresh asyncio.Event when the firmware emits REQ. - install-mac.sh: swap bleak/httpx for pyserial/httpx in the venv setup, drop the Bluetooth permission-priming step (no longer needed). - README: restore macOS install section, document the cu.* vs tty.* gotcha alongside the existing Linux -hupcl note. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Nice idea. I think keep it on a separate branch for a bit and then see about merging it. I'm getting a lot of alternative hardware pull requests, some devices without battery, some with, etc. Would be good to be able to offer a flexibility when building for different devices to be able to toggle features such as USB or BLE. |
|
Hey, sorry for the delay There's been a bit of a change to the architecture we have, that is we now have a hardware abstraction layer called Maybe you could ask your agent to refactor this PR to allow for the CDC transport layer to adhere to that new architecture and we can get this merged for devices that might want to opt for the USB transport instead of BLE |
Branches three open PRs into one working stack on a Windows host with the 480x480 Waveshare AMOLED-2.16: - Base: PR HermannBjorgvin#26 (dilbery's USB CDC transport) - Plus daemon: cross-platform port + Windows hook-compat - Plus feature: PR HermannBjorgvin#22 (tobby168's Activity screen / Claude Code hooks) - Plus 3 fixes: firmware buffers + parse for multi-session payloads Windows daemon port (vs PR HermannBjorgvin#26) ------------------------------- daemon/claude_usage_daemon.py: - find_port() on win32 uses pyserial list_ports filtered by USB VID (303A Espressif, 10C4 SiLabs, 1A86 WCH, 0403 FTDI). Locale-independent — works on non-English Windows where caption strings are translated. - termios import + HUPCL clear gated to sys.platform != "win32". - write_payload uses ensure_ascii=False so UTF-8 stays raw (em-dashes etc are 3 bytes instead of 6-byte \uXXXX escapes; smaller payloads, less pressure on ArduinoJson on the firmware side). daemon/clawdmeter_hook.py (from PR HermannBjorgvin#22): - fcntl is Unix-only; replace with msvcrt.locking on win32. daemon/install_hooks_win.py (new): - Windows analogue of install-mac.sh's inline-Python hook registration. Backs up settings.json before writing, idempotent. Windows host scripts (from PR HermannBjorgvin#23): flash-win.ps1, install-win.ps1, setup-win.ps1, uninstall-win.ps1, daemon/run-daemon.ps1. Brought along so this branch is buildable + installable on Windows without cherry-picking. Activity backport on USB CDC (vs PR HermannBjorgvin#22) ---------------------------------------- firmware/src/data.h: PR HermannBjorgvin#22 ActivityData / SessionData / TodoItem structs. firmware/src/ui.h: +SCREEN_ACTIVITY enum + ui_update_activity() API. firmware/src/ui.cpp: PR HermannBjorgvin#22 init_activity_screen / render_activity / activity_gesture_cb / ui_update_activity grafted onto PR HermannBjorgvin#26's ui.cpp; cycle order Splash > Usage > Link > Activity > Usage. firmware/src/main.cpp: parse_json gets optional ActivityData* out-param; sessions / todos extracted into the struct; ui_update_activity() called after each successful parse. daemon/claude_usage_daemon.py: - load_activity_sessions() + state_file_mtime() from PR HermannBjorgvin#22. - Main loop tracks last_state_mtime; payload includes "sessions" whenever the file changes OR a fresh API poll happens. Three firmware fixes needed for multi-session payloads ------------------------------------------------------ Real-world payloads with 2 sessions x 5-10 todos each routinely hit 700-1000 bytes — well past anything PR HermannBjorgvin#26's defaults handled. 1. firmware/src/serial_link.cpp: LINE_BUF_SIZE / DATA_BUF_SIZE 192 -> 4096. 192 was tuned for the ~120-byte Usage-only payload; longer lines were silently dropped at the line-accumulator level. 2. firmware/src/main.cpp: Serial.setRxBufferSize(4096) before Serial.begin. USB-CDC RX defaults to ~256 bytes; a 798-byte payload arrived as strlen=317 on the firmware side (truncated at receive). Bumping the buffer is the actual fix (CFG_TUD_CDC_RX_BUFSIZE redefine is ignored because Arduino-ESP32 overrides it via CONFIG_TINYUSB_CDC_RX_BUFSIZE). 3. firmware/src/main.cpp: parse_json is two-pass. - Pass 1 is a Filter()'d deserialization that only extracts the Usage scalars (s/sr/w/wr/st/ok). A tiny doc, always succeeds. - Pass 2 is a best-effort full parse for the "sessions" array. If it fails (oversized, mid-line truncation, unicode oddity) we leave ActivityData empty but still ACK the payload upstream — Usage stays intact, Activity falls back to its empty render state. Tested ------ Waveshare ESP32-S3-Touch-AMOLED-2.16 on Windows 10 (Dutch locale, Realtek BT 5.1 adapter unused, USB-C only). Two parallel Claude Code sessions on the same host: - 11+ todos across two sessions, ~1KB payload, ACK'd reliably. - Activity screen renders project name, in-progress todo activeForm, counter coloured by phase, todo list windowed around the in-progress item. PWR cycles Usage <-> Link <-> Activity. - state.json updates on every PreToolUse/PostToolUse/UserPromptSubmit/ Stop/SessionStart; daemon picks it up on its 5s tick.
Branches three open PRs into one working stack on a Windows host with the 480x480 Waveshare AMOLED-2.16: - Base: PR HermannBjorgvin#26 (dilbery's USB CDC transport) - Plus daemon: cross-platform port + Windows hook-compat - Plus feature: PR HermannBjorgvin#22 (tobby168's Activity screen / Claude Code hooks) - Plus 3 fixes: firmware buffers + parse for multi-session payloads Windows daemon port (vs PR HermannBjorgvin#26) ------------------------------- daemon/claude_usage_daemon.py: - find_port() on win32 uses pyserial list_ports filtered by USB VID (303A Espressif, 10C4 SiLabs, 1A86 WCH, 0403 FTDI). Locale-independent — works on non-English Windows where caption strings are translated. - termios import + HUPCL clear gated to sys.platform != "win32". - write_payload uses ensure_ascii=False so UTF-8 stays raw (em-dashes etc are 3 bytes instead of 6-byte \uXXXX escapes; smaller payloads, less pressure on ArduinoJson on the firmware side). daemon/clawdmeter_hook.py (from PR HermannBjorgvin#22): - fcntl is Unix-only; replace with msvcrt.locking on win32. daemon/install_hooks_win.py (new): - Windows analogue of install-mac.sh's inline-Python hook registration. Backs up settings.json before writing, idempotent. Windows host scripts (from PR HermannBjorgvin#23): flash-win.ps1, install-win.ps1, setup-win.ps1, uninstall-win.ps1, daemon/run-daemon.ps1. Brought along so this branch is buildable + installable on Windows without cherry-picking. Activity backport on USB CDC (vs PR HermannBjorgvin#22) ---------------------------------------- firmware/src/data.h: PR HermannBjorgvin#22 ActivityData / SessionData / TodoItem structs. firmware/src/ui.h: +SCREEN_ACTIVITY enum + ui_update_activity() API. firmware/src/ui.cpp: PR HermannBjorgvin#22 init_activity_screen / render_activity / activity_gesture_cb / ui_update_activity grafted onto PR HermannBjorgvin#26's ui.cpp; cycle order Splash > Usage > Link > Activity > Usage. firmware/src/main.cpp: parse_json gets optional ActivityData* out-param; sessions / todos extracted into the struct; ui_update_activity() called after each successful parse. daemon/claude_usage_daemon.py: - load_activity_sessions() + state_file_mtime() from PR HermannBjorgvin#22. - Main loop tracks last_state_mtime; payload includes "sessions" whenever the file changes OR a fresh API poll happens. Three firmware fixes needed for multi-session payloads ------------------------------------------------------ Real-world payloads with 2 sessions x 5-10 todos each routinely hit 700-1000 bytes — well past anything PR HermannBjorgvin#26's defaults handled. 1. firmware/src/serial_link.cpp: LINE_BUF_SIZE / DATA_BUF_SIZE 192 -> 4096. 192 was tuned for the ~120-byte Usage-only payload; longer lines were silently dropped at the line-accumulator level. 2. firmware/src/main.cpp: Serial.setRxBufferSize(4096) before Serial.begin. USB-CDC RX defaults to ~256 bytes; a 798-byte payload arrived as strlen=317 on the firmware side (truncated at receive). Bumping the buffer is the actual fix (CFG_TUD_CDC_RX_BUFSIZE redefine is ignored because Arduino-ESP32 overrides it via CONFIG_TINYUSB_CDC_RX_BUFSIZE). 3. firmware/src/main.cpp: parse_json is two-pass. - Pass 1 is a Filter()'d deserialization that only extracts the Usage scalars (s/sr/w/wr/st/ok). A tiny doc, always succeeds. - Pass 2 is a best-effort full parse for the "sessions" array. If it fails (oversized, mid-line truncation, unicode oddity) we leave ActivityData empty but still ACK the payload upstream — Usage stays intact, Activity falls back to its empty render state. Tested ------ Waveshare ESP32-S3-Touch-AMOLED-2.16 on Windows 10 (Dutch locale, Realtek BT 5.1 adapter unused, USB-C only). Two parallel Claude Code sessions on the same host: - 11+ todos across two sessions, ~1KB payload, ACK'd reliably. - Activity screen renders project name, in-progress todo activeForm, counter coloured by phase, todo list windowed around the in-progress item. PWR cycles Usage <-> Link <-> Activity. - state.json updates on every PreToolUse/PostToolUse/UserPromptSubmit/ Stop/SessionStart; daemon picks it up on its 5s tick.
|
Thanks for this PR — the USB CDC transport unblocked a real problem I was hitting on Windows. After a few days of Windows BLE-stack debugging on the same Tested on real hardwareHardware: Waveshare ESP32-S3-Touch-AMOLED-2.16 (480×480, AXP2101 PMU, CST9220 touch, no battery installed). Full end-to-end verified with the daemon as a per-user Scheduled Task pushing data 24×7. ACK round-trips visible in What I added on top of
|
Summary
Adds a USB CDC serial transport as an alternative to the existing BLE GATT data link, on both Linux and macOS. Implemented as a branch-level fork — main stays BLE, this branch is wire-only — because making the transport a build-time switch would be a much larger refactor and I wanted to share the working code first to gauge interest.
Happy to refactor this as an opt-in
-DCLAWDMETER_TRANSPORT=USBbuild variant if you'd prefer one tree instead of two; that path keeps BLE as the default and adds USB alongside. Let me know.Why
Hit a string of BLE-on-Linux pain points on a fresh CachyOS install:
Trusted: yesbutPaired: no, Bonded: no, so firmware writes that needed an encrypted link silently no-op'donWritehandler missing some ACKsFor a desk device that's plugged in for power anyway, USB CDC is a much simpler wire. Zero pairing state, no GATT handle drift across reboots, no encryption negotiation, and ACK/NACK round-trip is well under a millisecond.
What changed
Firmware
serial_link.{h,cpp}— owns allSerialI/O including the legacyscreenshotdebug command. Line-delimited protocol (host→dev: JSON payload |screenshot; dev→host:ACK/NACK/REQ/READY)main.cpp:ble_*calls replaced withserial_link_*. Side-button BLE HID is stubbed with aTODOfor a future USB HID composite (kept the GPIO read so adding HID later doesn't need to touch loop wiring)ui.{h,cpp}:SCREEN_BLUETOOTH→SCREEN_LINK, simpler 2-label status panel ("Connected" / "Waiting" / "Stale", port name). Reset-bonds tap zone removedplatformio.ini: NimBLE dependency + 5 BLE config flags goneble.{cpp,h}deleted (-310 lines net)Linux daemon (bash)
daemon/claude-usage-daemon.sh.sttyconfigures/dev/ttyACM0with-hupcl(critical: without it the kernel toggles DTR on every port open and the ESP32-S3 resets), thenprintf > /dev/ttyACM0for writessetsid bash -c \"cat $PORT | awk ...\") tails device output and flags REQ refresh requests for the inner poll loopbluetooth.targetmacOS daemon (Python)
daemon/claude_usage_daemon.pyrewritten: droppedbleakforpyserial. Port discovery globs/dev/cu.usbmodem*(the callout node — does NOT assert DTR on open, unlike/dev/tty.usbmodem*). HUPCL is also explicitly cleared viatermiosas belt-and-bracesREQ. Same two-strikes recycle-port pattern as the bash daemoninstall-mac.shnow installspyserialinstead ofbleakin the venv and drops the Bluetooth permission-priming step entirely (no permissions needed for USB serial)Docs
README.mdrewritten on this branch to describe USB transport for both OSes, dialout-group setup on Linux, thecu.* vs tty.*gotcha on macOS, the-hupclLinux gotcha, and the new wire-protocol tableCLAUDE.mdupdated for future Claude Code sessions landing on this branchinstall.shcheckssttyinstead ofbluetoothctl/busctlKnown limitations on this branch
Test plan
pio run -d firmware) — 1.0 MB, no warnings beyond a benign GCC linker stack-note[device] ACKscreenshotserial command still works (handled insideserial_link.cppnow)🤖 Generated with Claude Code