Skip to content
Hack Your WorldSoftware · Infrastructure · Home automation

Analysis

Fixing Bluetooth Disconnects During Idle Streams

An idle Bluetooth stream is not necessarily a dead connection. Track transport health separately from application activity, avoid watchdogs that punish silence, and reproduce the quiet path long enough to catch reconnect and progress failures.

M5StickC Plus running the Hardware Buddy firmware on a desk
Project photograph from the companion firmware repository.

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.

In this article

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 response for each GATT write. Bleak documents that distinction; it does not establish that the firmware parsed the assembled JSON or updated the display.

The relay also logs “first heartbeat delivered to the stick” after the first line’s chunks have been written successfully. The counter does not parse that line to establish that it is a heartbeat, and it does not wait for an application acknowledgement from the firmware. I would use a sequence number echoed by the receiver, or an observed display update, to test that stronger claim.

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

Connect, disconnect, and each write use explicit timeouts. The reviewed code does not wrap pair() or an individual start_notify() call in asyncio.wait_for. The notification retry deadline is checked after an exception; it cannot itself interrupt a call that never returns. 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 the source and tests establish

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 original review inspected the 0.2.1 release and its CI evidence; those are historical observations, not a claim about today’s latest release. On October 10 I reran all 59 tests at revision 6534d0f7 in an isolated Python 3.13 environment. They passed. The suite does not directly force the empty-queue timeout or prove Bluetooth survival across an HTTP reconnect. Those need additional fixtures. I did not pair or battery-test the physical stick; the hardware observations above come from the recorded project history.

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 descend from Anthropic’s Hardware Buddy example.

Correction, October 10, 2026: Successful GATT writes and the relay’s first-line log do not prove firmware-level delivery. The timeout and test-coverage descriptions now state the limits of the reviewed implementation.