Files
clawdmeter/firmware/src/ble.cpp
T
tobby168andClaude Opus 4.7 f252a871c4 Make BLE pairing work on macOS, allow daemon + HID to coexist
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) <noreply@anthropic.com>
2026-05-17 16:56:05 -07:00

278 lines
10 KiB
C++

#include "ble.h"
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <NimBLEHIDDevice.h>
#define DEVICE_NAME "Claude Controller"
// Custom GATT UUIDs for data channel
#define SERVICE_UUID "4c41555a-4465-7669-6365-000000000001"
#define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here
#define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device ack/nack notifies
#define REQ_CHAR_UUID "4c41555a-4465-7669-6365-000000000004" // device-initiated refresh request
#define BLE_BUF_SIZE 512
// 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)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (224) - Left Control
0x29, 0xE7, // Usage Maximum (231) - Right GUI
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data, Variable, Absolute) - Modifier byte
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)
0x25, 0x65, // Logical Maximum (101)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0)
0x29, 0x65, // Usage Maximum (101)
0x81, 0x00, // Input (Data, Array) - Key array (6 keys)
0xC0, // End Collection
};
static NimBLEServer* server = nullptr;
static NimBLEHIDDevice* hid_dev = nullptr;
static NimBLECharacteristic* input_kbd = nullptr;
static NimBLECharacteristic* tx_char = nullptr;
static NimBLECharacteristic* rx_char = nullptr;
static NimBLECharacteristic* req_char = nullptr;
static ble_state_t state = BLE_STATE_INIT;
static bool need_advertise = false;
static char rx_buf[BLE_BUF_SIZE];
static volatile bool data_ready = false;
static volatile bool has_received_data = false;
static char mac_str[18];
static void start_advertising() {
NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
adv->reset();
// 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->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");
}
class ServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer* s, NimBLEConnInfo& info) override {
state = BLE_STATE_CONNECTED;
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 {
// 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, remaining=%u)\n",
reason, (unsigned)s->getConnectedCount());
}
};
class RxCallbacks : public NimBLECharacteristicCallbacks {
void onWrite(NimBLECharacteristic* chr, NimBLEConnInfo& info) override {
std::string val = chr->getValue();
size_t len = val.length();
if (len >= BLE_BUF_SIZE) len = BLE_BUF_SIZE - 1;
memcpy(rx_buf, val.c_str(), len);
rx_buf[len] = '\0';
data_ready = true;
has_received_data = true;
}
};
// When the daemon enables notifications on the refresh char, ask for data
// if we have none yet. Firing on subscribe (not on connect) ensures the
// notification isn't dropped before the daemon's CCCD write completes.
class ReqCallbacks : public NimBLECharacteristicCallbacks {
void onSubscribe(NimBLECharacteristic* chr, NimBLEConnInfo& info, uint16_t subValue) override {
Serial.printf("BLE: req_char onSubscribe subValue=%u has_data=%d\n", subValue, has_received_data ? 1 : 0);
if (subValue != 0 && !has_received_data) {
ble_request_refresh();
}
}
};
void ble_init(void) {
NimBLEDevice::init(DEVICE_NAME);
NimBLEDevice::setSecurityAuth(true, false, true); // bonding, no MITM, SC
// Format MAC address
NimBLEAddress addr = NimBLEDevice::getAddress();
snprintf(mac_str, sizeof(mac_str), "%s", addr.toString().c_str());
for (int i = 0; mac_str[i]; i++) {
if (mac_str[i] >= 'a' && mac_str[i] <= 'f') mac_str[i] -= 32;
}
server = NimBLEDevice::createServer();
static ServerCallbacks serverCb;
server->setCallbacks(&serverCb);
// --- HID keyboard service ---
hid_dev = new NimBLEHIDDevice(server);
hid_dev->setReportMap((uint8_t*)HID_REPORT_MAP, sizeof(HID_REPORT_MAP));
hid_dev->setManufacturer("Anthropic");
// 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
// --- Custom data service ---
NimBLEService* svc = server->createService(SERVICE_UUID);
rx_char = svc->createCharacteristic(
RX_CHAR_UUID,
NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR
);
static RxCallbacks rxCb;
rx_char->setCallbacks(&rxCb);
tx_char = svc->createCharacteristic(
TX_CHAR_UUID,
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY
);
req_char = svc->createCharacteristic(
REQ_CHAR_UUID,
NIMBLE_PROPERTY::NOTIFY
);
static ReqCallbacks reqCb;
req_char->setCallbacks(&reqCb);
svc->start();
server->start();
start_advertising();
Serial.printf("BLE: init complete, MAC=%s\n", mac_str);
}
void ble_tick(void) {
if (need_advertise) {
need_advertise = false;
start_advertising();
}
}
ble_state_t ble_get_state(void) {
return state;
}
const char* ble_get_device_name(void) {
return DEVICE_NAME;
}
const char* ble_get_mac_address(void) {
return mac_str;
}
void ble_clear_bonds(void) {
NimBLEDevice::deleteAllBonds();
Serial.println("BLE: bonds cleared");
if (state == BLE_STATE_CONNECTED) {
server->disconnect(server->getPeerInfo(0).getConnHandle());
}
need_advertise = true;
}
bool ble_has_data(void) {
return data_ready;
}
const char* ble_get_data(void) {
data_ready = false;
return rx_buf;
}
void ble_send_ack(void) {
if (state == BLE_STATE_CONNECTED && tx_char) {
tx_char->setValue("{\"ack\":true}");
tx_char->notify();
}
}
void ble_send_nack(void) {
if (state == BLE_STATE_CONNECTED && tx_char) {
tx_char->setValue("{\"err\":true}");
tx_char->notify();
}
}
void ble_request_refresh(void) {
if (state == BLE_STATE_CONNECTED && req_char) {
uint8_t v = 0x01;
req_char->setValue(&v, 1);
req_char->notify();
Serial.println("BLE: refresh requested");
}
}
void ble_keyboard_press(uint8_t key, uint8_t modifier) {
if (state != BLE_STATE_CONNECTED || !input_kbd) return;
// HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6]
uint8_t report[8] = {modifier, 0, key, 0, 0, 0, 0, 0};
input_kbd->setValue(report, sizeof(report));
input_kbd->notify();
}
void ble_keyboard_release(void) {
if (state != BLE_STATE_CONNECTED || !input_kbd) return;
uint8_t report[8] = {0};
input_kbd->setValue(report, sizeof(report));
input_kbd->notify();
}