Merge pull request #51 from soapiece/fix/macos-daemon-retrieve-connected
This commit is contained in:
+136
-14
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user