#include "ble.h" #include #include #include #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(); // Only reflect ADVERTISING in the UI state when no client is connected. // With MAX_CONNECTIONS=2, onConnect re-advertises to fill the second slot; // without this guard the UI would flip CONNECTED → ADVERTISING on every // first connect and never come back until a second client arrived. if (!server || server->getConnectedCount() == 0) { state = BLE_STATE_ADVERTISING; } Serial.printf("BLE: advertising start=%s (connected=%u)\n", ok ? "OK" : "FAILED", server ? (unsigned)server->getConnectedCount() : 0); } 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(); }