From f252a871c43b6041b0895a69f39b24d06a10354b Mon Sep 17 00:00:00 2001 From: tobby168 Date: Sun, 17 May 2026 16:53:54 -0700 Subject: [PATCH] Make BLE pairing work on macOS, allow daemon + HID to coexist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS Bluetooth quirks made the original BLE setup unusable from a Mac host. Five focused changes to fix discovery, pairing, the keyboard identification wizard, and concurrent connections, plus a couple of cross-platform daemon/script niceties. Firmware (ble.cpp): * Advertise the standard HID Service UUID (0x1812) in the primary packet. Without it, macOS Sequoia's Bluetooth Settings GUI recognizes the device internally but silently hides it from the "Nearby Devices" list. Service UUIDs >16-bit overflow the 31-byte advertising packet, so the custom data-service UUID moved to the scan response. * Switch PnP ID from Apple's USB vendor (0x05AC + Magic Keyboard PID 0x820A) to Espressif's BT SIG vendor (0x02E5). macOS validates Apple-claimed HIDs against known device IDs and refuses to surface a Connect button for spoofers. * Add the LED output report (Num/Caps/Scroll Lock) to the HID descriptor — macOS treats a keyboard descriptor without LEDs as "incomplete" and triggers the Keyboard Setup Assistant repeatedly. * Set HID country code to 33 (US ANSI) instead of 0 (Not Supported) so macOS can identify the layout without asking the user. * Bump CONFIG_BT_NIMBLE_MAX_CONNECTIONS to 2 and restart advertising after each accept. macOS holds one connection for the HID keyboard link; the daemon now gets its own slot for the data service in parallel, instead of either side starving the other. screenshot.sh: auto-pick /dev/cu.usbmodem101 on macOS vs /dev/ttyACM0 on Linux, fall back to PlatformIO's bundled Python if pyserial isn't on the system Python (PEP 668 blocks `pip install` on Homebrew Python), and pass the actual framebuffer dimensions to ffmpeg instead of hardcoding 480x480. daemon/claude_usage_daemon.py: log API HTTP status + response body on 4xx/5xx so silent token-expiry failures (the daemon was reporting {"s":0,"w":0} payloads instead of surfacing a 401) are visible in the daemon log. Co-Authored-By: Claude Opus 4.7 (1M context) --- daemon/claude_usage_daemon.py | 3 ++ firmware/platformio.ini | 6 ++-- firmware/src/ble.cpp | 59 ++++++++++++++++++++++++++++++----- screenshot.sh | 32 +++++++++++++++---- 4 files changed, 84 insertions(+), 16 deletions(-) diff --git a/daemon/claude_usage_daemon.py b/daemon/claude_usage_daemon.py index 4451e8a..8484ba5 100755 --- a/daemon/claude_usage_daemon.py +++ b/daemon/claude_usage_daemon.py @@ -166,6 +166,9 @@ async def poll_api(token: str) -> dict | None: except httpx.HTTPError as e: log(f"API call failed: {e}") return None + if resp.status_code >= 400: + log(f"API HTTP {resp.status_code}: {resp.text[:200]}") + return None def hdr(name: str, default: str = "0") -> str: return resp.headers.get(name, default) diff --git a/firmware/platformio.ini b/firmware/platformio.ini index e87cd30..8d44c1b 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -11,12 +11,14 @@ build_flags = -DBOARD_HAS_PSRAM ; AXP2101 PMU -DXPOWERS_CHIP_AXP2101 - ; NimBLE config — peripheral-only, single connection + ; NimBLE config — peripheral, 2 simultaneous connections so the OS can + ; hold the HID link (Space key from BOOT button) while the daemon holds + ; its own connection for the custom data service. -DCONFIG_BT_NIMBLE_ROLE_BROADCASTER=1 -DCONFIG_BT_NIMBLE_ROLE_PERIPHERAL=1 -DCONFIG_BT_NIMBLE_ROLE_CENTRAL=0 -DCONFIG_BT_NIMBLE_ROLE_OBSERVER=0 - -DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 + -DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=2 ; LVGL config via build flags -DLV_CONF_SKIP -DLV_COLOR_DEPTH=16 diff --git a/firmware/src/ble.cpp b/firmware/src/ble.cpp index 458d6c6..1e03aee 100644 --- a/firmware/src/ble.cpp +++ b/firmware/src/ble.cpp @@ -13,7 +13,10 @@ #define BLE_BUF_SIZE 512 -// HID keyboard report descriptor +// HID keyboard report descriptor (standard 6-KRO boot-protocol-compatible). +// Includes the LED output report (Num/Caps/Scroll Lock indicators) — without +// it macOS's Keyboard Setup Assistant flags the device as "unidentifiable" +// because the descriptor doesn't look like a complete keyboard. static const uint8_t HID_REPORT_MAP[] = { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x06, // Usage (Keyboard) @@ -30,6 +33,16 @@ static const uint8_t HID_REPORT_MAP[] = { 0x95, 0x01, // Report Count (1) 0x75, 0x08, // Report Size (8) 0x81, 0x01, // Input (Constant) - Reserved byte + // LED output report — required for macOS to treat this as a full keyboard. + 0x95, 0x05, // Report Count (5) + 0x75, 0x01, // Report Size (1) + 0x05, 0x08, // Usage Page (LEDs) + 0x19, 0x01, // Usage Minimum (Num Lock) + 0x29, 0x05, // Usage Maximum (Kana) + 0x91, 0x02, // Output (Data, Variable, Absolute) - LED report + 0x95, 0x01, // Report Count (1) + 0x75, 0x03, // Report Size (3) + 0x91, 0x01, // Output (Constant) - LED report padding 0x95, 0x06, // Report Count (6) 0x75, 0x08, // Report Size (8) 0x15, 0x00, // Logical Minimum (0) @@ -58,10 +71,21 @@ static char mac_str[18]; static void start_advertising() { NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); adv->reset(); - adv->addServiceUUID(SERVICE_UUID); + // Primary advertising packet (≤31 bytes): + // flags (3) + appearance (4) + HID service 0x1812 (4) + name "Claude Controller" (19) + // = 30 bytes. macOS Bluetooth Settings only surfaces BLE-only devices + // that explicitly advertise the standard HID service UUID (0x1812) — + // without it the device is recognized internally but hidden from the + // GUI nearby-devices list. adv->setAppearance(HID_KEYBOARD); - adv->enableScanResponse(true); + adv->addServiceUUID(NimBLEUUID((uint16_t)0x1812)); // BLE HID Service adv->setName(DEVICE_NAME); + // Scan response carries the 128-bit custom data-service UUID for active + // scanners (the host daemon scans actively). + NimBLEAdvertisementData scanResp; + scanResp.setCompleteServices(NimBLEUUID(SERVICE_UUID)); + adv->setScanResponseData(scanResp); + adv->enableScanResponse(true); bool ok = adv->start(); state = BLE_STATE_ADVERTISING; Serial.printf("BLE: advertising start=%s\n", ok ? "OK" : "FAILED"); @@ -70,13 +94,23 @@ static void start_advertising() { class ServerCallbacks : public NimBLEServerCallbacks { void onConnect(NimBLEServer* s, NimBLEConnInfo& info) override { state = BLE_STATE_CONNECTED; - Serial.printf("BLE: connected from %s\n", info.getAddress().toString().c_str()); + Serial.printf("BLE: connected from %s (active=%u)\n", + info.getAddress().toString().c_str(), + (unsigned)s->getConnectedCount()); + // Keep advertising while a connection slot is still free so a second + // central (e.g. the host daemon alongside an OS-held HID link) can + // discover and connect. NimBLE auto-stops advertising on each accept. + if (s->getConnectedCount() < CONFIG_BT_NIMBLE_MAX_CONNECTIONS) { + need_advertise = true; + } } void onDisconnect(NimBLEServer* s, NimBLEConnInfo& info, int reason) override { - state = BLE_STATE_DISCONNECTED; + // Only flip the UI state to DISCONNECTED when the last client leaves. + if (s->getConnectedCount() == 0) state = BLE_STATE_DISCONNECTED; need_advertise = true; - Serial.printf("BLE: disconnected (reason=%d)\n", reason); + Serial.printf("BLE: disconnected (reason=%d, remaining=%u)\n", + reason, (unsigned)s->getConnectedCount()); } }; @@ -124,8 +158,17 @@ void ble_init(void) { hid_dev = new NimBLEHIDDevice(server); hid_dev->setReportMap((uint8_t*)HID_REPORT_MAP, sizeof(HID_REPORT_MAP)); hid_dev->setManufacturer("Anthropic"); - hid_dev->setPnp(0x02, 0x05AC, 0x820A, 0x0210); // BT SIG, generic keyboard - hid_dev->setHidInfo(0x00, 0x02); // country=0, flags=normally connectable + // PnP ID: (vendorIdSource, vendorId, productId, version). + // Source 1 = Bluetooth SIG, vendor 0x02E5 = Espressif. Originally claimed + // Apple's USB vendor 0x05AC + Magic Keyboard product 0x820A — macOS + // validates Apple-claimed HIDs against known device IDs and silently + // refuses to surface a Connect button for spoofers. + hid_dev->setPnp(0x01, 0x02E5, 0x0001, 0x0100); + // country=33 (US ANSI). Setting this to 0 ("not supported") causes macOS + // to launch the Keyboard Setup Assistant on first pair asking the user + // to identify the layout — we only ever send Space / Shift+Tab so the + // physical layout is irrelevant; advertise a known one to skip the wizard. + hid_dev->setHidInfo(33, 0x02); hid_dev->setBatteryLevel(100); input_kbd = hid_dev->getInputReport(1); // report ID 1 diff --git a/screenshot.sh b/screenshot.sh index 6e59313..c9b993e 100755 --- a/screenshot.sh +++ b/screenshot.sh @@ -1,19 +1,36 @@ #!/bin/bash # Take a screenshot from the Waveshare AMOLED display via LVGL snapshot. # Usage: ./screenshot.sh [output.png] [port] +# Default port: /dev/cu.usbmodem101 on macOS, /dev/ttyACM0 on Linux. OUTPUT="${1:-screenshot.png}" -PORT="${2:-/dev/ttyACM0}" +if [ -z "$2" ]; then + case "$(uname -s)" in + Darwin) PORT="/dev/cu.usbmodem101" ;; + *) PORT="/dev/ttyACM0" ;; + esac +else + PORT="$2" +fi + +# Use pio's bundled python if pyserial isn't on the system python. +PY="python3" +if ! python3 -c "import serial" 2>/dev/null; then + if [ -x "$HOME/.platformio/penv/bin/python" ]; then + PY="$HOME/.platformio/penv/bin/python" + fi +fi TMPRAW=$(mktemp /tmp/screenshot_XXXXXX.raw) -trap "rm -f '$TMPRAW'" EXIT +TMPDIMS=$(mktemp /tmp/screenshot_XXXXXX.dims) +trap "rm -f '$TMPRAW' '$TMPDIMS'" EXIT echo "Taking screenshot from $PORT..." -python3 - "$PORT" "$TMPRAW" << 'PYEOF' +"$PY" - "$PORT" "$TMPRAW" "$TMPDIMS" << 'PYEOF' import serial, sys -port_path, raw_path = sys.argv[1], sys.argv[2] +port_path, raw_path, dims_path = sys.argv[1], sys.argv[2], sys.argv[3] port = serial.Serial(port_path, 115200, timeout=10) port.reset_input_buffer() @@ -40,6 +57,8 @@ while len(data) < raw_size: with open(raw_path, "wb") as f: f.write(data) +with open(dims_path, "w") as f: + f.write(f"{w}x{h}\n") for _ in range(10): line = port.readline().decode("utf-8", errors="replace").strip() @@ -55,12 +74,13 @@ if [ $? -ne 0 ]; then exit 1 fi -ffmpeg -y -f rawvideo -pixel_format rgb565le -video_size 480x480 \ +DIMS=$(cat "$TMPDIMS") +ffmpeg -y -f rawvideo -pixel_format rgb565le -video_size "$DIMS" \ -i "$TMPRAW" -update 1 -frames:v 1 "$OUTPUT" 2>/dev/null || true if [ -f "$OUTPUT" ]; then - echo "Saved: $OUTPUT" + echo "Saved: $OUTPUT ($DIMS)" else echo "Error: conversion failed" exit 1