Skip to content

Add USB CDC transport variant (alternative to BLE GATT)#26

Open
dilbery wants to merge 3 commits into
HermannBjorgvin:mainfrom
dilbery:usb-transport
Open

Add USB CDC transport variant (alternative to BLE GATT)#26
dilbery wants to merge 3 commits into
HermannBjorgvin:mainfrom
dilbery:usb-transport

Conversation

@dilbery

@dilbery dilbery commented May 20, 2026

Copy link
Copy Markdown

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=USB build 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:

  • Pairing would succeed with Trusted: yes but Paired: no, Bonded: no, so firmware writes that needed an encrypted link silently no-op'd
  • After power-cycling the controller, bluez auto-reconnected (because of the prior trust), but the daemon kept its stale GATT characteristic path — writes "succeeded" at the busctl level but never reached the firmware. Display stuck on old data
  • Even on a healthy bond, about half the writes would hang for 25 s (the default D-Bus reply timeout) before failing — looked like the firmware's onWrite handler missing some ACKs

For 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

  • New serial_link.{h,cpp} — owns all Serial I/O including the legacy screenshot debug command. Line-delimited protocol (host→dev: JSON payload | screenshot; dev→host: ACK/NACK/REQ/READY)
  • main.cpp: ble_* calls replaced with serial_link_*. Side-button BLE HID is stubbed with a TODO for a future USB HID composite (kept the GPIO read so adding HID later doesn't need to touch loop wiring)
  • ui.{h,cpp}: SCREEN_BLUETOOTHSCREEN_LINK, simpler 2-label status panel ("Connected" / "Waiting" / "Stale", port name). Reset-bonds tap zone removed
  • platformio.ini: NimBLE dependency + 5 BLE config flags gone
  • ble.{cpp,h} deleted (-310 lines net)
  • Builds clean: 1.0 MB image, 28% RAM, 31% flash

Linux daemon (bash)

  • Full rewrite of daemon/claude-usage-daemon.sh. stty configures /dev/ttyACM0 with -hupcl (critical: without it the kernel toggles DTR on every port open and the ESP32-S3 resets), then printf > /dev/ttyACM0 for writes
  • Background reader (setsid bash -c \"cat $PORT | awk ...\") tails device output and flags REQ refresh requests for the inner poll loop
  • Two consecutive write failures recycle the port — recovers from USB re-enumeration without manual intervention
  • Service unit no longer depends on bluetooth.target

macOS daemon (Python)

  • daemon/claude_usage_daemon.py rewritten: dropped bleak for pyserial. Port discovery globs /dev/cu.usbmodem* (the callout node — does NOT assert DTR on open, unlike /dev/tty.usbmodem*). HUPCL is also explicitly cleared via termios as belt-and-braces
  • Background asyncio reader task tails the port and sets a refresh event when the firmware emits REQ. Same two-strikes recycle-port pattern as the bash daemon
  • install-mac.sh now installs pyserial instead of bleak in the venv and drops the Bluetooth permission-priming step entirely (no permissions needed for USB serial)

Docs

  • README.md rewritten on this branch to describe USB transport for both OSes, dialout-group setup on Linux, the cu.* vs tty.* gotcha on macOS, the -hupcl Linux gotcha, and the new wire-protocol table
  • CLAUDE.md updated for future Claude Code sessions landing on this branch
  • install.sh checks stty instead of bluetoothctl/busctl

Known limitations on this branch

  • Side buttons are inert. The left/right BLE HID Space/Shift+Tab feature is gone. USB HID composite (CDC + HID on the same USB endpoint) is doable via TinyUSB but I wanted to keep this PR scoped to the data link
  • Single device per host. A USB port serves one board; BLE could theoretically serve more from the same host. Not a real limitation for the desk-monitor use case but worth flagging

Test plan

  • Firmware builds clean (pio run -d firmware) — 1.0 MB, no warnings beyond a benign GCC linker stack-note
  • Flashed on real hardware (Waveshare ESP32-S3-Touch-AMOLED-2.16), display shows USB Link "Connected" status
  • Linux daemon runs as a systemd --user service, writes once per minute, no "Write failed" lines after switching from BLE. ACK lines from the firmware visible in the journal as [device] ACK
  • screenshot serial command still works (handled inside serial_link.cpp now)
  • Cable unplug/replug test — port lost at T+0, daemon backed off 1/2/4s, port reopened cleanly at T+8s, first poll sent immediately and ACKed. Log excerpt:
    18:59:00 Port lost, reconnecting...
    18:59:01 Waiting for /dev/ttyACM0 (retry in 1s)...
    18:59:02 Waiting for /dev/ttyACM0 (retry in 2s)...
    18:59:04 Waiting for /dev/ttyACM0 (retry in 4s)...
    18:59:08 Port opened: /dev/ttyACM0
    18:59:09 Sending: {...} ; [device] ACK
    
  • macOS daemon not exercised on real hardware (no Mac on hand). Code mirrors the bash daemon's structure 1:1 and uses pyserial idioms that should be solid; would appreciate a second pair of eyes from a macOS user before merging the macOS half

🤖 Generated with Claude Code

Drew and others added 3 commits May 20, 2026 18:54
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>
@dilbery dilbery marked this pull request as ready for review May 20, 2026 09:45
@HermannBjorgvin

Copy link
Copy Markdown
Owner

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.

@HermannBjorgvin

Copy link
Copy Markdown
Owner

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 hal to allow us to port alternative boards with different hardware capabilities.

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

dsk2k added a commit to dsk2k/Clawdmeter that referenced this pull request May 27, 2026
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.
dsk2k added a commit to dsk2k/Clawdmeter that referenced this pull request May 27, 2026
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.
@dsk2k

dsk2k commented May 27, 2026

Copy link
Copy Markdown

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 main-branch firmware (HID-pair auto-claim, GATT cache drift, NimBLE host-task hangs after CCCD writes) I gave up and switched to your usb-transport branch. Same kind of "actually just use a wire" relief you describe in the PR body, just on a different host OS.

Tested on real hardware

Hardware: Waveshare ESP32-S3-Touch-AMOLED-2.16 (480×480, AXP2101 PMU, CST9220 touch, no battery installed).
Host: Windows 10 (Dutch locale, NL Windows), Python 3.14, PlatformIO 6.1.19.

Full end-to-end verified with the daemon as a per-user Scheduled Task pushing data 24×7. ACK round-trips visible in daemon.out.log. Two parallel Claude Code sessions on the same host stream their state simultaneously via the sessions payload extension (more on that below).

What I added on top of usb-transport

Branch: https://github.com/dsk2k/Clawdmeter/tree/usb-transport-win (single commit, ready to cherry-pick or review).

Windows daemon port

daemon/claude_usage_daemon.py was macOS/Linux-only. To make it run on Windows:

  • find_port() on win32 uses pyserial's list_ports.comports() filtered by USB VID (303A Espressif, 10C4 SiLabs CP210x, 1A86 WCH CH340/CH341, 0403 FTDI). The advantage over the glob-based approach is locale-independence — on Dutch Windows the COM caption is "Serieel USB-apparaat (COM3)" and the Microsoft generic usbser driver fills Manufacturer with "Microsoft", so any English-string match fails silently. VID is invariant.
  • termios import + the HUPCL clear branch gated to sys.platform != "win32". pyserial's dsrdtr=False carries the DTR-on-open suppression on Windows; HUPCL isn't a Windows concept anyway.
  • json.dumps(..., ensure_ascii=False) so UTF-8 stays raw — em-dashes and accents are 3 bytes instead of 6-byte \uXXXX escapes. Real-world payloads with two parallel Claude Code sessions easily hit 700–1000 bytes; the escape overhead matters.

Three firmware fixes that aren't Windows-specific (will help any host)

These weren't Windows-side but I ran into them as soon as I tried to ride a richer payload (the PR #22 Activity / sessions extension) on top of your transport. Each one is a real bug that would bite anyone the moment payloads grow past the Usage-only ~120 bytes:

  1. firmware/src/serial_link.cpp: LINE_BUF_SIZE / DATA_BUF_SIZE: 192 → 4096. The accumulator dropped any line longer than 191 bytes. With multi-session payloads (~700–1000 bytes) the firmware just saw line-fragments that never parsed.

  2. firmware/src/main.cpp: Serial.setRxBufferSize(4096) before Serial.begin(). ESP32-S3 USB-CDC default RX buffer is ~256 bytes. A 798-byte payload arrived as strlen=317 on the firmware side — truncation at the kernel/USB-CDC layer, before serial_link_tick() ever sees it. (I tried the obvious -DCFG_TUD_CDC_RX_BUFSIZE=4096 build flag first; Arduino-ESP32 redefines it via CONFIG_TINYUSB_CDC_RX_BUFSIZE and ignores it. The Serial.setRxBufferSize() runtime call is what actually takes.)

  3. firmware/src/main.cpp: parse_json two-pass. ArduinoJson 7 occasionally chokes on heavy payloads (long unicode prompts, many nested objects). I split parsing in two:

    • Pass 1: Filter()-ed deserialization that only extracts the Usage scalars (s/sr/w/wr/st/ok). A tiny doc — always succeeds even if the full payload is malformed.
    • Pass 2: best-effort full parse for sessions. A failure here logs and returns true so Usage still ACKs.

Symptom before fix 3: JSON parse error: InvalidInput followed by NACK for any payload with a complex sessions array. With it: Usage works unconditionally, Activity falls back gracefully when present but unparseable.

On the "refactor as opt-in -DCLAWDMETER_TRANSPORT=USB" question

For a Windows user who has reproducible BLE-on-host hell, USB-CDC being the default would be a relief. Linux/macOS users with a working BLE setup probably want main. So opt-in via build flag matches the way most people would actually pick. Happy to help wire that up if you'd like — branches mostly diverge in three files (ble.cpp/serial_link.cpp and how main.cpp wires them) which is straightforward to make #ifdef-able.

Related

  • I split off the host-side Windows scripts (flash/install/uninstall PowerShell + Scheduled Task daemon wrapper) into PR #23 on the upstream repo. They're in this branch too because they're needed for build+install, but the PR has them in cleaner standalone form against main (BLE).
  • The Activity / sessions extension is PR Activity screen: live Claude Code session state from hooks #22 by @tobby168 — I have a separate comment going there about the hook-script Windows compat (fcntlmsvcrt).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants