Merge pull request #18 from tobby168/macos-ble-compat

This commit is contained in:
Hermann Björgvin
2026-05-18 00:46:19 +00:00
committed by GitHub
4 changed files with 84 additions and 16 deletions
+3
View File
@@ -166,6 +166,9 @@ async def poll_api(token: str) -> dict | None:
except httpx.HTTPError as e: except httpx.HTTPError as e:
log(f"API call failed: {e}") log(f"API call failed: {e}")
return None 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: def hdr(name: str, default: str = "0") -> str:
return resp.headers.get(name, default) return resp.headers.get(name, default)
+4 -2
View File
@@ -11,12 +11,14 @@ build_flags =
-DBOARD_HAS_PSRAM -DBOARD_HAS_PSRAM
; AXP2101 PMU ; AXP2101 PMU
-DXPOWERS_CHIP_AXP2101 -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_BROADCASTER=1
-DCONFIG_BT_NIMBLE_ROLE_PERIPHERAL=1 -DCONFIG_BT_NIMBLE_ROLE_PERIPHERAL=1
-DCONFIG_BT_NIMBLE_ROLE_CENTRAL=0 -DCONFIG_BT_NIMBLE_ROLE_CENTRAL=0
-DCONFIG_BT_NIMBLE_ROLE_OBSERVER=0 -DCONFIG_BT_NIMBLE_ROLE_OBSERVER=0
-DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 -DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=2
; LVGL config via build flags ; LVGL config via build flags
-DLV_CONF_SKIP -DLV_CONF_SKIP
-DLV_COLOR_DEPTH=16 -DLV_COLOR_DEPTH=16
+51 -8
View File
@@ -13,7 +13,10 @@
#define BLE_BUF_SIZE 512 #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[] = { static const uint8_t HID_REPORT_MAP[] = {
0x05, 0x01, // Usage Page (Generic Desktop) 0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard) 0x09, 0x06, // Usage (Keyboard)
@@ -30,6 +33,16 @@ static const uint8_t HID_REPORT_MAP[] = {
0x95, 0x01, // Report Count (1) 0x95, 0x01, // Report Count (1)
0x75, 0x08, // Report Size (8) 0x75, 0x08, // Report Size (8)
0x81, 0x01, // Input (Constant) - Reserved byte 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) 0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8) 0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0) 0x15, 0x00, // Logical Minimum (0)
@@ -58,10 +71,21 @@ static char mac_str[18];
static void start_advertising() { static void start_advertising() {
NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
adv->reset(); 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->setAppearance(HID_KEYBOARD);
adv->enableScanResponse(true); adv->addServiceUUID(NimBLEUUID((uint16_t)0x1812)); // BLE HID Service
adv->setName(DEVICE_NAME); 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(); bool ok = adv->start();
state = BLE_STATE_ADVERTISING; state = BLE_STATE_ADVERTISING;
Serial.printf("BLE: advertising start=%s\n", ok ? "OK" : "FAILED"); Serial.printf("BLE: advertising start=%s\n", ok ? "OK" : "FAILED");
@@ -70,13 +94,23 @@ static void start_advertising() {
class ServerCallbacks : public NimBLEServerCallbacks { class ServerCallbacks : public NimBLEServerCallbacks {
void onConnect(NimBLEServer* s, NimBLEConnInfo& info) override { void onConnect(NimBLEServer* s, NimBLEConnInfo& info) override {
state = BLE_STATE_CONNECTED; 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 { 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; 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 = new NimBLEHIDDevice(server);
hid_dev->setReportMap((uint8_t*)HID_REPORT_MAP, sizeof(HID_REPORT_MAP)); hid_dev->setReportMap((uint8_t*)HID_REPORT_MAP, sizeof(HID_REPORT_MAP));
hid_dev->setManufacturer("Anthropic"); hid_dev->setManufacturer("Anthropic");
hid_dev->setPnp(0x02, 0x05AC, 0x820A, 0x0210); // BT SIG, generic keyboard // PnP ID: (vendorIdSource, vendorId, productId, version).
hid_dev->setHidInfo(0x00, 0x02); // country=0, flags=normally connectable // 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); hid_dev->setBatteryLevel(100);
input_kbd = hid_dev->getInputReport(1); // report ID 1 input_kbd = hid_dev->getInputReport(1); // report ID 1
+26 -6
View File
@@ -1,19 +1,36 @@
#!/bin/bash #!/bin/bash
# Take a screenshot from the Waveshare AMOLED display via LVGL snapshot. # Take a screenshot from the Waveshare AMOLED display via LVGL snapshot.
# Usage: ./screenshot.sh [output.png] [port] # Usage: ./screenshot.sh [output.png] [port]
# Default port: /dev/cu.usbmodem101 on macOS, /dev/ttyACM0 on Linux.
OUTPUT="${1:-screenshot.png}" 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) 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..." echo "Taking screenshot from $PORT..."
python3 - "$PORT" "$TMPRAW" << 'PYEOF' "$PY" - "$PORT" "$TMPRAW" "$TMPDIMS" << 'PYEOF'
import serial, sys 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 = serial.Serial(port_path, 115200, timeout=10)
port.reset_input_buffer() port.reset_input_buffer()
@@ -40,6 +57,8 @@ while len(data) < raw_size:
with open(raw_path, "wb") as f: with open(raw_path, "wb") as f:
f.write(data) f.write(data)
with open(dims_path, "w") as f:
f.write(f"{w}x{h}\n")
for _ in range(10): for _ in range(10):
line = port.readline().decode("utf-8", errors="replace").strip() line = port.readline().decode("utf-8", errors="replace").strip()
@@ -55,12 +74,13 @@ if [ $? -ne 0 ]; then
exit 1 exit 1
fi 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 -i "$TMPRAW" -update 1 -frames:v 1 "$OUTPUT" 2>/dev/null || true
if [ -f "$OUTPUT" ]; then if [ -f "$OUTPUT" ]; then
echo "Saved: $OUTPUT" echo "Saved: $OUTPUT ($DIMS)"
else else
echo "Error: conversion failed" echo "Error: conversion failed"
exit 1 exit 1