Merge pull request #51 from soapiece/fix/macos-daemon-retrieve-connected

This commit is contained in:
Hermann Björgvin
2026-05-31 12:18:07 +00:00
committed by GitHub
2 changed files with 201 additions and 23 deletions
+136 -14
View File
@@ -157,6 +157,120 @@ async def scan_for_device() -> str | None:
return None
# --- macOS: recover a device the OS already holds as an HID keyboard --------
#
# The firmware advertises as a BLE HID keyboard so its buttons type into the
# Mac. macOS auto-connects to that HID, and CoreBluetooth then EXCLUDES the
# peripheral from BleakScanner.discover() results (already-connected devices
# never appear in scans). bleak's connect-by-address path also scans
# internally, so a cached address can't help either. The documented escape
# hatch is retrieveConnectedPeripheralsWithServices_, which returns
# peripherals the system is already connected to. We wrap the result in a
# BLEDevice carrying the live (peripheral, manager) details so BleakClient
# connects to it directly without scanning. CoreBluetooth shares the single
# physical link, so this rides the existing HID connection — the keyboard
# keeps working.
_cb_manager = None # reused CentralManagerDelegate (CoreBluetooth)
async def _get_cb_manager():
"""Lazily create and ready a shared CoreBluetooth central manager."""
global _cb_manager
if _cb_manager is None:
from bleak.backends.corebluetooth.CentralManagerDelegate import (
CentralManagerDelegate,
)
mgr = CentralManagerDelegate()
await mgr.wait_until_ready() # raises if Bluetooth is unauthorized/off
_cb_manager = mgr
return _cb_manager
async def retrieve_connected_macos(skip_addr: str | None = None):
"""Return a BLEDevice for a system-connected 'Claude Controller', or None.
Two-step lookup, strongest signal first:
1. Peripherals connected under our CUSTOM service UUID. Membership in
that service is unambiguous (no other device exposes it), so we accept
by service alone — the peripheral's name can be None on macOS.
2. Fall back to the generic HID service 0x1812, but ONLY trust a
peripheral whose name matches DEVICE_NAME. 0x1812 also matches
unrelated keyboards/mice, so picking blindly here could grab the
wrong device.
``skip_addr`` skips a peripheral whose UUID just failed to connect, so a
stale CoreBluetooth handle can't trap us into never trying a fresh scan.
"""
from CoreBluetooth import CBUUID
from bleak.backends.device import BLEDevice
try:
manager = await _get_cb_manager()
except Exception as e: # BleakBluetoothNotAvailableError etc.
log(f"CoreBluetooth unavailable: {e}")
return None
cm = manager.central_manager
def _wrap(p):
addr = p.identifier().UUIDString()
log(f"Found system-connected peripheral: {p.name()!r} [{addr}]")
return BLEDevice(addr, p.name(), (p, manager))
def _ok(p) -> bool:
return not (skip_addr and p.identifier().UUIDString() == skip_addr)
# 1. Custom service — accept by service membership alone.
custom = cm.retrieveConnectedPeripheralsWithServices_(
[CBUUID.UUIDWithString_(SERVICE_UUID)]
)
for p in custom or []:
if _ok(p):
return _wrap(p)
# 2. Generic HID service — require an exact name match.
hid = cm.retrieveConnectedPeripheralsWithServices_(
[CBUUID.UUIDWithString_("1812")]
)
for p in hid or []:
if _ok(p) and p.name() == DEVICE_NAME:
return _wrap(p)
return None
async def discover_target(skip_addr: str | None = None):
"""Return a connectable target, or None.
macOS: prefer the system-connected peripheral (HID-grabbed devices are
invisible to scans); fall back to a normal scan that yields a BLEDevice
so the subsequent connect doesn't have to re-scan. ``skip_addr`` is
forwarded so a just-failed peripheral is skipped, making the scan
fallback reachable.
Other platforms: keep the original cached-address / scan-by-name flow.
A freshly scanned address is cached here (the only place it's saved).
"""
if sys.platform == "darwin":
dev = await retrieve_connected_macos(skip_addr=skip_addr)
if dev is not None:
return dev
log(f"Not held by OS; scanning for '{DEVICE_NAME}' ({SCAN_TIMEOUT}s)...")
dev = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=SCAN_TIMEOUT)
if dev:
log(f"Found: {dev.address}")
return dev
address = load_cached_address()
if not address:
address = await scan_for_device()
if address:
save_address(address) # cache only freshly-scanned addresses
return address
async def poll_api(token: str) -> dict | None:
headers = dict(API_HEADERS_TEMPLATE)
headers["Authorization"] = f"Bearer {token}"
@@ -226,15 +340,17 @@ class Session:
return False
async def connect_and_run(address: str, stop_event: asyncio.Event) -> bool:
"""Connect to a known address and poll until disconnected or stopped.
async def connect_and_run(target, stop_event: asyncio.Event) -> bool:
"""Connect to a target and poll until disconnected or stopped.
Returns True if the connection was used successfully (so the caller
keeps the cached address), False if the connection failed and the
cache should be invalidated.
``target`` is either an address string (Linux) or a BLEDevice carrying
live CoreBluetooth details (macOS). Returns True if the connection was
used successfully (so the caller keeps the cached address), False if the
connection failed and the cache should be invalidated.
"""
log(f"Connecting to {address}...")
client = BleakClient(address)
display = target if isinstance(target, str) else target.address
log(f"Connecting to {display}...")
client = BleakClient(target)
try:
await client.connect()
except (BleakError, asyncio.TimeoutError) as e:
@@ -299,13 +415,13 @@ async def main() -> None:
log(f"Poll interval: {POLL_INTERVAL}s")
backoff = 1
skip_addr: str | None = None # macOS: a peripheral to skip for one cycle
while not stop_event.is_set():
address = load_cached_address()
if not address:
address = await scan_for_device()
if address:
save_address(address)
else:
# Apply any pending skip exactly once, then clear it so the next
# cycle re-tries retrieveConnected (the device may have recovered).
target = await discover_target(skip_addr=skip_addr)
skip_addr = None
if not target:
log(f"Device not found, retrying in {backoff}s...")
try:
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
@@ -314,8 +430,14 @@ async def main() -> None:
backoff = min(backoff * 2, 60)
continue
ok = await connect_and_run(address, stop_event)
addr = target if isinstance(target, str) else target.address
ok = await connect_and_run(target, stop_event)
if not ok:
if sys.platform == "darwin":
# No string cache to drop; instead skip this stale handle on
# the next retrieveConnected so the scan fallback is reachable.
skip_addr = addr
else:
log("Invalidating cached address")
SAVED_ADDR_FILE.unlink(missing_ok=True)
try:
+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())