Bluetooth transport for the Car Thing - #152
Conversation
|
Companion client PR (pairing-code overlay on the device screen, transport badge, smaller album art): ItsRiprod/DeskThing-Client#31 |
macOS removed Bluetooth PAN, so there is no IP-over-Bluetooth to lean on. Instead, a small TCP multiplexer runs over a single RFCOMM serial channel: btmux.py owns 127.0.0.1:8891 on the device (the endpoint the client already talks to) and frames its TCP streams across the radio to a helper on the computer, which replays them onto the local DeskThing server and serves a control API for the UI. After one-time provisioning the device only needs power and reconnects on its own on every boot — verified by cold-booting a flashed Car Thing: it re-establishes the Bluetooth link and resumes streaming Spotify with zero intervention. The device mux binds a real AF_BLUETOOTH RFCOMM socket (no rfcomm-binary tty bindings to leak) and a PING/PONG heartbeat on both sides tears down a half-open link so reconnects always recover cleanly. Pairing works the way the Car Thing originally did: the computer initiates, the device's screen shows a 6-digit code (btagent.py serves it locally for the client to draw), and the person confirms the same code. Helpers clear stale half-bonds before pairing fresh. Every supported OS gets a helper speaking the same frame protocol and control API: macOS in Swift over IOBluetooth (compiled at build time by bt_source/build-btbridge.js — no binaries in git), Linux in Python over BlueZ, Windows in C over Winsock RFCOMM + the Win32 Bluetooth authentication API. Python golden-vector tests lock the frame protocol across implementations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The main process owns the helper's lifecycle through a small manager facade: bridgeProcess starts and stops the packaged helper with the app, bridgeClient wraps its local control API, and the provisioner turns a USB-connected Car Thing into a Bluetooth-ready one — installing the mux and the pairing agent as supervisord services and reporting the device's radio address back so the pairing wizard knows who to talk to. Platforms without a helper get a stub manager that reports unsupported. The renderer reaches all of it through a typed BLUETOOTH IPC domain. Vitest suites cover the IPC dispatch, the manager's degradation when the helper is missing or unreachable, the control-API client, and the provisioner step machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Bluetooth setup page becomes a pairing wizard: scan, pick the Car Thing, watch the same 6-digit code appear on the device's screen and in this dialog, confirm, done — plus one-time USB provisioning with a live step checklist, and unpair. Device details gain a Connection Type card with a Bluetooth/USB preference toggle, and the top bar shows an always-visible Bluetooth chip while the link is live. Everything hides itself on platforms without a bridge or when the helper isn't running. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Song-state updates dominate client traffic and are highly repetitive; perMessageDeflate shrinks them substantially, which matters on the ~155 KB/s Bluetooth link and costs nothing on faster transports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
42cf20f to
1e2c549
Compare
Setup and everyday-use instructions: one-time USB provisioning, pairing with the code shown on the device screen, going wireless, the transport indicators, and troubleshooting. Complements the developer-oriented bt_source/README.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Related PRs from the same work:
|
The heartbeat added during reboot-survival work only ever landed on the device mux and the macOS helper. btmux.py pings every 5s and drops the link after 15s without a PONG, and neither linux/btbridge nor win/btbridge.c handled frame type 4 at all — so on those platforms the link could never survive 15 seconds. It would come up, go silent, get torn down, reconnect, and repeat forever. Both now answer PING with PONG, track inbound PONGs, and run their own outbound heartbeat so a half-open link is torn down locally instead of lingering — matching the macOS helper and the device. Tests: PING/PONG golden vectors join the cross-implementation contract, plus a functional suite driving the Linux helper's real frame pump against a fake socket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed a fix for a bug I found while designing the follow-up: the Linux and Windows helpers never answered PING. The heartbeat added during reboot-survival work only landed on the device mux and the macOS helper. Both now answer PING, track PONGs, and run their own outbound heartbeat so a half-open link gets torn down locally. PING/PONG golden vectors joined the cross-implementation contract, plus a functional suite that drives the Linux helper's real frame pump against a fake socket (PING answered, PONG refreshes liveness, silent peer torn down, unknown frame type doesn't desync the following stream). Two things I want to flag honestly for reviewers, neither of which I've changed here: 1. Linux/Windows are untested on hardware. They implement the same test-locked protocol, and this fix removes the one defect I could prove by reading, but I only have a Mac and a Car Thing. Treat those two helpers as needing a hardware review before anyone relies on them. 2. The device's pairing window is unbounded. |
|
Follow-up ready for review: two-way tunnel (protocol v2) — edward-rosado#1 It's opened against this branch rather than upstream What it does: v1 is one-directional — only the device can open a stream, always to one hardcoded destination. v2 lets either side open, and an OPEN carries a target descriptor. Stream IDs are split by their high bit so both ends allocate without coordinating; HELLO negotiates version, capabilities, and hands the device a clock (it has no RTC, and a wrong clock fails every TLS handshake it makes later in ways that look like a tunnel bug). The payoff is immediate: the Backward compatibility is by construction and hardware-verified: empty OPEN keeps its v1 meaning, a peer that never HELLOs is treated as v1, and unknown frame types are skipped rather than resetting the receive buffer (the Swift helper previously wiped it, which would have discarded every other stream's in-flight bytes — fixed there). I ran a v2 helper against my still-v1 device: it reports Security posture: default-deny named services enforced independently on both ends. That isn't extra hardening — it replaces a protection this PR removes, since the hardcoded 36 vitest + 59 Python tests. Windows is clang-type-checked against stub Win32 headers; Linux and Windows remain hardware-untested, as noted above. |
|
Status update, and some hardware results that are relevant to reviewing this. The transport is running unplugged. Everything below was measured over the Bluetooth link with no USB cable attached — including the screenshots, which come back through the tunnel's inbound Two things I want to be explicit about, because they change how you might read this PR: 1. The radio link is not the bottleneck, and I can now prove it. I measured the same track change at both ends in the same epoch: the server pushes, and the device repaints −0.27s later — zero, within clock skew. Every user-visible delay people attribute to "it's over Bluetooth" was elsewhere. Specifically it was two bugs with nothing to do with transport, now fixed in #153 (server-side track-boundary detection) and ItsRiprod/Deskthing-Apps#35 (a request queue answering with an already-finished request). A track change went from a median of ~11s to about a second, and none of that was the link. 2. PING/PONG is answered on all three helpers now. The Linux and Windows helpers implemented the framing but never answered a heartbeat, so the link could not survive 15s on those platforms. Fixed and covered by the cross-implementation test vectors. Current state: 38 vitest + 75 Python, all passing, typecheck clean. Honest limitations, unchanged:
Happy to split anything out if it would make review easier. |
|
@ItsRiprod — requesting review on this and three related PRs when you have time. GitHub won't let me add you as a reviewer formally from a fork, hence the mention. They are independent and can be taken in any order, but this is the order I'd suggest, easiest first:
#153 and #35 came out of investigating a complaint about this PR — "the screen is slow over Bluetooth". It turned out the link was not responsible at all: server push to device repaint measures −0.27s, i.e. zero within clock skew. I'd rather send you the two real fixes separately than have them buried in a transport PR. Happy to split, rebase, or drop anything that isn't worth your time — including this one. No urgency. |
Bluetooth transport for the Car Thing
Why
The Car Thing needs two cables today: power and data. This PR removes the data cable — after one-time setup, the device connects to the DeskThing server over Bluetooth every time it powers up, and only needs power.
There is no free lunch to get there: Apple removed Bluetooth PAN from macOS, so IP-over-Bluetooth doesn't exist. Instead this adds a small TCP multiplexer running over a single RFCOMM serial channel.
Architecture
The client already talks to
localhost:8891; when Bluetooth is up, the device-side mux simply becomes that endpoint. The tunnel itself requires zero client changes (a companion client PR adds the pairing-code overlay and a transport badge).type(1) streamID(4 BE) len(2 BE)+ payload — OPEN/DATA/CLOSE, streams originate device-side. Documented inbt_source/README.md, locked by golden-vector tests shared across all implementations.adb reverse tcp:8891so Bluetooth carries the traffic; when the link drops (or the user pins USB), it restores the reverse. Preference + paired device persist in the platform's app-data dir.bt_source/build-btbridge.jsduring the build.Pairing — the flow the Car Thing shipped with
Stale half-bonds (one side paired, the other not) make hosts abort silently right after encryption — a genuinely nasty failure mode we hit repeatedly during development. Every helper therefore removes any existing bond before pairing fresh, and the UI exposes unpair.
Cross-platform
Every OS DeskThing supports gets a helper speaking the same control API, so the server and UI are platform-blind:
bt_source/btbridge.swift)swiftcat build timebt_source/linux/btbridge)bt_source/win/btbridge.c)cl/clang/gccat build time; skipped (USB-only) when no compilerTested end-to-end on macOS with real hardware. The Linux and Windows helpers implement the identical, test-locked contract but have not been exercised on hardware — flagging that plainly for review.
What the user sees
Numbers
perMessageDeflate(this PR) and smaller album art (companion client PR).Tests
npm run test:bt— vitest suites for the IPC dispatch, the manager facade (including degradation when the helper is missing/unreachable), the control-API client, and the provisioner step machine; Python suites for the frame-protocol golden vectors (the cross-implementation contract) and the pairing agent's bluetoothctl parsing and state lifecycle.Note for macOS users
First launch after install triggers the system Bluetooth permission prompt, attributed to DeskThing. Until it's accepted, the helper waits and USB continues to work.
🤖 Generated with Claude Code