Replace USB serial/HID with Bluetooth Low Energy

Move all communication from USB to BLE. The device now advertises as
"Claude Controller" and acts as both a BLE HID keyboard (for touch
gestures) and a GATT data server (for usage updates from the daemon).

- Add NimBLE-Arduino BLE module with custom GATT service + HID keyboard
- Add third screen (Bluetooth) with connection status, MAC, reset button
- Rewrite daemon in bash using bluetoothctl/busctl for BLE GATT writes
- Add screenshot capture via LVGL snapshot over serial
- Remove TinyUSB mode — normal pio upload works again
- Update README with BLE architecture, screenshots, gesture docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann Björgvin Haraldsson
2026-04-04 19:30:19 +00:00
co-authored by Claude Opus 4.6
parent 930055170f
commit 10b8052bcc
22 changed files with 917 additions and 167 deletions
+207
View File
@@ -0,0 +1,207 @@
#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 notifies here
#define BLE_BUF_SIZE 512
// HID keyboard report descriptor
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
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 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 char mac_str[18];
static void start_advertising() {
NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
adv->reset();
adv->addServiceUUID(SERVICE_UUID);
adv->setAppearance(HID_KEYBOARD);
adv->enableScanResponse(true);
adv->setName(DEVICE_NAME);
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\n", info.getAddress().toString().c_str());
}
void onDisconnect(NimBLEServer* s, NimBLEConnInfo& info, int reason) override {
state = BLE_STATE_DISCONNECTED;
need_advertise = true;
Serial.printf("BLE: disconnected (reason=%d)\n", reason);
}
};
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;
}
};
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");
hid_dev->setPnp(0x02, 0x05AC, 0x820A, 0x0210); // BT SIG, generic keyboard
hid_dev->setHidInfo(0x00, 0x02); // country=0, flags=normally connectable
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
);
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_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();
}
bool ble_is_connected(void) {
return state == BLE_STATE_CONNECTED;
}