Merge pull request #18 from tobby168/macos-ble-compat
This commit is contained in:
@@ -166,6 +166,9 @@ async def poll_api(token: str) -> dict | None:
|
||||
except httpx.HTTPError as e:
|
||||
log(f"API call failed: {e}")
|
||||
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:
|
||||
return resp.headers.get(name, default)
|
||||
|
||||
@@ -11,12 +11,14 @@ build_flags =
|
||||
-DBOARD_HAS_PSRAM
|
||||
; AXP2101 PMU
|
||||
-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_PERIPHERAL=1
|
||||
-DCONFIG_BT_NIMBLE_ROLE_CENTRAL=0
|
||||
-DCONFIG_BT_NIMBLE_ROLE_OBSERVER=0
|
||||
-DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
|
||||
-DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=2
|
||||
; LVGL config via build flags
|
||||
-DLV_CONF_SKIP
|
||||
-DLV_COLOR_DEPTH=16
|
||||
|
||||
+51
-8
@@ -13,7 +13,10 @@
|
||||
|
||||
#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[] = {
|
||||
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||
0x09, 0x06, // Usage (Keyboard)
|
||||
@@ -30,6 +33,16 @@ static const uint8_t HID_REPORT_MAP[] = {
|
||||
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)
|
||||
@@ -58,10 +71,21 @@ static char mac_str[18];
|
||||
static void start_advertising() {
|
||||
NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
|
||||
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->enableScanResponse(true);
|
||||
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");
|
||||
@@ -70,13 +94,23 @@ static void start_advertising() {
|
||||
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());
|
||||
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 {
|
||||
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;
|
||||
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->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
|
||||
// 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
|
||||
|
||||
|
||||
+26
-6
@@ -1,19 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Take a screenshot from the Waveshare AMOLED display via LVGL snapshot.
|
||||
# Usage: ./screenshot.sh [output.png] [port]
|
||||
# Default port: /dev/cu.usbmodem101 on macOS, /dev/ttyACM0 on Linux.
|
||||
|
||||
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)
|
||||
trap "rm -f '$TMPRAW'" EXIT
|
||||
TMPDIMS=$(mktemp /tmp/screenshot_XXXXXX.dims)
|
||||
trap "rm -f '$TMPRAW' '$TMPDIMS'" EXIT
|
||||
|
||||
echo "Taking screenshot from $PORT..."
|
||||
|
||||
python3 - "$PORT" "$TMPRAW" << 'PYEOF'
|
||||
"$PY" - "$PORT" "$TMPRAW" "$TMPDIMS" << 'PYEOF'
|
||||
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.reset_input_buffer()
|
||||
@@ -40,6 +57,8 @@ while len(data) < raw_size:
|
||||
|
||||
with open(raw_path, "wb") as f:
|
||||
f.write(data)
|
||||
with open(dims_path, "w") as f:
|
||||
f.write(f"{w}x{h}\n")
|
||||
|
||||
for _ in range(10):
|
||||
line = port.readline().decode("utf-8", errors="replace").strip()
|
||||
@@ -55,12 +74,13 @@ if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
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
|
||||
|
||||
|
||||
if [ -f "$OUTPUT" ]; then
|
||||
echo "Saved: $OUTPUT"
|
||||
echo "Saved: $OUTPUT ($DIMS)"
|
||||
else
|
||||
echo "Error: conversion failed"
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user