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:
|
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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user