iPhone manual lap timer → BLE → ESP32 bridge → ESP-NOW → HDZero goggle OSD. FPV drone racing use case: operator taps LAP on phone, lap time appears on pilot's goggle.
firmware/ ESP32 PlatformIO project (Arduino framework)
app/ iOS SwiftUI app (iOS 18+, xcodegen)
docs/ Architecture, research, TestFlight setup (allow-listed: only docs/manual/ + docs/flash/ ship to Pages)
docs/manual/ End-user manual (en + ja); served on GitHub Pages
docs/flash/ Browser firmware flasher (esptool-js); served on GitHub Pages
scripts/ build / upload-testflight / release helpers
.github/workflows/ CI: builds firmware, composes Pages artefact, deploys (TestFlight upload is scripts/-driven via the release skill)
.claude/skills/release/ Claude Code skill for cutting a release end-to-end (develop bump → TestFlight → PR develop→main → tag → GitHub Release)
develop= default branch; CI deploys staging to https://saqoosha.github.io/HDZap/dev/ (/dev/flash/,/dev/ja/).main= release branch, protected (PR-only merge, no force push, no delete, admin bypass enabled). CI deploys production at the canonical paths (/,/flash/,/ja/).- Pages is one site per repo, so the workflow checks out both branches on every push, builds firmware for each, and composes a single
_site/with main at the root and develop mirrored under/dev/. Pushing to either branch refreshes its slice without touching the other. - Releases promote develop → main through a release PR (script-driven). Direct push to
mainis rejected.
# Firmware
cd firmware && pio run # build
cd firmware && pio run -t upload # flash
cd firmware && pio device monitor # serial 115200
# iOS
cd app && xcodegen generate # regenerate .xcodeproj after changes
# Then build in Xcode (BLE requires physical device)Browser-based firmware installer hosted on GitHub Pages. Production at
https://saqoosha.github.io/HDZap/flash/, staging at
https://saqoosha.github.io/HDZap/dev/flash/. Drives esptool-js directly (NOT
ESP Web Tools / <esp-web-install-button>) so the entire UI is custom Japanese
copy with no English library dialogs. Target hardware: M5StickS3 only.
docs/flash/index.html+style.css— Japanese UI with a 4-state machine (idle / working / done / error). No external custom elements.docs/flash/flasher.js— ES module that importsESPLoader+Transportfromhttps://unpkg.com/esptool-js@0.5.7/bundle.jsand runs the flash flow. All firmware paths are resolved vianew URL("firmware/...", import.meta.url)so the page works at any subpath (root/flash/and/dev/flash/) without modification. Pinned to 0.5.7: 0.6.x has a known regression where compressedwriteFlashon ESP32-S3 fails withstatus 201 (ESP_TOO_MUCH_DATA).docs/flash/manifest.json— single field (version). CI overwrites the value with<branch>-<short SHA>(e.g.main-743c728,develop-9f09570) per side so the deployed page can show which build is live and which slice it belongs to; nothing else is consumed at runtime.docs/flash/m5sticks3.jpg— product photo (M5Stack), credited in footer.docs/flash/firmware/*.bin— produced by CI, gitignored; copying locally for testing is fine..github/workflows/flasher.yml— checks outmainanddevelopside by side, runspio run -e m5stick-s3for each, stages 4 bins (bootloader.bin,partitions.bin,boot_app0.bin,firmware.bin → hdzap.bin), size-checks each (>= 1 KiB), writesCHECKSUMS.txt, stamps the per-branchmanifest.json, then composes_site/with main at the root (_site/flash/,_site/index.html,_site/ja/) and develop mirrored under/dev/(_site/dev/flash/,_site/dev/index.html,_site/dev/ja/). Allow-list approach: ONLY the paths underdocs/manual/anddocs/flash/are copied — never the wholedocs/tree, sodocs/report.md/docs/architecture.md/ etc. stay unpublished. PR builds compose the same artefact (with the PR head on the targeted side) but the deploy job is gated to push events.- ESP32-S3 partition offsets used by
flasher.jsPARTS:0x0 / 0x8000 / 0xe000 / 0x10000(S3 starts the bootloader at 0, not 0x1000 like classic ESP32). - The
eraseAllparameter inwriteFlashis wired to the "完全初期化する" checkbox in the UI. Default unchecked → existing NVS is preserved (UID, sleep timeout in namespacehdzero). Checked → full chip erase, wiping all saved state. There is no OTA / Wi-Fi update path: every re-flash goes through this same Web Serial flow. - Local test:
python3 -m http.server 8765 --directory docs --bind 127.0.0.1then openhttp://127.0.0.1:8765/flash/in Chrome (Web Serial requires HTTPS orlocalhost). Copy build artifacts intodocs/flash/firmware/first; the page header will showversion: devbecause the CI version stamp only runs on deploy. - GitHub Pages must be set to Source = "GitHub Actions" in repo
Settings → Pages for the workflow to deploy. The
github-pagesenvironment's deployment-branch policy explicitly allows bothmainanddevelop; new branches that need to deploy must be added there.
- NEVER use delay() between ESP-NOW packets — breaks packet delivery
- ESP-NOW max 10 packets per OSD cycle (clear + 8 writes + draw)
- OSD grid: 50x18, lowercase ASCII maps to FPV glyphs (auto-uppercase in osd.h)
- BLE UUIDs must match between firmware (ble_service.h) and iOS (BluetoothManager.swift)
- Service UUID:
f47ac10b-58cc-4372-a567-0e02b2c3d490(bumped from…d48e; iOS CoreBluetooth caches GATT per-peripheral for unbonded devices, so a new service UUID is the only reliable cache-invalidation hook short of rebooting the iPhone). Adding a characteristic — or changing its property bitmap — without a service bump is safe ONLY when no existing iOS build attempts to read or write it — iOS won't see the new char/property until the service UUID changes. The most recent bump (…d48e → …d490) shipped CHR_FW_VERSION (…d48f), a READ-only string seeded fromgit describe --tags --dirty --alwaysby the PlatformIO pre-scriptfirmware/scripts/inject_version.py; iOS reads it on connect and warns when its leading major-version component disagrees with the app'sCFBundleShortVersionString. The prior bump (…d48d → …d48e) shipped CHR_DEVICE_NAME (…d489), the renameable BLE-advertised name char — iOS writes UTF-8 (≤20 B), firmware persists to NVS namespacehdzerokeybtname(defaultHDZapBridge) andESP.restart()s soBLEDevice::init(name)re-runs with the new value, and bonded iOS auto-reconnects after the ~3 s reboot. The earlier…d48c → …d48dbump shipped CHR_OSD_LAYOUT (…d48b) gainingPROPERTY_WRITE_NRforwriteWithoutResponseslider drags; iOS caches each char's property bitmap and silently dropswriteWithoutResponseon a char whose cached bitmap doesn't advertise the WRITE_NR bit, so a property change is a GATT shape change for cache-invalidation purposes. BLEServer::createService()must be passed an explicitnumHandlescovering1 (service decl) + 2 per characteristic + 1 per BLE2902 descriptor— the default of 15 silently truncates overflow characteristics. The call uses 32 for headroom; recompute and bump if a future GATT addition pushes the count past ~28.- Bind phrase UID derivation: MD5(
-DMY_BINDING_PHRASE="<phrase>"), first 6 bytes, bit0 cleared - VTX not required for backpack OSD display
- Binding overwrites existing UID — scenarios 1 & 2 avoid this by reusing existing UID
- Flight battery telemetry uses promiscuous Wi-Fi capture because Backpack telemetry is visible on-air but not addressed to the M5 ESP-NOW MAC. Filter it by the separately persisted telemetry source sender MAC (
teleuid, learned from the TX bind/sniff packet), not by the OSD targetg_uid.
msp.h— packet building only, no I/Oespnow_link.h— ESP-NOW init/send/reinit + broadcast helper, no business logicosd.h— OSD commands via ESP-NOW, no layout knowledgeble_service.h— BLE GATT server, stages payloads + sets flags for main loopbind.h— ELRS bind protocol, stateless (broadcast via espnow_link)espnow_recv.h— owns the unified ESP-NOW recv callback registration plus the promiscuous-mode RX hook for backpack telemetry not addressed to us directly.espnow_recv_attach_cb()is called fromsetup()and after every ESP-NOW reinit; the callback fans out to bind capture (gated ong_sniff_active), flight-battery decode (gated onhdzap_telemetry_source_matches), and telemetry-debug capture (gated ong_telemetry_sniff_active). No other module callsesp_now_register_recv_cb— adding a second caller would silently overwrite the unified handler.tx_sniff.h— flag-only TX UID bind capture.sniff_start/sniff_stoptoggleg_sniff_active; the actual capture happens inside the unified callback inespnow_recv.h.g_sniff_uid+g_sniff_capturedguarded byg_sniff_mux.g_sniff_activeis read by main.cpp's deep-sleep gate so a deep sleep can't silently drop the BLE-staged sniff session.telemetry_sniff.h— flag-only Backpack telemetry debug ring sniffer.telemetry_sniff_start/stoptoggleg_telemetry_sniff_active; the unified callback inespnow_recv.hcallstelemetry_sniff::capture_if_active(...)to copy 20-byte records into a portMUX-guarded ring drained one-per-loop in main.cpp. Coexists withtx_sniff— both ride the same callback, no preempt logic.g_telemetry_sniff_activealso feeds the deep-sleep gate.flight_battery_telemetry.h/crsf_battery_telemetry.h— flight-pack CRSF Battery (frame type 0x08) decode. The unified callback (and the promiscuous-MSP candidate consumer in main.cpp) callflight_battery_on_espnow_payload(), which runscrsfp_try_battery_from_any_msp_payload; on success the staged sample is consumed by main.cpp and pushed viable_maybe_notify_flight_battery.g_flight_battery_droppedcounts staged-overwrite events. CRSF parser keeps per-reason rejection counters (g_crsf_rej_*+g_crsf_accepts) so "no telemetry decoded" isn't silent — main.cpp emits a 30 s reject-reason summary while a telemetry source is configured but no decode has happened.osd_text_display.h— iOS-owned 4-row goggle OSD text. Per-row dirty bitmap;render()writeStrings just the dirty rows + draw (no clear), and the goggle's overlay buffer keeps prior content between writes.m_dirtysurvives across retries so the state machine can re-emit the same bits;clearDirtyBits(mask)is the surgical drop, called from main.cpp on verify-success / give-up.nvs_store.h— UID persistence (sentinel-protected) + deep-sleep timeout (slpmin, single-byte putUChar — no sentinel needed; one NVS entry can't be torn at the entry level) + Bluetooth device name (btname, ≤20 B UTF-8; defaultHDZapBridgereturned when absent). Namespace "hdzero".power_log.h— SPIFFS-backed CSV append at/power.csvfor issue #5 phase 2/3 measurement runs. Schema =millis,voltage_mv,percent,charging,panel_asleep,ble_connected; main.cpp throttles to one row per 30 s and sentinel-marks out-of-range VBAT readings as -1. Schema-mismatch detection on boot wipes incompatible old logs (with CR strip so println-written headers compare equal); a stale/power.csv.tmpfrom an interrupted rotate is also cleaned up at boot. Auto-rotates at ~110 KB to keep the most recent ~80 KB inside the 128 KB SPIFFS partition; if the rotate write fails the original is preserved (no half-rotated tmp ever replaces the source).dumpToSerial()runs insetup()so plug-in-USB-after-battery-run prints the trail without a separate tool.stick_display.h— M5StickS3 LCD status display, no business logic. Battery widget (top row of UID band, left of BLE pill) is fed bymain.cppviasetBattery(percent, charging).sleepPanel()/wakePanel()/isPanelAsleep()own the panel power state for issue #5 phase 1;sleepPanel()callsM5.Display.sleep()only — do NOT prependsetBrightness(0)or it corrupts LGFX's_brightnesscache andwakeup()restores brightness=0.wakePanel()waits 5 ms afterwakeup()(ST7789 SLPOUT settling) then forces a full repaint.battery_monitor.h— AXP2101 percent + charging poll, alarm tier (None/Low/Critical with hysteresis) + silence latch. Singletick(now, silenceRequested) → Outcome(Throttled/StateChanged/TierChanged) replaces the priorpoll()+silence()+consumeSilencedDirty()trio; silence-dirty edges fold intoStateChanged, andTierChangedis returned instead ofStateChangedon a tier transition (callers test!= Throttledfor the BLE+LCD push and== TierChangedfor the sticky strip message; theTierChanged = 0b11 / StateChanged = 0b01bit pattern encodes the subset relation structurally). Actuator-free —main.cppowns LCD/BLE/speaker dispatch; the onlySerial.printfpaths are PMIC-validity edges and silence-press-but-already-silenced traces. Beep cadence is a separate destructive-read channel:consumeBeepDue(now)burns the slot on atruereturn; pair withscheduleBeepRetry(now)on aM5.Speaker.tone()failure so the next ~1 s retries instead of waiting out the 15-30 s cadence.payload(uint8_t (&out)[2])uses a reference-to-array so a single-byte buffer can't compile, and defensively masks bit 3 off when tier==None so a regression intick()'s clear-on-tier-transition policy can't leak a wire-illegal byte to iOS.main.cpp— event loop; consumes staged BLE data underg_ble_mux, runs heavy work (NVS, ESP-NOW reinit) outside the BLE task, and hosts the render-retry state machine (IDLE→PENDING→WAITING_ACK). Snapshots the dispatched dirty mask at render time so verify-success can clear only the bits we sent (BLE writes during WAITING_ACK survive). OSD delivery uses MAC-layer feedback fromesp_now_register_send_cb(counters inespnow_link.h) — if any packet in a cycle fails to deliver,render()is re-dispatched up toMAX_RENDER_RETRIEStimes. TheIDLE && hasDirty → requestRendercatch-up trigger picks up bits that arrived during a verify window or while ESP-NOW was down.cancelRender()drops the cycle when stale state would be rendered (UID change, OSD clear, laps reset). Owns issue #5 power-saving glue: phase-1 LCD-off (30 s idle,markActivity()resets on button press or OSD-text dirty), phase-2-redux runtime tuning (setCpuFrequencyMhz(80), BLE/WiFi TX power), phase-3 deep sleep (g_sleep_timeout_msfrom NVS-backedslpmin, ext1 wake on BtnA/BtnB; gate also guards ong_sniff_activeand pendingg_sleep_minutes_changed), and the per-30-s power-log append (sentinel-marks VBAT readings outside [2500, 4400] mV). The sleep-config consumer block runs BEFORE the sleep gate so an iOS write at the idle threshold is never lost.
Drilldown structure after the #36 restructure (the flat 10-section list was replaced with status-first navigation rows that fit one iPhone screen):
- Format (inline at the root) — race time, target lap, target pace. The variable in
SettingsView.swiftisraceSection, but the user-visible header isText("Format")— refer to the on-screen label in user-facing prose. - Device section header containing three rows:
- "M5StickS3" row →
ConnectionSettingsView— connected device + battery + Bluetooth name rename + discovered list + Scan. The Bluetooth name row (DeviceRenameView, gated onbluetooth.isConnected && bluetooth.supportsDeviceRename) writes CHR_DEVICE_NAME (…d489) and warns about the M5 reboot. - "Goggle pairing" row →
PairingSettingsView(nav title: "Pairing") — bind phrase / manual UID / new pairing modes + TX UID capture + auto-rollback flow + apply alert, all on one workflow screen. - "OSD layout" row →
OSDLayoutSettingsView(nav title: "OSD Layout") — preview, position slider, alignment, per-row show/hide, plus Send Test OSD + Clear OSD + Reset layout.
- "M5StickS3" row →
- App section header containing two rows:
- "Lap announcer" row →
AudioSettingsView. - "Appearance" row →
AppearanceSettingsView.
- "Lap announcer" row →
- About section (
aboutSection) — surfaces the iOS app version (always) and the firmware version row (only after CHR_FW_VERSION lands;bluetooth.firmwareVersion != nil). When the firmware's leading major-version component disagrees with the app'sCFBundleShortVersionString, the FW row + afirmwareMismatchSummaryline both render in red. The same version pair also appears insideConnectionSettingsView'sversionRow(drilldown), so the two surfaces share a single source of truth and never disagree on what's wrong.
The pairing / OSD-layout / lap-announcer / appearance rows show their current value on the right rail (Apple Settings.app pattern): pairing UID in decimal (96,210,… — matches what HDZero goggles and the M5Stick LCD show), OSD layout row range, lap announcer state + language, accent color swatch. The M5StickS3 row uses a 2-line layout instead — status dot + name on top, battery / unbound subtitle below — so the connection state is glanceable without drilling in.
PairingSettingsView.runPairingFlow is explicitly @MainActor so a future Task.detached call site fails at build time rather than crashing inside BluetoothManager.recordError.
- Firmware: C++ headers in include/, single source in src/
- iOS: @MainActor + @Observable (not ObservableObject), @Environment for DI
- BLE callbacks stage paired state under
g_ble_mux(UID staging, lap frame); idempotent single-flag commands use barevolatile. Seeble_service.hshared-state docstring. Heavy work (NVS, ESP-NOW reinit) runs in main loop, not in callbacks. CBCentralManagerdelegate queue MUST be main (queue: nil).BluetoothManageris@MainActor;recordErrorruntime-asserts main-actor isolation.- NVS namespace: "hdzero"; keys:
"uid"(6 bytes) +"init"(sentinel for torn-save detection). Save order is remove sentinel → write uid → write sentinel; loadUid warns but still returns a present uid when the sentinel is absent (fail-soft — dropping a valid UID on every torn save would be worse than a log line). - Unicast MAC invariant:
uid[0] & 0x01 == 0at every assignment site M5.BtnA/B.wasPressed()is non-consuming (pure read of a latched edge), so multiple consumers observe the same press in one tick — current pattern:markActivity()(LCD wake / phase 1 idle reset) reads the edge, and the samewasPressed()derives asilenceReqflag passed intobatteryMonitor.tick(now, silenceReq)(alarm silence;tick()no-ops the silence when tier==None or already silenced). Don't add a "consume" wrapper — the multi-observer model is the design.
- Target / current: M5StickS3 (ESP32-S3, 1.14" LCD, 2 buttons, AXP2101 PMIC)
- Goggle: HDZero with ELRS backpack
- Buttons: BtnA + BtnB are multi-purpose — wake the LCD panel from phase-1 idle sleep, silence the battery low/critical alarm (sticky message stays; tier escalation re-arms beeps), and wake the device from phase-3 deep sleep via ext1 (GPIO11 / GPIO12, both RTC-capable on ESP32-S3). Each consumer reads
wasPressed()independently — the model is multi-observer per the convention above.