Skip to content

Bluetooth transport for the Car Thing - #152

Open
edward-rosado wants to merge 6 commits into
ItsRiprod:mainfrom
edward-rosado:feature/bluetooth-transport
Open

Bluetooth transport for the Car Thing#152
edward-rosado wants to merge 6 commits into
ItsRiprod:mainfrom
edward-rosado:feature/bluetooth-transport

Conversation

@edward-rosado

Copy link
Copy Markdown
Contributor

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

Car Thing                                   Computer
─────────                                   ────────
DeskThing client ──TCP 127.0.0.1:8891──► btmux.py ══RFCOMM ch 3══► btbridge ──TCP 127.0.0.1:8891──► DeskThing server
      │                                     ▲                          │
      │ pairing PIN overlay                 │                          └── control API 127.0.0.1:8899
      └──── GET 127.0.0.1:8892/pairing ── btagent.py                       (consumed by the server, surfaced over typed IPC)

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).

  • Frame protocol: type(1) streamID(4 BE) len(2 BE) + payload — OPEN/DATA/CLOSE, streams originate device-side. Documented in bt_source/README.md, locked by golden-vector tests shared across all implementations.
  • Transport arbitration: while the RFCOMM link is up, the helper removes adb reverse tcp:8891 so 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.
  • No binaries in git: each helper is compiled (or shipped as source) by bt_source/build-btbridge.js during the build.

Pairing — the flow the Car Thing shipped with

  1. DeskThing asks: Setup → Bluetooth → Scan → Pair.
  2. The device's screen shows a 6-digit code (the on-device pairing agent serves it locally; the client draws it fullscreen).
  3. The person confirms the same code in DeskThing. Done.
  4. From then on the device reconnects by itself: power it anywhere in range and it comes back — the helper retries every 10s, forever.

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:

OS Implementation Build
macOS Swift over IOBluetooth (bt_source/btbridge.swift) swiftc at build time
Linux Python over BlueZ (bt_source/linux/btbridge) shipped as source, no compile
Windows C over Winsock RFCOMM + Win32 Bluetooth auth API (bt_source/win/btbridge.c) cl/clang/gcc at build time; skipped (USB-only) when no compiler

Tested 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

  • A pairing wizard on the Bluetooth setup page (scan → code → confirm), one-time USB provisioning with a live step checklist, and unpair.
  • A Connection Type card in device details (which link is live, preference toggle).
  • An always-visible Bluetooth chip in the top bar while the link carries data.
  • On platforms without a helper all of it hides — plain USB setups look unchanged.

Numbers

  • The link saturates at ~155 KB/s (~1.25 Mbps); macOS caps the RFCOMM MTU at 667 bytes, so the mitigations are payload-side: websocket perMessageDeflate (this PR) and smaller album art (companion client PR).
  • Verified on an arm64 Mac with a flashed Car Thing on wall power (USB unplugged): client connected over Bluetooth, 324 KB transfer checksum-verified at 151 KB/s, device services survive reboot (supervisord, self-healing).

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

@edward-rosado

Copy link
Copy Markdown
Contributor Author

Companion client PR (pairing-code overlay on the device screen, transport badge, smaller album art): ItsRiprod/DeskThing-Client#31

edward-rosado and others added 4 commits August 5, 2026 09:16
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>
@edward-rosado
edward-rosado force-pushed the feature/bluetooth-transport branch from 42cf20f to 1e2c549 Compare August 5, 2026 13:17
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>
@edward-rosado

Copy link
Copy Markdown
Contributor Author

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

Copy link
Copy Markdown
Contributor Author

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. 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 — so on those platforms the link could never survive 15 seconds. Come up, go silent, torn down, reconnect, repeat.

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. btagent.py holds pairable on/discoverable on for the lifetime of the service and auto-accepts the passkey after 5s, then trusts the peer permanently; btmux.py accepts any RFCOMM peer without comparing the address. That's a deliberate tradeoff — the Car Thing is headless and cannot confirm a pairing itself, so the human confirms computer-side — but it does mean an unattended device can be bonded by a radio in range. Bounding it to a setup window plus pinning the peer address is a contained change; I'd rather land it as its own reviewed commit than slip it in here. Happy to do that in this PR if you'd prefer it before merge.

@edward-rosado

Copy link
Copy Markdown
Contributor Author

Follow-up ready for review: two-way tunnel (protocol v2)edward-rosado#1

It's opened against this branch rather than upstream main, because GitHub can't host a stacked PR whose base only exists in a fork. That keeps the diff clean — just the v2 work, none of #152's commits. Once this PR lands I'll retarget it to main here.

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 cdp service forwards the device's chromium debugger to a loopback port, so chrome://inspect debugs the Car Thing over Bluetooth with no cable — and it's the only route to a screenshot of a device whose firmware has no screencap.

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 inbound: false, offers no services, and carries Spotify exactly as before.

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 127.0.0.1:8891 was the only thing between the device and everything on the computer's loopback. The device is not a router, so 127.0.0.1:5037 is inexpressible rather than filtered. Port 8891 is permanently excluded: an inbound stream there would loop back over the link and eat the whole radio.

36 vitest + 59 Python tests. Windows is clang-type-checked against stub Win32 headers; Linux and Windows remain hardware-untested, as noted above.

@edward-rosado

Copy link
Copy Markdown
Contributor Author

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 cdp service because the device firmware has no screencap.

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:

  • Linux and Windows are untested on hardware. They implement the same test-locked protocol, and the Windows helper is clang-typechecked against stub Win32 headers (-Wall -Wextra clean), which catches signature errors but is not a substitute for running it. macOS is the only one exercised end-to-end.
  • The two-way tunnel work (protocol v2, inbound streams, the cdp service that makes the screenshots possible) is not in this PR — it is stacked on top and I'd rather you review this one on its own.

Happy to split anything out if it would make review easier.

@edward-rosado

Copy link
Copy Markdown
Contributor Author

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

PR What it is
1 #153 Track changes take ~11s median to reach any client. Server-side only, no Bluetooth involvement, helps everyone. 18 tests. Probably the easiest to evaluate and the widest benefit.
2 ItsRiprod/Deskthing-Apps#35 Spotify rate limiting: honor Retry-After globally, and stop the request queue answering with an already-finished request.
3 ItsRiprod/DeskThing-Client#31 Ask for the current track on connect (screen used to sit on "Waiting For Track…" until the song changed), faster reconnect, smaller artwork.
4 this PR The Bluetooth transport itself. The largest, and the only one that needs hardware to fully evaluate.

#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.

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.

1 participant