Merge pull request #51 from soapiece/fix/macos-daemon-retrieve-connected
This commit is contained in:
+145
-23
@@ -157,6 +157,120 @@ async def scan_for_device() -> str | None:
|
|||||||
return 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:
|
async def poll_api(token: str) -> dict | None:
|
||||||
headers = dict(API_HEADERS_TEMPLATE)
|
headers = dict(API_HEADERS_TEMPLATE)
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
@@ -226,15 +340,17 @@ class Session:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def connect_and_run(address: str, stop_event: asyncio.Event) -> bool:
|
async def connect_and_run(target, stop_event: asyncio.Event) -> bool:
|
||||||
"""Connect to a known address and poll until disconnected or stopped.
|
"""Connect to a target and poll until disconnected or stopped.
|
||||||
|
|
||||||
Returns True if the connection was used successfully (so the caller
|
``target`` is either an address string (Linux) or a BLEDevice carrying
|
||||||
keeps the cached address), False if the connection failed and the
|
live CoreBluetooth details (macOS). Returns True if the connection was
|
||||||
cache should be invalidated.
|
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}...")
|
display = target if isinstance(target, str) else target.address
|
||||||
client = BleakClient(address)
|
log(f"Connecting to {display}...")
|
||||||
|
client = BleakClient(target)
|
||||||
try:
|
try:
|
||||||
await client.connect()
|
await client.connect()
|
||||||
except (BleakError, asyncio.TimeoutError) as e:
|
except (BleakError, asyncio.TimeoutError) as e:
|
||||||
@@ -299,25 +415,31 @@ async def main() -> None:
|
|||||||
log(f"Poll interval: {POLL_INTERVAL}s")
|
log(f"Poll interval: {POLL_INTERVAL}s")
|
||||||
|
|
||||||
backoff = 1
|
backoff = 1
|
||||||
|
skip_addr: str | None = None # macOS: a peripheral to skip for one cycle
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
address = load_cached_address()
|
# Apply any pending skip exactly once, then clear it so the next
|
||||||
if not address:
|
# cycle re-tries retrieveConnected (the device may have recovered).
|
||||||
address = await scan_for_device()
|
target = await discover_target(skip_addr=skip_addr)
|
||||||
if address:
|
skip_addr = None
|
||||||
save_address(address)
|
if not target:
|
||||||
else:
|
log(f"Device not found, retrying in {backoff}s...")
|
||||||
log(f"Device not found, retrying in {backoff}s...")
|
try:
|
||||||
try:
|
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
|
||||||
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
|
except asyncio.TimeoutError:
|
||||||
except asyncio.TimeoutError:
|
pass
|
||||||
pass
|
backoff = min(backoff * 2, 60)
|
||||||
backoff = min(backoff * 2, 60)
|
continue
|
||||||
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 not ok:
|
||||||
log("Invalidating cached address")
|
if sys.platform == "darwin":
|
||||||
SAVED_ADDR_FILE.unlink(missing_ok=True)
|
# 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:
|
try:
|
||||||
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
|
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
|||||||
@@ -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