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