Workbench
My Bluetooth Approval Button Broke When Nothing Was Happening

My Bluetooth approval button broke when nothing was happening. On Windows, the device could receive its clock and then stay asleep. After that was fixed, an idle system disconnected about once a minute. One relay later sat for roughly 26 minutes without making progress. Every failure came from treating silence as if it meant the connection was dead.
The code is public in ttpears/buddy-bridge. I maintain the bridge that gathers Claude Code session events from multiple machines and sends them to an M5StickC Plus running the companion firmware. The project is independent and unofficial; it is not affiliated with or endorsed by Anthropic.
The object on my desk is part display, part remote
The hardware is a small ESP32-based M5StickC Plus with a screen and two useful buttons. The firmware can show whether sessions are busy, idle, or waiting on permission. When an opted-in Claude Code session asks to run a tool, the front button approves that one request and the side button denies it.
buddy-bridge connects that single device to more than one computer. A client hook reports session events to a hub. The hub keeps the current state and permission queue. A relay holds the Bluetooth Low Energy connection to the stick. The relay can run on Linux, Windows, or an Android phone, while the hub may stay on a server.
Claude Code hooks
↓
client → HTTP hub → streaming HTTP relay → Bluetooth → M5Stick
↑ ↓
└──────── one-time approve / deny ─────┘
The web dashboard mirrors the state and approval controls, so the bridge still works without the physical stick. The hardware is the interesting constraint, though: it has one BLE owner, a small battery, and no reason to churn connections while the system is idle.
Windows delivered the clock and nothing after it
The first failure looked like a firmware problem. The stick connected and received the initial time-setting message, so discovery, pairing, and the first write had all worked. The pet still stayed asleep because the heartbeat frames that followed never arrived.
The relay split each newline-delimited JSON message into chunks sized for the Bluetooth connection. It wrote those chunks with response=False, the unacknowledged form of the GATT write. On the Windows WinRT backend, flow control could hang silently after the first packet. The call path did not produce a useful error; the clock on the device made the connection look healthier than it was.
The first relay fix switched those chunks to acknowledged writes. Each write now uses response=True and has a five-second timeout. Acknowledgement costs a little throughput, but a tiny status heartbeat does not need bulk-transfer speed. It needs a clear answer about whether the receiver accepted each chunk.
I also added a log message after the first complete heartbeat reaches the stick. “Connected” was too early to be the success condition. The useful checkpoint is the first application-level message delivered after pairing.
The first watchdog punished a healthy idle system
A watchdog then exposed a different mistake. If no line arrived from the hub for 45 seconds, the relay reopened the connection. That sounds reasonable until the producer is considered.
The hub deliberately deduplicates identical heartbeats. When sessions are quiet and the displayed state has not changed, there is no value in repeatedly sending the same payload. The hub also has a keepalive floor so the firmware does not go indefinitely without a frame.
The server-side stream handler waited on a queue with the same boundary. When the queue produced no item before that wait expired, Python raised queue.Empty. The handler treated that ordinary idle timeout like the end of the stream and closed the connection.
The result was a race between two correct-looking timers. Deduplication kept the queue quiet; the queue wait expired at the keepalive boundary; the relay saw no hub data and reconnected. An idle system flapped about once a minute because it was behaving exactly as designed.
The current hub fix gives an empty queue its actual meaning. The stream remains open and the handler builds and sends the current heartbeat as a keepalive. A broken pipe or reset still ends the request. A period with no changed state does not.
try:
obj = client.get(timeout=KEEPALIVE_FLOOR_SEC)
except queue.Empty:
obj = hub.build_heartbeat()
# Empty queue: send current truth, do not close the stream.
This fix let the relay return to shorter failure-detection windows. There was no longer a reason to hide the race by stretching the watchdog from seconds into minutes.
Reopening HTTP should not tear down Bluetooth
The idle reconnects had another cost. The first relay loop made the HTTP stream and the Bluetooth link one unit of work. If the stream ended, the whole loop exited and BLE disconnected too.
That meant a harmless proxy reset or idle timeout churned the radio, woke the stick, used more battery, and created a gap in which a new permission prompt could be missed. The transport layers had different failure boundaries but the program gave them one lifetime.
The more serious version appeared in the logs as a relay that said it was reconnecting and then made no progress for about 26 minutes. The blocking HTTP reader had been sent to the shared asyncio executor with no socket timeout. A wedged read could outlive the stream that created it. Enough leaked readers could consume the executor’s workers and stall the supervision loop that was supposed to recover them.
The relay redesign separated those lifetimes. The outer scope establishes BLE once. An inner loop opens and pumps the HTTP stream. If the hub stream reaches EOF, goes quiet, or fails to open, only that stream is replaced. The stick remains connected.
The blocking reader now runs in its own daemon thread with a finite socket timeout and a stop event. It always puts a None sentinel into the asyncio queue when it exits, even after an exception, so the consumer has a defined way to wake up. Teardown closes the response and gives the thread a bounded join instead of waiting forever.
Bluetooth operations have boundaries too: connect, disconnect, and every write use explicit timeouts. A stream failure keeps BLE. A failed BLE write drops BLE and starts a clean full reconnect. Fast failures opening the hub stream receive exponential backoff; a normal stream that ran for a while is reopened promptly.
Remote approval needs a narrow failure mode
A physical approval button is convenient only if a bridge failure does not silently approve anything. Control is opt-in per Claude Code session: launching through buddy sets BUDDY_CONTROL=1. A normal claude session still reports ambient status but keeps its ordinary local permission prompt.
For a controlled session, the hook registers the permission with the hub and long-polls for a decision. The hub assigns an ID, keeps prompts in FIFO order, and routes a button response to that ID. “Approve” means allow this request once; the bridge does not create a standing permission.
If the hub is unreachable, returns an odd response, or times out, the hook exits without an allow or deny instruction. Claude Code then falls back to its normal prompt. The transport failing closed would strand work; failing open would be unsafe. Falling back to the original local decision keeps the failure visible without inventing consent.
A shared token gates the event, permission, decision, relay, state, and dashboard routes when the hub is exposed beyond one machine. The comparison uses a constant-time check. The deployment example refuses to start a public hub without a token and puts it behind HTTPS. On a LAN, leaving authentication off is possible, but it is not appropriate for an internet-facing approval service.
What I verified for this article
I checked the current 0.2.1 source at commit f01def2, the three relay commits above, the hook and hub decision paths, the deployment files, and the release workflow.
I created an isolated Python 3.13 environment on Windows, installed the project in editable mode with its development and BLE extras, and ran the complete test suite. All 59 tests passed in 2.31 seconds. The tests cover the hub routes and token gate, prompt routing, stream priming and displacement, heartbeat deduplication, reader teardown, service and hook installation, configuration, Windows tray behavior, and packaging entry points.
The first run reported two packaging failures because I invoked the virtual environment’s Python executable without putting its Scripts directory on PATH. The package was installed correctly, but tests that intentionally call buddyctl by name could not find it. Activating that same environment—the way the test expects—produced the clean 59-test result. I did not hide the first run by changing the code.
The current main-branch GitHub Actions run also passed under Python 3.12. The latest published release is 0.2.1, with a Windows bundle, Android APK, and SHA-256 checksums. I did not pair or battery-test the physical stick during this review, so the runtime observations above come from the project’s recorded commit evidence, not a new hardware measurement.
The quiet path is part of the protocol
Most connection tests exercise change: connect, send something, receive something, disconnect. The bug lived in the opposite condition. Nothing changed, so deduplication worked. No prompt arrived, so the approval queue stayed empty. The system then destroyed a healthy connection because silence had not been modeled as a valid state.
The repair was not one larger timeout. It was deciding what each layer owns. The hub owns truthful keepalives. HTTP can reconnect without touching BLE. Bluetooth failures cause Bluetooth recovery. A missing remote decision returns control to the local prompt.
Once those boundaries were explicit, an idle desk pet could finally just sleep.
Disclosure: I maintain buddy-bridge and the companion firmware fork. Both are open-source under the MIT license. The firmware descends from Anthropic’s Hardware Buddy example, and the project credits ToxicOrca for the Android bridge, token authentication, Windows wrappers, and battery-related work. Repository and hardware links are not affiliate links. The hero is a project photograph from the firmware repository.