- AirPlay mirroring receiver ported from UxPlay (src/airplay/, GPL-3.0): mDNS, RTSP, pairing, FairPlay, AES-CTR - Own Wi-Fi hotspot with a random per-board password in NVS (optional STA mode for testing) - BLE GATT link (NimBLE-Arduino): commands, status, H.264 video forwarded from a PSRAM GOP buffer - USB HID unchanged in spirit; adds the consumer Home key - Host tests (bplist, GOP buffer) and a Mac BLE test harness (tools/) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Testing the board from a Mac
board_test.py plays the iOS app over BLE (docs/ble-protocol.md); the Mac's own Screen
Mirroring feeds the board's AirPlay receiver, so AirPlay -> PSRAM GOP -> BLE -> H.264 can be
checked without the iPhone. Needs uv, arduino-cli with esp32 core 3.3.x, ffmpeg, and the BLE host
library (once): arduino-cli lib install "NimBLE-Arduino" (2.5.x). Always build with hardware/flash.sh:
it force-includes hid_bridge/nimble_cfg.h (NimBLE buffer sizes); a plain IDE build fails a static_assert.
1. Flash a test build (board plugged into the Mac)
cp hardware/hid_bridge/secrets.example.h hardware/hid_bridge/secrets.h # once; fill in the Mac's Wi-Fi
hardware/flash.sh --sta --debug # joins that Wi-Fi instead of opening its hotspot; logs on USB serial
Watch the log (AirPlay handshake steps, boot heap/PSRAM line):
arduino-cli monitor -p /dev/cu.usbmodem* -c baudrate=115200
Without --sta the board opens its own hotspot ESP32Agent-XXXX (password: net read below);
the Mac can join that instead, but then it has no internet.
2. BLE only (no mirroring yet)
uv run hardware/tools/board_test.py info # pairs (macOS prompt), reads `net`, prints status
uv run hardware/tools/board_test.py bench --kb 512
uv run hardware/tools/board_test.py hid # moves/clicks the Mac's pointer: board is a USB mouse
uv run hardware/tools/board_test.py stress --seconds 20 --kb 512 # bench filler + ping/move every 200 ms
stress prints reply latency p50/p95/max and missing replies (target: 0 missing, p95 < 500 ms);
--load stream uses stream on instead (needs mirroring). The Mac receives only ~100-200 kbps, so use the
iPhone for throughput: with --debug, send N bench 512 on the USB serial; the result is echoed as
video: N ok <ms> (kbps = 51281024/ms). A/B the radio: N coex bt|balance|wifi on serial, and
hardware/flash.sh --ap-tune (20 MHz hotspot, 300 TU beacons) vs a normal build.
net passwords are masked in the output.
3. AirPlay -> BLE
- Control Center -> Screen Mirroring -> ESP32 Agent (the Mac must be on the same Wi-Fi).
The serial log should show
GET /info,pair-setup,pair-verify 1/2,fp-setup 1/2,setup: mirror stream,mirror: data connection accepted,mirror: SPS ... PPS. - Then:
uv run hardware/tools/board_test.py snap # -> hardware/tools/out/snap.h264 + snap.png (snap latency)
uv run hardware/tools/board_test.py stream --seconds 20 # -> out/stream.h264 + stream.png, kbps, gaps
uv run hardware/tools/board_test.py all # info, bench, snap, stream, hid, status
uv run hardware/tools/board_test.py cmd "res 480 1040" "fps 5" # any command lines
ffplay hardware/tools/out/stream.h264 plays the stream.
4. Back to the iPhone
Reflash without --sta (hardware/flash.sh), then plug the board into the iPhone.
TODO / next optimizations (measured 2026-10-15, NimBLE-Arduino 2.5.1, board on the Mac + iPhone app connected)
- Throughput vs latency:
bench 512to the iPhone (serial) was ~381 kbps (balance), ~412 (coex bt), ~364 (coex wifi) with only the msys reserve gate; 30 ms interval halved it (180 kbps). With the controller-credit cap while commands flow (VIDEO_CTRL_PKTS = 8, CMD_ACTIVE_MS = 1000) it is ~130-170 kbps, because the app's periodic pings keep the cap on. Mac stress (bench load, ping/move every 200 ms): 0 missing, p50 33-66 ms, p95 83-156 ms, max 93-262 ms (before the cap: p95 0.96-2.2 s). Next: cap per connection instead of globally, tune VIDEO_CTRL_PKTS/CMD_ACTIVE_MS on the iPhone, or ignore the app's keepalive pings when deciding "commands flow". - L2CAP CoC for video (1 channel x 247 MPS is already configured, unused).
- Coex A/B on the iPhone with live mirroring (
coex,--ap-tune); not measured with AirPlay running yet. - Stream: IDR-only fallback (P-frames > 800 ms old are skipped) not yet exercised with live mirroring
(
board_test.py stress --load stream).