Fix macOS BLE daemon: connect to HID-held device via retrieveConnected

On macOS the firmware is auto-connected by the OS as a BLE HID keyboard,
and CoreBluetooth excludes already-connected peripherals from scan
results. bleak's connect-by-address path also scans internally, so the
daemon's scan loop never found the device ("Device not found" forever)
even though it was plainly visible in System Settings.

Discover the target on macOS via CoreBluetooth's
retrieveConnectedPeripheralsWithServices_ and connect to the returned
peripheral directly (no scan). The custom service UUID is matched first
(unambiguous); the generic HID service 0x1812 is only trusted on an exact
DEVICE_NAME match so it can't grab an unrelated keyboard/mouse. A
peripheral that fails to connect is skipped for one cycle so the scan
fallback stays reachable. The device's two connection slots let the OS
HID link and the daemon run simultaneously.

Linux/BlueZ path is unchanged (still scans + caches address); the
redundant per-reconnect address save was removed so caching happens only
on a fresh scan.

Adds daemon/test_macos_connect.py, a foreground smoke test for the macOS
connect path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
soapiece
2026-05-30 21:32:33 -07:00
co-authored by Claude Opus 4.8
parent 6b5314ef28
commit 18282e0a93
2 changed files with 201 additions and 23 deletions
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Quick end-to-end test of the macOS connected-peripheral path.
Discovers the HID-held 'Claude Controller', connects without scanning,
finds the custom GATT characteristics, and writes one test payload.
Run from Terminal.app (which has Bluetooth permission):
cd daemon && ./.venv/bin/python ./test_macos_connect.py
"""
import asyncio
from bleak import BleakClient
import claude_usage_daemon as d
async def main() -> None:
d.log("Discovering target via macOS connected-peripheral path...")
target = await d.discover_target()
if not target:
d.log("FAIL: no target found (device powered on and showing splash?)")
return
display = target if isinstance(target, str) else f"{target.name} [{target.address}]"
d.log(f"Target: {display}")
client = BleakClient(target)
d.log("Connecting (should NOT scan)...")
await client.connect()
if not client.is_connected:
d.log("FAIL: connected=False")
return
d.log("Connected. Enumerating services...")
# List services/chars so we can confirm the custom service is reachable.
found_rx = False
for service in client.services:
for ch in service.characteristics:
if ch.uuid.lower() == d.RX_CHAR_UUID.lower():
found_rx = True
d.log(f"RX characteristic present: {found_rx}")
if found_rx:
payload = '{"s":42,"sr":120,"w":17,"wr":4320,"st":"ok_test","ok":true}'
d.log(f"Writing test payload: {payload}")
await client.write_gatt_char(d.RX_CHAR_UUID, payload.encode(), response=False)
d.log("PASS: wrote test payload — check the device screen.")
else:
d.log("FAIL: custom RX characteristic not found on the peripheral.")
await client.disconnect()
d.log("Disconnected. Done.")
if __name__ == "__main__":
asyncio.run(main())