Brand consistency: the device now advertises as "Clawdmeter". Updated the three functional spots that must agree for discovery-by-name to work — ble.cpp DEVICE_NAME (advertised) and both daemons' DEVICE_NAME (scan match) — plus the README/CLAUDE.md/install-script references (and fixed the stray "Claudemeter" typo in the README). Note: hosts cache the GATT name, so an already-paired device keeps showing the old name until removed/re-paired (bluetoothctl remove <mac>). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
289 lines
10 KiB
C++
289 lines
10 KiB
C++
#include "ble.h"
|
|
#include <Arduino.h>
|
|
#include <NimBLEDevice.h>
|
|
#include <NimBLEHIDDevice.h>
|
|
|
|
#define DEVICE_NAME "Clawdmeter"
|
|
|
|
// 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 "Clawdmeter" (12)
|
|
// = 23 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 = std::min(val.length(), (size_t)(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_bonds(void) {
|
|
return NimBLEDevice::getNumBonds() > 0;
|
|
}
|
|
|
|
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();
|
|
}
|