ADHD Habit Enforcement Watch & Anchor — Firmware Specification¶
Version: 0.17 (Draft — see Section 0 for spec status, firmware parity, and the change log) Scope: Firmware for the ESP32-C3-WROOM Watch and ESP32-C3-WROOM Anchor devices, intended as a complete reference for a coding agent.
A hardware pivot is in flight and this document does not yet cover it. On 2026-08-07 the watch PCB was re-spun onto Nordic silicon — Fanstel BM20C (nRF54LM20A) and WM02E (WiFi) in place of the ESP32-C3 — and ordered. Everything below still describes the ESP32-C3 firmware that exists and runs today, and remains the authority for it. The new board's verified pin map is recorded in §1.3; the Zephyr firmware for it is unwritten and will get its own document. Do not read §1.3 as describing anything that has been built. Companion:
proximity_engine_spec_v2.1.md(algorithm rationale & math). Where the two disagree, this document wins — it is the one reconciled against the code.v0.10 is a documentation merge. The former
firmware_spec_v0.9_amendment.md— a 17-part patch that had to be applied in the reader's head — has been folded into the sections it amended. Nothing about device behaviour changed in this revision. Every merged block is marked with its shipped status:
- ✅ Shipped — implemented and validated on hardware.
- 🟡 Partly shipped — read the note; the spec text describes the target, the note describes what the code actually does today.
- 📋 Planned — specified, not built. Do not assume any of this exists on a device.
The archived amendment is at
vault/docs/archive/firmware_spec_v0.9_amendment.mdfor provenance only; it is not normative and it is stale in the places noted below.
0. Spec Status, Versioning & Change Log¶
This spec changes rapidly. Rules for maintaining this section: - Bump the version (0.x → 0.x+1) on any normative change — behavior, wire format, constant defaults — and add a change-log entry (date + bullet list) at the same time. - Keep the parity table current: update statuses whenever firmware work lands or an audit is performed, and stamp the audit date. The table tracks spec vs. code, not aspirations. - Wire-format changes that require the app and firmware to move in lockstep must be flagged as lockstep in both the change log and MOBILE_APP_SPEC §0.
0.1 Spec ↔ firmware parity¶
Code homes: watch WatchFIrmware/src, anchor AnchorFirmware/src, shared proximity proximity_engine. Last audited: 2026-07-28.
| Spec area | Watch | Anchor | Notes |
|---|---|---|---|
| Schedule system + recurrence (§5.3) | ✅ | ✅ | Watch verified: recalculate_day() with daily/weekly/monthly, NVS blob persistence, midnight re-arm. Anchor now implements §4.7 (2026-07-11): full blob persisted to NVS, local anchor_recalculate_day() (daily/weekly/monthly + once-by-date + negate + sort) on receipt/boot/midnight; the recurrence-day bug is fixed (matching runs against today's computed list). |
| GATT surface (§4.4, §5.6) | ✅ …0011–…001B present |
✅ …0002–…000D present |
Verified by UUID grep. Pending Changes …001A (Read+Notify) and Emergency Pass …001B (Read+Write+Notify) created 2026-07-12. |
| Schedule transfer (§6.2) | ✅ v2 blob | ✅ v2 blob | Format version byte (0x02) now implemented on watch + anchor (2026-07-11); unknown version → 0x00 / HTTP 400. donning_grace_s field parsed on both. Extended Settings payloads landed (watch +settle_window_min, anchor +tz_offset). Lockstep with the app's ScheduleEncoder — flash together. |
| Proximity engine (§4.10, §5.4.1) | ✅ | ✅ | Shared module, working per commit history. |
| Enforcement / worn / LED (§5.2–§5.7) | ✅ | — | Look-to-wake removed in v0.7 — DORMANT shows the always-on analog clock (§5.7.4) with no motion interrupt. v0.16 (2026-08-10), hardware-verified: proximity verdict on the ring (§5.7.5, slots 7–8), two-LED pilot latched through enforcement sleep and the alarm blink's dark half. §5.7.2 priority 1 reversed — enforcement sleep no longer clears the ring. |
| Anchor escalation over UDP (§5.5.1) | ✅ | ✅ | v0.16. Was edge-triggered once per worn transition and silently dead whenever the watch had no WiFi — which, on the bench watch, was always: no saved credentials, so every WATCH_REMOVED was refused locally. Now re-asserted per enforcement poll, WiFi kept alive for windows with beepAnchors, and the retry path logs. Verified end-to-end on hardware 2026-08-10 (test enforcement --criteria get_away --beep-anchor, 5-minute window, watch unworn throughout): window-start WATCH_REMOVED → anchor [ALARM] start, five per-poll re-assertions each answered already beeping, anchor sounded for 299 808 ms of a 300 000 ms window. |
Bench provisioning over serial (0xB0/0xB1) |
✅ | — | v0.16. WiFi credentials and clock were BLE-only and so unreachable from a cable-only bench. BENCH_NO_SLEEP builds only, alongside the existing 0xAF NVS wipe. 0xB1 verified on hardware: set the clock, the schedule recalculated, and the 20:20–21:20 getAway window opened correctly. |
Phone docking (§4.11, phoneAway §5.4.1) |
✅ | ✅ | Verified on hardware 2026-08-01 (branch phone_dock_refactor), anchor + watch + a host-BLE stand-in phone. Anchor: Dock Register 0x01/0x00, 2 s RSSI poll, notify-on-change, docked at −51 dBm, clean undock on unregister. Watch: [PROX] Dock status: docked/UNDOCKED read inside the query, near_phone fusion, 60 s PHONE_AWAY_TOLERANCE_S honoured then sustained enforcement (compliant for 11 polls docked → grace → met=0 at 70 s). Anchor advertises throughout a held link (310–538 ads/link), so the watch can still reach it during a dock session. One defect found and fixed: ble_gap_conn_rssi() returns rc=0 with a foreign RSSI (−86…−109, the watch's) on the docking handle immediately after another link tears down, which produced spurious undocks — i.e. false enforcement against a compliant user. Now requires DOCK_UNDOCK_CONFIRM_POLLS (3 = 6 s, well inside the 60 s tolerance) consecutive bad samples before believing an undock. |
| Broadcast undock + local dock alarm (§4.11.1, §5.4.1) | ✅ latch + dock-watch scan | ✅ adv flag + local alarm | v0.17 (2026-08-11). Closes the hole that made "walk out of the room carrying the phone" the most compliant-looking action available on phoneAway: the dock signal was connection-only, so leaving range read as dock == -1 → docked → compliant, forever. Anchor now advertises ANCHOR_ADV_FLAG_UNDOCKED (MSD byte 25, appended — the watch's size < 25 guard makes it backward-compatible) while a phoneAway window it is anchorId for is open, the phone has been docked at least once in that window, and it is not docked now; and it alarms locally on that same evidence rather than waiting for a WATCH_REMOVED it will never receive — continuously, not on the AnchorEnforcementProfile duty cycle, whose 5–29% duty is tuned for an unworn watch rather than an unattended phone. Watch runs a 10 s / 600 ms passive dock-watch scan (the enforcement poll's 180–600 s cadence would miss the in-range window essentially always), latches what it sees, and clears only on an observed re-dock. Dock alarms sound continuously rather than on the 5–29% duty cycle the profiles apply to WATCH_REMOVED. Both firmwares build; not yet verified on hardware — flash together, this is a lockstep pair. |
| Commitment integrity (§9) | ✅ Phases 1–3 | — | Phase 2 done (2026-07-11): full §9.1/§9.3 diff gate (per-key event diff, tighten-immediate / loosen-quarantine, unsettled-above-baseline + far-future exceptions), §9.2 settle state in NVS on the monotonic elapsed basis, NVS pending queue (full proposed state, supersede + expiry rules), §9.4 autonomous promotion, Pending Changes …001A (Read+Notify, §9.5 format), END responds 0x01/0x03 (0x04 fast-path retired per §6.2). Phase 3 done (2026-07-12): Emergency Pass …001B (SPEND applies a one-day negate bypassing the gate; SET_ALLOWANCE raise quarantined 0x03 / lower immediate; Read returns allowance/remaining/regen countdowns, all elapsed-basis), §9.7 time + Settings-tz hardening (0x02 reject when a clock/tz write would escape the active window), §9.8 settings gating (dormancy true→false and settle-window increases quarantined, reverse immediate). On-hardware validation pending. |
| Donning grace + window-start unworn beep (§5.4.4) | ✅ | ✅ (blob parse) | Done (2026-07-11). Watch: window-start WATCH_REMOVED when unworn, donningGraceS grace with deadline/short-circuit/sleep-cap/expiry-recheck/removal-cancel. Watch Status condition_met byte added. Anchor parses donning_grace_s for wire parity. |
| Anchor schedule persistence + midnight recalc (§4.7 as of v0.5) | — | ✅ | Done (2026-07-11): NVS blob persist + SNTP/tz-based local recalc on receipt/boot/midnight; fail-open when no valid time or no stored schedule. |
| Prox v2 P1: IMU motion channel + HMM + STILL poll tier (§5.4.5, §5.4.1) | ✅ | — | Watch-only, no wire change. Landed and hardware-validated; PROX_V2_AUTHORITATIVE = 1 — the v2 decision is binding, not shadowed. S3 thresholds measured (IMU_STILL_VAR = 400, IMU_LOCO_VAR = 15000). v0.16: subject to the §13.0 NEAR floor — a confident v0.8 NEAR binds over an HMM AWAY/AMBIG, because the still-window ceiling can strand the posterior on the wrong side indefinitely for a motionless wrist (see the v0.16 change-log entry for the captured lam). AWAY still goes the long way round through the filter. |
| Prox v2 P2: beacon schedule (§4.3, §4.12) | — | 🟡 | Shipped as the S1b power-only 2-slot fallback: BEACON_CHANNEL_CONTROL = 0, so slot_id ∈ {0, 3}, CHANNELS_VALID = 0, and per-channel RSSI / cross-channel spread are unavailable. PDR is unaffected and works. BEACON_TX_LO_DBM = −6 (S1a-measured; not the amendment's −21). |
| Prox v2 P2: stepped-power PDR (watch-local) | ✅ | — | Watch measures LO-slot hit ratio inside the observation window and folds it in as a local log-LR. Needs no wire change — it never travelled in the vector. Measured 97% hit inside / 0% at the edge. |
| Prox v2 P2: observation window (§5.4.1 step 1) | ✅ | — | PROX_OBSERVE_WINDOW_MS = 1800, passive full-duty, IMU burst sampled concurrently. |
| Prox v2 P2: vector v2 + score2 + side table (§4.10.8, §6.3.1) | ❌ | ❌ | Not built. The wire is still the v0.8 [count][N×8] format with no version byte and no trailer (prox_serialize_vector). §4.10.8 does not exist in code. Lockstep with the app parser when it lands. |
| Prox v2 P2: calibration two-leg (§4.10.7, §5.6) | ✅ | ✅ | Shipped as calibration-v2, which supersedes the amendment's near/away leg byte: phases INSIDE/EDGE plus one-shot FINALIZE/ABORT ops, deferred re-scoring, and a per-anchor demonstrated cutoff. See CALIBRATION_V2_DESIGN.md. |
| Prox v2 P3: coupling detector, per-channel δ (Mode C) | ❌ | — | Not started. Blocked on P2 field validation. |
| Prox v2 P4: FTM / CSI | ❌ | ❌ | Spike-gated (S4/S5); see §10.1 toolchain notes. Do not pre-build. |
| Prox v2 P5: R1 starved-vector abstention (§4.10.3) | — | ✅ | Pulled forward — closes a live exploit. PROX_STARVED_SCORE = 128 replaces the old score-50 branch. |
| Prox v2 P5: R4 connect-failure motion witness (§5.4.1) | ✅ | — | Pulled forward. Connect-failure evidence contributes only with a LOCOMOTION witness. |
| Prox v2 P5: R2 offset-invariant Signal B (§4.10.3) | — | ✅ | Landed on branch r2-offset-invariant-fingerprint, with the separate PROX_FP_MIN_PRESENT floor and within-class (WiFi vs BLE) correlation. |
| Prox v2 P5: R3/R5 occlusion classifier + trailer (§5.4.6, §6.3.1) | ❌ | ❌ | Not built. No OCC_* constants exist in proximity.h. S6-gated, and lockstep with the app. |
| Anchor WiFi credential slots + WiFi Status char (§4.4, §4.5.1) | — | ❌ | New in v0.8. 4-slot NVS credential table with dedup/LRU eviction, slot-cycling retry loop, …000E WiFi Status (Read+Notify), non-blocking credential write response. Lockstep with MOBILE_APP_SPEC §8.14. |
| Watch-side anchor WiFi repair (§5.5.3) | ❌ | — | New in v0.8. Battery-gated (>4000 mV), 20-min interval, schedule-referenced anchors only. No wire-format change on the watch side; depends on the anchor …000E characteristic above. |
| Prox v2 P1: IMU channel + HMM + STILL poll tier (§5.4.1, §5.4.5) | 🟡 shadow | — | New in v0.9 (2026-07-28). Code complete and green on the §10-A P1 host acceptance suite (proximity_engine/tests, 435 checks). Partial hardware verification done 2026-07-28 (two defects found + fixed); motion-dependent and sleep-dependent checks outstanding. Runs in shadow: PROX_V2_AUTHORITATIVE = 0, both decisions logged per query as [PROXv2], v0.8 still binding. The STILL poll tier is gated on the same flag. Flip after an evening of on-hardware shadow logging. Watch-only; no wire change. |
| Prox v2 P2: beacon schedule (§4.3, §4.12) | — | ❌ | Blocked on Spikes S1/S2 (hardware). |
| Prox v2 P2: vector v2 + score2 + side table (§4.10, §6.3) | ❌ | ❌ | Lockstep with app parser. Note the score-characteristic collision recorded in the v0.9 change log. |
| Prox v2 P2: calibration leg byte (§5.6, §4.10.7) | ❌ | — | Same lockstep batch. |
| Prox v2 P3: coupling detector, per-channel δ (Mode C) | ❌ | — | After P2 field validation. |
| Prox v2 P4: FTM / CSI | ❌ | ❌ | Spike-gated (S4/S5); CSI additionally needs the anchor IDF 5.x migration. |
| Board revision V1 — nRF54LM20A (§1.3) | 📋 | — | New in v0.11. PCB ordered 2026-08-07; no boards in hand, no firmware, no Zephyr port. §1.3 is a transcription of the KiCad netlist, nothing more. The ESP32-C3 pin map in §1.1 remains the one every line of shipping code is written against. v0.12: V1 is confirmed a bring-up prototype, not the shipping/certification revision; no USB data path, deliberately. v0.14: the LFXO is present — embedded in the BM20C module. |
Legend: ✅ implemented/verified · 🟡 believed implemented, unverified · ⚠️ diverges from spec · ❌ not started · 📋 hardware/plan recorded, nothing built
0.2 Change log¶
v0.17 — 2026-08-11 — phoneAway could not see the violation it exists to catch
Found while trying to film the phone-dock flow: take the phone off the dock, walk away, and nothing happens — the watch keeps flashing green (which is correct, LED_SLOT_PROX_AWAY is green and it was away from the anchor) and the anchor stays silent. Three separate reasons, and the first is the real one.
-
The dock signal was connection-only. The watch learns
dockedby BLE-connecting to the anchor inside its enforcement poll (§5.4.1 step 1). So it can only observe an undock while it is still in radio range — and carrying the phone out of the room ends that in seconds. Past that point "walked away, phone still docked" (compliant) and "walked away carrying the phone" (violation) are the same observation: anchor unreachable →dock == -1→ treated as docked → compliant, permanently. The single most compliant-looking thing a user could do on this criterion was to commit the violation. Fixed by the broadcast flag + watch-side latch (§4.11.1, §5.4.1 step 5). -
Nothing could ever have made the anchor beep for this.
send_to_anchors()has exactly two call sites,WATCH_REMOVED(0x01) andWATCH_WORN(0x02), both driven by the IR worn sensor; the anchor'shandle_udp()callsstart_beeping()only under0x01. A failed criterion sent nothing, to anyone. The anchor detected the undock within ~6 s the whole time, pushed it to Dock Status, and did nothing else with it. It now alarms on its own evidence (§4.11.1) — the watch cannot raise this one, because by then it cannot see it. -
Even the
WATCH_REMOVEDpath was disarmed for this template.PhoneFreeTemplate.expand()sets neitherbeepAnchorsnoranchorProfile, so the anchor would have rejected the datagram with "this anchor is not in the event's beepAnchors." Rather than require an app change to fix a firmware-visible hole, §4.11.1's local alarm defaults a missinganchorProfiletoAP_MEDIUM— the dock alarm must not be silently dead for every commitment made by the template that exists to make them. -
The anchor's alarm duty cycle was tuned for the wrong event. All three
AnchorEnforcementProfilevalues back off hard — 5%, 9% and 29% duty, the gentlest leaving 60 s of silence between 3 s bursts. That is right forWATCH_REMOVED, which frequently just means a watch on a charger. It is wrong for a dock violation, where a 30-second gap is comfortably long enough to pick the phone up and leave. Dock alarms now runBEEP_ANCHOR_ALARMcontinuously and ignore the profile duty cycle, still bounded byg_max_beep_minutesand the event'sendTime.
Lockstep: watch and anchor must be flashed together. A new watch against an old anchor sees no flag and behaves exactly as v0.16; an old watch against a new anchor ignores the appended byte. Neither combination is broken, but neither gives the new behaviour. No app change and no schedule wire-format change.
Not the fix, deliberately: the poll cadence was left alone. ENFORCEMENT_POLL_INTERVAL_MET_S (180 s) and the ENFORCEMENT_POLL_INTERVAL_STILL_S (600 s) tier that phoneAway sits in are correct for what they do; the problem was never that the poll was too slow, it was that the poll was looking down the only channel that goes dead at exactly the wrong moment.
v0.16 — 2026-08-10 — Three enforcement defects found on hardware while filming Sunrise Lock
A bench session on the ESP32 watch + anchor (branch sunrise-lock-demo-fixes, off r2-offset-invariant-fingerprint) chasing "the watch does not beep when I am standing next to the anchor". Three independent faults, only the first of which was a guess anyone had made.
- §13.0 NEAR floor (§5.4.1). With
PROX_V2_AUTHORITATIVE = 1the HMM is binding, and it was reporting AWAY while the watch sat one metre from its anchor. Captured:
v0.8=NEAR v2=AWAY p_near=16 lam=-731 score=209/170 emit=+152
v0.8=NEAR v2=AMBIG p_near=120 lam=-34 score=213/170 emit=+184
v0.8=NEAR v2=AMBIG p_near=120 lam=-34 score=205/170 emit=+120
lam is frozen across consecutive polls. That is the still-window ceiling in hmm_fold() working as designed — a motionless wrist earns no fresh fading draw, so the window's evidence is capped at one observation's worth and re-reading the same strong score adds nothing. Correct as a defence against a parked wrist marching to false confidence; wrong as the last word, because it strands the posterior wherever the window's first observation put it and no amount of later agreement can move it. On getAway the effect was that the watch went compliant while the user stood next to the anchor — the criterion's entire failure mode — and only ever alarmed via the §13.4-R6 liveness timeout, i.e. for the wrong reason and up to five minutes late.
Fixed at the prox_decide() seam: a confident v0.8 NEAR binds over an HMM AWAY/AMBIGUOUS. This is the engine's own asymmetry, not a new rule — passive attenuation can fabricate AWAY but never NEAR, so a score clearing the anchor's calibrated cutoff plus hysteresis cannot be an artefact of a degraded link. Deliberately not a revert of the v2 cutover: AWAY, AMBIGUOUS, the connect-failure channel, PDR and the locomotion gate are untouched. Logged as NEAR floor — v0.8 NEAR binds over v2 ….
-
Anchor escalation could not fire at all (§5.5.1). The watch had no saved WiFi credentials, and anchor beeping is UDP-only — so every
WATCH_REMOVEDdied atsend_to_anchorswithNOT SENT: no WiFi. This was invisible:try_connect_saved_wifi_async()was silent on every path including "no credentials", so a watch that could never reach an anchor looked identical to one choosing not to. Diagnosed by finding the anchor in the host's ARP table and the watch absent from it. §5.5.1 now re-asserts per poll instead of once per edge, keeps WiFi alive for windows withbeepAnchors, and the retry path logs what it is doing. -
No WiFi also meant no SNTP, so the watch's clock read 1952-02-07. The watch is the root of trust for the schedule and the clock (§5.3), window entry is matched on the exact local minute, and the anchor independently window-checks every
WATCH_REMOVEDagainst its own SNTP-correct clock (§4.7). A watch two clocks adrift can therefore enter windows at the wrong wall time and have its escalations rejected as "outside window" even once WiFi returns. Not a code defect — a provisioning one — but it is the failure that makes the other two hard to see, and nothing surfaced it.
Both provisioning paths (WiFi credentials, clock) were BLE-only, i.e. unreachable from a bench with a cable but no working BLE central. Added bench-only serial opcodes alongside 0xAF: 0xB0 sets WiFi credentials, 0xB1 sets the clock. Compiled out of any build that is not BENCH_NO_SLEEP; a cable is already required, so they grant no escape a reflash would not. 0xB1 deliberately skips §9.7's escape-the-active-window guard — that guard defends a commitment against its owner holding the phone, and this opcode exists precisely to reposition a window for a take.
-
§5.7 LED. The ring showed compliance and never proximity, and went fully dark at every enforcement sleep — so for most of a window it said nothing, and when it did speak it could not distinguish "I think you are away" from "I have not decided" from "grace is holding me quiet". New slots
LED_SLOT_PROX_NEAR(7) andLED_SLOT_PROX_AWAY(8), a two-LED pilot latched through enforcement sleep and through the alarm blink's dark half, and stale verdicts rendered as undecided. See §5.7.5. Slot ids are appended, so the app stays compatible;LED_SLOT_COUNT7 → 9 changes the NVS blob size, which the existing exact-size check rejects, so watches fall back to defaults once. -
The serial command handler never ran on an unpaired watch. It sat at the bottom of
loop(), below theSTATE_UNPAIREDearly return, so on a factory-blank watch no opcode worked — including0xAF, meaning the NVS-wipe recovery path was unreachable on a watch that had just been wiped. Found immediately after adding0xB0: a freshly erased board answered it with silence. Hoisted intoserial_command_tick()and called before that return, on the same reasoning theBENCH_HARNESStick above it already used — a tethered board is usually an unpaired one. All opcodes now work in every state. -
The watch logged WiFi passwords in plaintext, on both credential paths (
try_connect_saved_wifiand the BLE Settings write). Serial logs from these boards are routinely pasted into issues, agent transcripts and the capture corpus. Now logs the password's length, which is what actually diagnoses the failure — it separates "stored empty", "truncated at 63" and "stored fine" — without carrying the secret. The anchor was checked and never did this. -
Bench note, not a firmware change: PlatformIO's bundled esptool 4.5.1 cannot reset these boards (
OSError: [Errno 71] Protocol errorfromsetRTSinsideUSBJTAGSerialReset), sopio run -t uploadfails. esptool 4.11 fromphone_sim/.venvflashes them fine. And the two boards swap/dev/ttyACM*numbers when either re-enumerates — always address them by/dev/serial/by-id/…_<MAC>-if00. Seeagent-notes.md; both cost real time in this session.
v0.15 — 2026-08-10 — Correction: Board V1's vibration motor and servo pins were transposed
Every mention here of the V1 pin map is a forward reference to firmware_spec_v3_nrf.md §2, and that table had two rows crossed. Re-exported from Impulse.kicad_sch on 2026-08-10: the vibration motor is P3.03 / ball A7 (via R5 → Q3 BSS138 → M2), and P1.02 / ball A8 is the servo (/SERVO → U1 pin 1). P3.09 / K7, which the spec named as the servo, has no net at all.
Nothing about the ESP32 firmware is affected — this document's own pins are GPIO numbers on a different MCU, and the firmware source names only those. What changed here are the three forward references (§0.1's V1 note, the pin-share note, §1.3's summary) and one quoted constant comment. The BLK-03 claim is unaffected: ring P0.02 and motor P3.03 are still separate pins, so the v2 §1.1 share is still designed out.
Full detail, including why firmware built from the wrong table would have driven the anchor's strap lock on every vibration event, is in firmware_spec_v3_nrf.md §0.5 v0.8.
v0.14 — 2026-08-09 — Correction: the BM20C embeds its own 32.768 kHz crystal
v0.12 recorded that Board V1 has no LFXO and that the LF clock therefore runs from the internal RC — a correctness risk for §5.3's exact-minute window matching. That was wrong. The BM20C datasheet (datasheets/BM20C.pdf, Draft Ver 0.82) states "32.0 MHz and 32.768 kHz crystals embedded"; both clocks are inside the Fanstel module. The "optional sleep crystal" in the integration guidance is an additional external part, not a missing required one.
Nothing in this document's ESP32 behaviour is affected. firmware_spec_v3_nrf.md §3.2 is rewritten, and what remains there is one firmware task rather than a hardware risk: Zephyr must be told to select LFXO, or it silently falls back to RC with the crystal unused.
The reason this got recorded wrong is worth keeping: it came from a recollection — the author's note that the sleep crystal was the one optional item skipped — treated as a hardware fact without checking a datasheet. §0's rule that the code is the fact and the spec is the bug has a third case: a note from the author is a report, not a measurement.
v0.13 — 2026-08-09 — §1.3 handed off to firmware_spec_v3_nrf.md; no firmware change
- New document:
firmware_spec_v3_nrf.md— the reference for the nRF54LM20A / Zephyr firmware. It inherits this document's MCU-independent chapters by reference (its §0.2 lists them) rather than restating them, so this document stays normative for schedules, enforcement, wire formats, and all of §9. - §1.3's pin map moved there (v3 §2) rather than being copied. One board, one pin table.
- The watch and anchor are the same PCB — confirmed by the author. The
/SERVOheader onP1.02is the anchor's strap lock (§4.9), not an unexplained addition to a wearable; v0.11's note calling it unexplained was wrong and is gone. (Pin corrected in v0.15 — this saidP3.09.) This also makeslaunch-plan.md§5's "one PCB, two host configurations" FCC scoping literally true. - The proximity engine is deleted on nRF, with no compatibility path — both ends of the link now support Bluetooth Channel Sounding, so distance is measured rather than inferred. §4.10, §4.12, §5.4.5, §5.4.6, §6.3 and §10-A of this document do not apply to the nRF build. RSSI survives only for phone docking (§4.11). Deleting the proximity/fingerprint/calibration characteristics is a lockstep wire change with
MOBILE_APP_SPEC.md; v3 §4.8 lists exactly what the app drops.
v0.12 — 2026-08-08 — §1.3 corrections from the author; no firmware change
Three answers to open questions, all recorded in §1.3. Documentation only.
- V1 is a bring-up prototype, not the shipping revision. The log's word "final" meant functionally complete enough to live with, not ready to certify. v0.11 read it the other way and said so in
launch-plan.md§5; both are corrected. §12.6 — certify the shipping board, not the prototype — applies to V1 in full. - The absent USB data path is deliberate, for board space, with RTT over SWD covering debug. Recorded with its second-order consequence: USB CDC is a no-tools, no-trace bypass on a commitment device, so its absence hardens the threat model. Not a marketing claim (§10 forbids the framing) — a design constraint to weigh before anyone re-adds USB data for convenience.
- ~~No 32.768 kHz LFXO is fitted~~ — wrong, corrected in v0.14. Both crystals are embedded in the BM20C module.
v0.11 — 2026-08-08 — Board revision V1 recorded (nRF54LM20A); no firmware change
Documentation only. No device behaviour changes and no code moves in this revision — the shipping firmware is still ESP32-C3 and every constant, pin, and wire format elsewhere in this document is unaffected.
- New §1.3 — Board revision V1 (nRF54LM20A). The watch PCB was ordered 2026-08-07 with the ESP32-C3 replaced by two Fanstel modules: BM20C (nRF54LM20A, Bluetooth + host MCU) and WM02E (WiFi, on QSPI with a BT/WiFi coexistence handshake). Two layers became four; the 12× SK6805 ring moved from a separate part onto the back of the board; a user-accessible wake button was added on
P0.05. §1.3's pin map is transcribed from~/Documents/KiCad/ImpulseBluetooth/Impulse.kicad_sch(title block "Impulse Board V1"), not from a datasheet or a plan. - The motor / LED-ring pin share is designed out. On V1 the ring is on
P0.02and the vibration motor onP3.03(through R5 → a BSS138 low-side switch). (Motor pin corrected in v0.15 — this saidP1.02, which is the servo. The claim itself is unaffected: they are still separate pins.) This is the changelaunch-plan.md§13 has been tracking and the one that gates certification — but the boards have not arrived, so §1.1's constraint still governs every unit that physically exists, and the claims fence inlaunch-plan.md§10 is unchanged. The §1.1 LED data-pin note is amended to say which revision it is talking about. - §7
LED_RING_DATA_PIN's "next rev: GPIO 7" plan is retired. That was an ESP32 plan for a board that was never built. The comment now points at §1.3. - Why the pivot happened is recorded in
vault/docs/agent-notes.md(2026-08-08) rather than here, because it is a judgement call about the proximity engine rather than a device behaviour: the nRF54LM20A supports channel sounding, which replaces the RF-fingerprint engine's statistical distance estimate with a direct measurement.proximity_engine_spec_v2.1.mdcarries a matching banner.
v0.10 — 2026-08-08 — Amendment merged; no behavioural change
Editorial only. firmware_spec_v0.9_amendment.md (17 parts) is folded into the sections it patched, so this document is once again the single normative firmware reference. Each merged block carries a ✅ / 🟡 / 📋 status marker, because the amendment had drifted from the code in ways that would mislead a reader who applied it literally:
PROX_V2_AUTHORITATIVEis 1, not 0. The v2 decision is binding; the shadow period is over.BEACON_TX_LO_DBMis −6, not −21 (S1a measured it on real hardware).IMU_STILL_VAR/IMU_LOCO_VARare 400 / 15000, no longer TBD (S3 landed).IMU_STALE_MSis 10000, not 5000.BEACON_CHANNEL_CONTROL = 0— S1b's power-only 2-slot fallback is what ships, so every per-channel feature in Parts 4/12 is dark.- The proximity vector is still v0.8 on the wire (no version byte, no trailer). Amendment Parts 12 and 17.1 are plans, not descriptions.
- The Proximity Score characteristic ships 3 bytes
[score][flags][near_threshold]— the third byte is calibration-v2's per-anchor cutoff, not the amendment'sneff. Part 13 was never implemented as written; §4.4 now records the shipped layout and the reconciliation owed. - Calibration shipped as calibration-v2 (INSIDE/EDGE phases + FINALIZE/ABORT), which supersedes the amendment's near/away leg byte.
HMM_STILL_DRAW_PERIOD_S(43200) is a real constant that the amendment never named.
v0.9 — 2026-07-28 — Proximity engine v2.1, Phase 1 only (watch only; no wire-format change; the full v2.1 plan is now §10-A of this document, and proximity_engine_spec_v2.1.md carries the math)
Phase 1 of the amendment's §10-A roadmap has landed in proximity_engine/; Phases 2–4 have not started (P2 is gated on hardware spikes S1/S2, which cannot be run without the devices).
- Root cause being fixed. Indoor RSSI reads flat not because distance is unmeasurable but because a stationary receiver draws one small-scale fading sample per frequency and then re-measures that same sample forever. v1 averaged those repeats into confident nonsense — a wrist parked in a null averaged its way to "away". v2.1 counts independent fading draws, not samples, and lets the decision change only when the IMU says the wrist could have moved.
- Motion channel (§5.4.5, new). STILL / FIDGET / LOCOMOTION / UNKNOWN from three zero-cost inputs: the existing ENFORCEMENT-only IA1 wake verdict over each light-sleep interval (
prox_note_sleep_interval), awake IA1 firings (prox_note_motion_interrupt), and one 32-sample @ 50 Hz accelerometer burst per query, sampled inside the pre-query scan's wait loop so it adds no awake time. DORMANT's v0.7 zero-IMU-interrupt guarantee is unchanged — no new interrupt source exists, and nothing here runs outside ENFORCEMENT and calibration bursts. - Motion-gated integrator (
ProxIntegrator). Replaces the co-location EWMA rings. While STILL: N_eff frozen, mean tracked atINTEG_STILL_WEIGHT(enough for genuine slow shadowing), and reported variance relaxes back outward toward one draw instead of collapsing on near-zero residuals. STILL → LOCOMOTION restarts the window. - Two-state motion-conditioned HMM (§5.4.1 step 8). Replaces the threshold interpretation and the co-location two-threshold hysteresis machine, returning the identical
NEAR / AWAY / AMBIGUOUSinterface with the identical criterion-dependent fail-safe, so no downstream consumer changed. Implemented as one scalar log-odds in Q8; the two-state forward update collapses tolse(L, -c) - lse(L - c, 0), two table lookups. Nolog/exp/sqrton any tick path —prox_luts.his generated byproximity_engine/tools/gen_prox_luts.py. - Connect-failure evidence. The ad-hoc v0.8 rule ("failed connect + advertisement ≤
PROX_FAR_RSSI_THRESHOLD_DBM⇒ AWAY") is deleted at its call site and routed throughprox_note_connect_failure()as a log-LR, so it competes on the same scale as everything else instead of overriding the filter. The constant moved fromwatch_prox_transport.hintoproximity.h. - Third poll tier. Met + HMM-confident + STILL backs the enforcement poll off to
ENFORCEMENT_POLL_INTERVAL_STILL_S(600 s). Any IA1 firing exits it immediately. A fresh IMU burst is taken just before enforcement light sleep so the tier decides on current evidence rather than a stale reading. - Shipping in shadow.
PROX_V2_AUTHORITATIVE = 0: both decisions are computed on every query and logged side by side ([PROXv2] … <-- DIVERGED), with v0.8 still binding and the STILL tier disabled. Flipping that one constant is the entire cutover. - Two recorded deviations from the amendment, both in code comments and
proximity_engine/tests/prox_v2_p1_notes.md: 1.near_thrinProxScoreResult2. The amendment's emissionlogit((score+0.5)/256)is centred on score 128, which would silently discard this branch's calibration-v2 per-anchor demonstrated cutoff (a score of 180 would read NEAR-ish for an anchor whose demonstrated cutoff is 210). The emission is centred on the anchor's own decision point instead. Consequence for P2: the score characteristic already carriesnear_thrin byte 3, which amendment Part 13 reassigns toneff; that payload must become 4 bytes[score][flags][neff][near_thr]. 2.NEFF_LOCO_PER_S/NEFF_FIDGET_PER_Sare draws per second, not pre-scaled Q4. Read as Q4 they credit 0.5 draws per second of walking — 32× below the rate the engine spec's own §1.3 derives (λ/2 = 6.2 cm at ~1 m/s ≈ 16/s) — and no walk-in accumulates enough evidence to flip promptly. N_eff is stored Q4; the rates are whole draws. - New constant
HMM_STILL_DRAW_PERIOD_S(12 h) replaces the amendment's unspecified "PFLIP_STILL is small, not zero" trickle with a physical quantity: how long a provably-motionless wrist must sit before the engine concedes one fresh independent draw. It alone sets the still-lock strength — at 12 h an all-night frozen fade cannot flip a decision, while a silently stuck IMU still yields to sustained contradiction within half a day. - Two defects found by on-hardware shadow logging, both fixed, both now regression-tested. They are the same defect twice, in opposite directions, and both concern how a still window accounts for evidence:
1. Watch-local evidence must ride the draw gate. The amendment's §6.1 emission places
l_localoutside theg(N_eff)discount. A stationary watch out of comfortable range re-fails its GATT connect on every poll, and each failure added a fullLL_CONNFAIL_AWAY_Q8— the posterior marched −610 → −1378 → −2146 → −3473 to saturation on one observation repeated. That is precisely the frozen-fade failure this engine exists to prevent, arriving through a different channel.l_localis now summed with the anchor's verdict before the gate. 2. A still window's evidence is a level, not a per-tick increment. Fixing (1) alone made whichever observation arrived first in a window spend its single draw and lock out every later one: a weak connect-failure hint pinned the posterior at AMBIGUOUS while the anchor's actual score — unambiguously far — was suppressed as "the same draw", permanently. Each still tick now credits only the change since the window's last reading, and only in the direction the observation points. Re-reading the same thing adds nothing; a materially different reading still lands. - Acceptance: all four §10-A P1 tests (frozen-fade, walk-approach, teleport-rejection, IMU-stale) plus LUT/motion/integrator/coloc/connect-failure coverage — 435 checks green, host-side, no hardware (
cd proximity_engine/tests && make). - On-hardware shadow logging: partially done (2026-07-28, tethered watch, blank NVS, anchor at ≈ −79 dBm, via the
BENCH_NO_SLEEPbench harness). Verified: clean boot, IMU burst plumbing, motion classifier stable on real sensor noise, the full scan → vector → connect → score → HMM → log path, draw gating under repeated evidence, flat heap over ~10 min of continuous query load, and zero posterior drift across a 4-minute soak of unchanging still evidence (48/48 decisions at an identical log-odds). Measured resting burst variance is 9–33 mg² against the 400 mg²IMU_STILL_VARplaceholder — better than 10× headroom, so STILL is declared robustly. Still outstanding, and required before flippingPROX_V2_AUTHORITATIVE: anything needing physical motion (FIDGET/LOCOMOTION classification, the cadence detector, the IA1 seam, walk-approach end to end) and the sleep-interval verdict, which a no-sleep bench build cannot exercise. Details inproximity_engine/tests/prox_v2_p1_notes.md§4.
v0.8 — 2026-07-21 (anchor wire-format change — lockstep with MOBILE_APP_SPEC v1.3 §8.14)
- Anchor WiFi credentials become a 4-slot table (§4.4, §4.5.1). ANCHOR_WIFI_MAX_CRED_SLOTS = 4, deduplicated by SSID, LRU-evicted preferring never-connected slots. Credential offers are now non-destructive — the central failure mode of the old single-pair store (overwriting working credentials with wrong ones and stranding an anchor) is closed by construction. Slots are tried most-recently-successful first, so a healthy anchor never drops a working link for a speculative offer.
- New anchor characteristic: WiFi Status …000E (Read + Notify) (§4.4). [state][ssid_len][ssid][ipv4][rssi][slots_used]. Previously nothing could ask an anchor about its own network state, so the app could only infer health from HTTP timeouts and the only remedy was a blind overwrite. States 0x03 (auth failed) / 0x04 (AP not found) define distress. Exposing ipv4 also gives the app a BLE fallback for learning anchor addresses when mDNS is blocked or iOS Local Network permission is denied.
- Credential write is now non-blocking (§4.4) — behavior change. Responds 0x01 = accepted, immediately; the connection outcome arrives via a WiFi Status notification. The old blocking write stalled 10–15 s against BLE write-response timeouts, making it least reliable in exactly the recovery scenarios it exists for. 0x01 no longer means "connected."
- New §5.5.3 — watch-side anchor WiFi repair. A watch on the charger (battery > ANCHOR_REPAIR_MIN_BATTERY_MV = 4000 mV, DORMANT, not app-connected) checks nearby anchors every 20 min and offers its own stored credentials to any in distress. Motivated by Sunrise Lock: the watch sits on the nightstand all night beside the exact anchors that commitment depends on, and a stranded anchor means the alarm silently never fires (§4.7 fail-open). Eligibility is restricted to anchors referenced by the watch's own schedule (anchorId / beepAnchors) — a security boundary, since any device can advertise Major 0x4A0F and "any anchor in range" would leak the user's WiFi password to a planted device. The watch repairs distress only; it never offers to a healthy anchor. No Watch Status change — repairs are silent and the app infers them from the anchor's own …000E.
- §1 correction — battery sense is functional. The hardware table's "non-functional, reserved for future" note on GPIO 3 was stale; read_battery_mv() works and is load-bearing for the §5.5.3 gate. A matching stale comment survives in main.cpp beside the battery_pct packing.
- New constants (§7): ANCHOR_WIFI_MAX_CRED_SLOTS, ANCHOR_WIFI_SLOT_ATTEMPT_LIMIT, ANCHOR_REPAIR_MIN_BATTERY_MV, ANCHOR_REPAIR_SCAN_INTERVAL_S, ANCHOR_REPAIR_BACKOFF_S.
- Anchor WiFi must become event-driven (§4.5.1). The 0x03/0x04 distress split requires classifying the WiFi.onEvent() disconnect reason (WIFI_REASON_NO_AP_FOUND vs the auth-family reasons); the existing blocking WiFi.status() polls in try_connect_wifi/WifiCredCallback can't and are replaced.
- Schedule CRC readback (lockstep with MOBILE_APP_SPEC §8.16). Watch Status …0016 gains a 4-byte schedule_crc (after condition_met); the anchor answers GET /schedule with its stored blob's CRC32 and mirrors the same value in WiFi Status …000E. Lets the app confirm sync rather than infer it, catching a device that reset to a stale persisted schedule. Watch Status growing is a lockstep wire change.
v0.7 — 2026-07-14 (watch only; no wire-format change)
- Look-to-wake removed; DORMANT is now a zero-motion-interrupt state. The wrist-raise wake source (IA2, LOOK_WAKEUP_ENABLED, and the LOOK_* tunables) is deleted from imu.cpp/imu.h. DORMANT_SLEEP arms no IMU interrupt at all — it wakes on the timer/advertise-heartbeat and BLE only. Motion (IA1) remains a wake source only during ENFORCEMENT (§8.4 part 2), unchanged.
- Always-on analog clock (§5.7.4). Instead of lighting the ring on a glance, the watch keeps the 12-LED analog clock lit continuously and redraws it once per minute. The SK6805 ring latches its last frame, so the clock stays visible through DORMANT_SLEEP; it is repainted (minute-gated) by led_update() and refreshed just before each sleep. The second hand is removed — led_show_time() now takes (hour, minute) only and no longer blocks on a per-second animation. §5.7.2 priority 1 amended: DORMANT_SLEEP leaves the clock lit rather than clearing the ring (enforcement light sleep still clears it). This is a battery optimization: it eliminates the motion/look wakeups that were the dominant idle drain.
Anchor audit — 2026-07-12 (code fixes in AnchorFirmware/src/main.cpp; no wire-format change)
- Field bug — spontaneous restarts: several contributing causes fixed in the anchor. (1) Schedule store + anchor_recalculate_day ran inside the BLE SchedCtrl-END callback, i.e. on the NimBLE host task, racing the loop task's readers of g_sched_blob/g_events (use-after-free / corruption); now deferred to the loop task. (2) BLE BEGIN transfers malloc'd an untrusted 4-byte length with no cap; now bounded to 64 KB. Also added esp_reset_reason() + persisted reboot-counter / last-abnormal-reason logging at boot so field reboots are diagnosable. Note: the anchor disables the brownout detector (RTC_CNTL_BROWN_OUT_REG=0), so genuine rail dips will surface as PANIC/garbage resets, not ESP_RST_BROWNOUT, when reading that log.
- Field bug — intermittent Identify beep: the Identify callback beeped inline with a blocking delay() on the BLE host task and drove the buzzer pin in parallel with the loop's alarm state machine (single-core race). Reworked to a non-blocking request serviced by the loop with priority over the alarm; loop is now the single owner of the buzzer pin.
- Conformance — HTTP /schedule: the WiFi-credential BLE write started only mDNS + UDP on first connect, so the HTTP endpoint (§7) never listened until a later reconnect; now routed through setup_network_services().
- Pin divergence (flagged, unverified — needs hardware): anchor firmware uses GPIO 21 for the buzzer and GPIO 9 (BOOT) for factory reset; §1's anchor table lists the buzzer on GPIO 12 and does not mention a reset pin. Field beeping works, so the code pins appear correct and §1 is stale — left unchanged pending hardware confirmation. SERVO_MOVE_DURATION_MS is 1000 ms in code vs. 500 ms in §7 (tunable).
v0.6 — 2026-07-10
- New §5.4.4 "Donning Grace & Window-Start Worn Handling" (unblocks the Sunrise Lock template): the watch sends WATCH_REMOVED at window start if already unworn (transition-independent), and a new per-event donningGraceS field (uint16 seconds, 0 = none) suppresses watch-side enforcement for N seconds after the watch is put on.
- §3.2 / §6.2: Event struct and schedule blob gain donning_grace_s (inserted after negate). The v2 blob layout has never been implemented, so SCHEDULE_FORMAT_VERSION stays 0x02 — the field ships in the same lockstep batch.
- §5.6: Watch Status payload gains a condition_met byte (after active_event_id) so the app can distinguish "actively alarming" from "in-window, compliant" — lockstep.
- §9.1: classification row for donningGraceS (decrease = tightening, increase = loosening).
v0.5 — 2026-07-10
- New §9 "Commitment Integrity — The Watch as Root of Trust": on-watch schedule-diff gate (tighten = immediate, loosen = quarantined 24 h), canonical tighten/loosen classification shared with the app, on-watch settle timers + settled baselines, pending queue with autonomous promotion, Pending Changes characteristic (…001A), on-watch emergency-pass ledger + characteristic (…001B), time-write hardening, settings gating, and a 3-phase rollout plan.
- §4.7 rewritten: the anchor persists the full schedule blob to NVS and recomputes today's event list locally (on receipt / boot / midnight). The app's midnight push is removed (a phone cannot reliably run at midnight); replaced by staleness-based foreground push.
- §5.1.4: mid-day schedule pushes now route through the §9.3 diff gate instead of blind replacement.
- §6.2: schedule blob gains a leading format version byte (0x02) — lockstep with app + anchor; schedule END responses extended (0x03 partial-quarantine, 0x04 active-event rejection).
- §5.6 watch Settings payload extended: +settle_window_min u16 (clamped 30–240, gated per §9.8); §4.4 anchor Settings payload extended: +tz_offset_minutes int16 (needed for §4.7 local midnight recalc) — both lockstep with the app.
- §9.6/§5.6: Emergency Pass characteristic gains a SET_ALLOWANCE opcode (0x02). §9.3: pending entries store the full proposed state; a newer accepted edit to the same event cancels its pending entries. §9.4: expired pending entries are dropped. §9.7: guard extended to timezone changes via Settings.
- Audit: watch recurrence support verified present (no watch work needed); anchor recurrence/persistence divergence logged in §0.1.
v0.4 and earlier — pre-changelog; see git history. (v0.4 added the proximity engine, LED status ring, look-to-wake, analog-clock ring mode. Look-to-wake was later removed in v0.7.)
Table of Contents¶
- Hardware Reference
- System Overview
- Shared Data Structures
- Anchor Firmware - 4.1–4.9 Identity, GATT, WiFi, schedule, beeping, servo - 4.10 Anchor Proximity Engine (incl. 4.10.8 feature distributions & peer-anchor side table) - 4.11 Phone Docking Detection - 4.12 Beacon Schedule Task
- Watch Firmware - 5.1 State Machine - 5.2 Worn Detection - 5.3 Schedule System - 5.4 Enforcement (incl. 5.4.5 IMU Motion Channel, 5.4.6 Occlusion Channel) - 5.5 Anchor Communication - 5.6 BLE GATT Service (Watch ↔ Phone) - 5.7 LED Status Indicator
- Inter-Device Communication Protocols
- Constants & Tunables
- Power & Radio Optimization (Watch)
- Commitment Integrity — The Watch as Root of Trust
- Deferred Items & v0.8 Coding-Agent Handoff
- 10-A Proximity v2.1 roadmap (coding agent)
Note on modularity: All code implementing the proximity fingerprinting algorithm — on both the watch and the anchor — must reside in a single shared file, proximity.cpp, with a corresponding proximity.h. Sections of the spec that describe this algorithm are labelled (proximity.cpp). No proximity logic should appear in other firmware files; other files call only the public API declared in proximity.h.
1. Hardware Reference¶
§1.1 and §1.2 describe the hardware every unit that physically exists is built on, and are what the firmware in WatchFIrmware/ and AnchorFirmware/ is written against. §1.3 describes a board that has been ordered and not yet received.
1.1 Watch (ESP32-C3-WROOM)¶
| Function | Pin | Notes |
|---|---|---|
| Active buzzer | GPIO 12 | HIGH = on |
| SPI MISO | GPIO 2 | |
| SPI MOSI | GPIO 7 | |
| SPI CLK | GPIO 6 | |
| IMU chip select | GPIO 8 | |
| IMU interrupt INT1 | GPIO 4 | Configured as motion wakeup source |
| IR worn emitter | GPIO 1 | IR LED drive; HIGH = on |
| IR worn receiver | GPIO 0 | ADC; phototransistor reflection level |
| Vibration motor | GPIO 10 | HIGH = on |
| LED status ring | GPIO 10 | Addressable LED data line. See note below. |
| Battery voltage sense | GPIO 3 | ADC, Vbat/2 via a 1/2 divider. Functional — see note below. |
Battery sense note: GPIO 3 carries Vbat/2 through a resistor divider, read with ADC_11db attenuation. read_battery_mv() (main.cpp) returns battery millivolts by doubling the raw reading before scaling; voltage_to_percentage() maps that to the 0–100 value in Watch Status (§5.6). Earlier revisions of this spec described the sense as non-functional/reserved — that is stale; the ADC works and is load-bearing for the anchor WiFi repair gate (§5.5.3). A matching stale comment remains in main.cpp next to the battery_pct packing and should be corrected when that code is next touched.
IMU: LIS3DHTR, connected via SPI. Worn sensor: ITR8307/S17/TR8 (C81632) reflective opto-interrupter — a GaAs IR LED emitter (GPIO 1) paired with an NPN phototransistor receiver read on the ADC (GPIO 0). Skin held against the sensor reflects the emitted IR back into the phototransistor. LED status ring: 12× SK6805-EC15 addressable LEDs in a ring, WS2812-compatible (800 kHz, GRB byte order), driven on a single data line via FastLED. Used purely as a status indicator (Section 5.7); it never affects enforcement logic.
LED data-pin note (load-bearing — read before wiring the firmware): On every ESP32-C3 watch that exists the ring shares GPIO 10 with the vibration motor, which is disabled by a PCB switch — so on these boards the motor is non-functional and GPIO 10 carries LED data. This is still true of every unit in hand, and it is the reason launch-plan.md §10 forbids showing vibration and the ring on one unit. The data pin is a single named constant LED_RING_DATA_PIN (Section 7) so that the pin is the only thing a revision changes. The LED feature must not modify, gate, or otherwise interfere with the vibration motor driver code on GPIO 10 — on hardware where the motor is active the LED ring is simply not wired to that pin.
The fix is not another ESP32 revision. An earlier plan in this spec moved the ring to GPIO 7 on a "next revision"; that board was never built, and the pin share is instead designed out on the nRF board in §1.3, where the two are on genuinely separate pins.
1.2 Anchor (ESP32-C3-WROOM)¶
| Function | Pin | Notes |
|---|---|---|
| Active buzzer | GPIO 12 | HIGH = on |
| SG90 servo signal | GPIO 10 | PWM; 180° = closed/locked, 160° = open |
The anchor uses the ESP32-C3's built-in WiFi and BLE radios.
1.3 Board revision V1 — nRF54LM20A 📋 — see firmware_spec_v3_nrf.md¶
Status: ordered 2026-08-07, not received, no firmware.
The ESP32-C3-WROOM is replaced by two Fanstel modules — BM20C (nRF54LM20A: Bluetooth radio and host MCU) and WM02E/WM02C (WiFi over QSPI). Two layers became four, the LED ring moved onto the back of the board, and a user-accessible wake button was added. The watch and the anchor are the same board, differing only in case and whether a servo is soldered to the U1 pads, signal on P1.02 (the 3-pin footprint is never populated — v3 §2).
V1 is a bring-up prototype, not the shipping revision. The 2026-08-08 log called it "the final PCB prototype"; the author corrected that the same day — "I meant 'final' as in this board will have 100% functionality for me personally… not final as in this is shipping ready." Do not treat V1 as the board to certify (launch-plan.md §12.6).
The pin map and everything firmware-facing now live in
firmware_spec_v3_nrf.md— §2 for the pin map, §1 for the platform, §4 for what replaces the proximity engine. It was moved rather than copied: two pin tables for one board is exactly the drift this workspace keeps paying for. This document remains the complete reference for the ESP32-C3 firmware, which is still the only firmware that exists.
2. System Overview¶
The system consists of two device types:
Anchors are fixed devices placed at meaningful locations around a home. They advertise their identity over BLE and receive commands over WiFi. They hold a copy of the daily schedule and beep when instructed. Each anchor also runs a continuous proximity engine: it scans the surrounding RF environment (BLE devices and WiFi APs) at all times, maintains a statistical fingerprint of its installation location, and computes proximity scores on demand when the watch submits its own RF scan vector over a GATT connection.
Watches are wearable devices. They enforce a user-defined schedule of location and WiFi presence requirements by vibrating and/or buzzing when the user is not complying. They communicate with a companion mobile app over BLE and WiFi, and with anchors over WiFi.
3. Shared Data Structures¶
These data structures are used by both the watch firmware and the anchor firmware. The encoding used when transmitting over the wire is defined in Section 6.
3.1 Enumerations¶
enum RecurrenceType {
once = 0,
daily = 1,
weekly = 2,
monthly = 3,
}
// Criteria defines what physical condition the watch enforces.
enum Criteria {
getAway = 0, // User must be away from anchorId
stayNear = 1, // User must be near anchorId
getOffWifi = 2, // User must disconnect from wifiSSID
getOnWifi = 3, // User must connect to wifiSSID
phoneAway = 4, // Mode B: the user's phone is docked at anchorId; the user
// must stay away from it (phone-distance commitment).
}
// EnforcementProfile defines the watch's behavior during enforcement.
// All profiles are described fully in Section 5.4.
enum EnforcementProfile {
strictSilent = 0,
normalSilent = 1,
looseSilent = 2,
strictBoth = 3,
normalBoth = 4,
looseBoth = 5,
strictBuzz = 6,
normalBuzz = 7,
looseBuzz = 8,
}
// AnchorEnforcementProfile defines the anchor beeping pattern when the watch
// is removed during an enforcement event.
enum AnchorEnforcementProfile {
light = 0, // beep 3s, wait 60s, repeat
medium = 1, // beep 3s, wait 30s, repeat
hard = 2, // beep 4s, wait 10s, repeat
}
3.2 Event Struct¶
struct Event {
String id; // UUID v4, 16 bytes
int64 referenceDate; // Unix timestamp (seconds). For 'once': the specific
// date. For recurring: the origin/anchor date from
// which recurrence is calculated.
uint16 startTime; // Minutes since midnight (0–1439)
uint16 endTime; // Minutes since midnight (0–1439). Must be > startTime.
RecurrenceType recurrenceType;
uint8 dayOfWeek; // 1–7 (Mon–Sun). Required if weekly; 0 otherwise.
uint8 dayOfMonth; // 1–31. Required if monthly; 0 otherwise.
Criteria criteria;
EnforcementProfile profile;
bool negate; // If true, this event cancels a recurring event for
// one specific day. See Section 5.3.
uint16 donningGraceS; // Seconds of watch-side enforcement suppression after the
// watch is put on during this event's window (0 = none).
// See Section 5.4.4.
String? anchorId; // UUID of target anchor. Null if WiFi-based criteria.
String? wifiSSID; // Target WiFi network name. Null if anchor-based criteria.
// Anchor beep-on-removal fields:
List<String> beepAnchors; // UUIDs of anchors that should beep if the watch is
// removed during this event. May be empty.
AnchorEnforcementProfile? anchorProfile; // Null if beepAnchors is empty.
}
Validation rules (enforced at the app level before transmission; the firmware may assert but not correct):
- If recurrenceType == weekly, dayOfWeek must be 1–7.
- If recurrenceType == monthly, dayOfMonth must be 1–31.
- If recurrenceType == once or daily, dayOfWeek and dayOfMonth must be 0.
- Exactly one of anchorId or wifiSSID must be non-null, except when negate == true, in which case both may be null.
- If beepAnchors is non-empty, anchorProfile must be non-null.
- endTime > startTime (events may not span midnight).
- donningGraceS must be 0–1800 (app-enforced clamp; the firmware does not correct out-of-range values).
3.3 Anchor Device Record¶
Used by the watch to track known anchors:
Record lifecycle: An AnchorRecord is created whenever the watch first needs to track an anchor — on first advertisement discovery, or when a loaded schedule references the anchor's UUID (as anchorId or in beepAnchors). The app's IP-table push only fills in the optional ipAddress field of an existing record; it must never be the sole means by which a record comes to exist. bleMac (from BLE advertisement) is the load-bearing field for proximity and beep delivery; ipAddress is a best-effort optimization with mDNS/broadcast fallbacks (§5.5.1) and may remain null.
struct AnchorRecord {
String uuid; // UUID v4
String name; // Human-readable name, assigned in mobile app
uint8 bleMac[6]; // BLE MAC address of this anchor (captured from advertisement).
// Used by the watch to initiate a directed GATT connection without
// a full BLE scan. Populated on first advertisement seen.
int8 lastRSSI; // Last observed advertisement RSSI in dBm. Retained for discovery
// filtering but no longer used for near/away classification.
int64 lastSeen; // Unix timestamp of last BLE advertisement seen
String? ipAddress; // Last known IPv4 address, null if never seen on WiFi
int64 ipLastUpdated; // Unix timestamp when ipAddress was last confirmed by app
uint8 lastProxScore; // Last proximity score returned by this anchor (0–255). Updated
// each time the watch completes a proximity query. Not persisted.
}
3.4 Shared Proximity Structures (proximity.cpp)¶
These structures are used by both the watch (to build and transmit scan vectors) and the anchor (to receive, compute, and store). They are defined in proximity.h.
// One entry in a scan vector. Represents a single observed RF emitter.
struct ProxDevice {
uint8 mac[6]; // BLE MAC address or WiFi BSSID (6 bytes in both cases)
uint8 type; // 0 = BLE device, 1 = WiFi AP
int8 rssi; // Signal strength in dBm (signed)
}
// A complete RF environment snapshot. Assembled by the watch before each proximity query.
struct ProxScanVector {
ProxDevice devices[PROX_MAX_DEVICES];
uint8 count; // Number of valid entries (0 to PROX_MAX_DEVICES)
}
// Result returned by the anchor after scoring a watch's scan vector.
struct ProxScoreResult {
uint8 score; // Proximity likelihood: 0 = definitely away, 255 = definitely here.
// Computed as a blend of live correlation and fingerprint likelihood,
// scaled to uint8. See Section 4.10.
uint8 flags;
// Flag bits:
// bit 0 (PROX_FLAG_FINGERPRINT_ACTIVE): fingerprint has >= PROX_MIN_DEVICE_COUNT
// devices with sufficient samples to meaningfully contribute to the score.
// bit 1 (PROX_FLAG_LOW_DEVICE_COUNT): fewer than PROX_MIN_DEVICE_COUNT devices
// were seen in the watch's vector; score is degraded and unreliable.
// bit 2 (PROX_FLAG_SAMPLE_ACCEPTED): the vector just scored passed the
// self-supervised training gate (§4.10.4) and was folded into the
// fingerprint. Set per-query; used by the calibration burst (§4.10.7)
// to count samples that actually taught the anchor.
}
4. Anchor Firmware¶
4.1 Overview¶
The anchor is a minimal device that: 1. Advertises its UUID over BLE continuously. 2. Accepts BLE connections for initial setup (WiFi credentials, identify command) and for proximity queries from the watch. 3. Connects to WiFi and listens for UDP commands from the watch. 4. Holds a daily schedule copy and beeps when appropriate. 5. Manages an SG90 servo on GPIO 10 that mechanically locks or releases the anchor's strap, preventing the anchor from being moved during an enforcement event. 6. Runs the proximity engine continuously (proximity.cpp): scans surrounding BLE devices and WiFi APs, maintains a statistical location fingerprint, and computes proximity scores on demand.
4.2 Identity & First Boot¶
On first boot, the anchor checks NVS (non-volatile storage) for an existing UUID. If none is found (factory-fresh state), it generates a random UUID v4, stores it to NVS, and uses it permanently. The UUID never changes unless a factory reset is performed.
Factory reset procedure: Hold the factory reset trigger (implementation detail: a GPIO pad or button defined in hardware config) for 5 continuous seconds. The firmware erases the NVS partition and reboots. On next boot, a new UUID is generated.
4.3 BLE Advertising¶
The anchor advertises continuously using the iBeacon format:
- UUID: The anchor's stored UUID.
- Major: 0x4A0F — fixed Impulse namespace fingerprint. The watch uses this value to filter out non-Impulse iBeacons (AirTags, Tile trackers, etc.) during BLE scanning.
- Minor: beacon-schedule slot tag (was a constant 0x0000 through v0.8). When BEACON_SCHEDULE_ENABLE = 1:
Minor = (slot_id << 12) | (cycle_seq & 0x0FFF)
where slot_id identifies the current (channel, TX power) slot per §4.12 and cycle_seq increments once per full schedule cycle. Major and Minor are transmitted big-endian on air per iBeacon convention, so slot_id occupies the high nibble of the first Minor byte — verify against the watch-side parse in the P2 round-trip test. When the schedule is disabled, Minor = 0x0000 and behaviour is exactly v0.8. Watches treat an all-zero Minor as "no schedule" and degrade gracefully to Phase-1 features only.
- Status flags: byte 25 of the manufacturer payload, appended past the 25-byte iBeacon layout (§4.11.1). Bit 0 = ANCHOR_ADV_FLAG_UNDOCKED; bits 1–7 reserved, transmitted 0. Appended and never inserted: every existing parser reads this payload at fixed offsets behind a size < 25 guard, so a longer payload is backward-compatible while renumbering anything above would silently remap Major/Minor. There were no spare bits to reuse — Major is the fixed namespace fingerprint the watch filters on, and all 16 bits of Minor are allocated. A watch on older firmware ignores the byte; a watch on newer firmware reads an older anchor's 25-byte payload as "no flags set."
- Advertising interval: BEACON_ADV_INTERVAL_MS (50 ms) while the schedule is enabled; 100 ms legacy otherwise.
- Connectable: Yes, in every slot — setup, identify, and proximity queries must work regardless of the schedule's current slot. The anchor must remain connectable at all times.
🟡 Shipped state.
BEACON_CHANNEL_CONTROL = 0, so the anchor runs the §4.12 item-3 fallback: a 2-slot power-only schedule withslot_id ∈ {0, 3}. Per-slot channel restriction was found to be available on this NimBLE host (spike S1b) but its on-air pass criterion was never checked, so it stays off. Consequence: PDR works exactly as specified; per-channel RSSI and cross-channel spread do not exist, and anyCHANNELS_VALIDbit reads 0.
The anchor also emits a scan response packet containing:
- The ANCHOR_SERVICE_UUID as a service UUID, allowing the mobile app to identify anchors by service UUID on platforms (iOS) where Apple manufacturer data is stripped from scan results.
- The anchor's 16-byte UUID as service data under ANCHOR_SERVICE_UUID, so the app can read the anchor's identity from the scan response without connecting.
4.4 Anchor GATT Service¶
The anchor exposes one custom BLE GATT service for setup operations.
Service UUID: ANCHOR_SERVICE_UUID (a fixed constant defined in the shared codebase, used by both anchor firmware and the mobile app).
Characteristic: Identify (Write, No Response)¶
UUID: ANCHOR_IDENTIFY_CHAR_UUID
On any write (payload is ignored), the anchor activates its buzzer for IDENTIFY_BEEP_DURATION_MS (default: 800ms), then stops. This allows the mobile app user to identify which physical anchor corresponds to which UUID.
Characteristic: WiFi Credentials (Write, With Response)¶
UUID: ANCHOR_WIFI_CRED_CHAR_UUID
Payload (JSON, UTF-8):
Credential slots (v0.8). The anchor stores up to ANCHOR_WIFI_MAX_CRED_SLOTS (default 4) credential pairs in NVS, not one. This makes credential offers (§4.5.1, §5.5.3, MOBILE_APP_SPEC §8.14) non-destructive: handing an anchor a new network can never orphan it, because the previously working credentials survive as a fallback.
On write, the anchor:
1. Parses the JSON. Malformed or zero-length payload → respond 0x00, no state change.
2. Deduplicates by SSID. If the SSID already occupies a slot, that slot's password is updated in place and the slot is promoted to most-recently-offered. Otherwise the pair is inserted into a free slot, or — if all slots are full — evicts the least-recently-successfully-connected slot. A slot that has never connected successfully is evicted before any slot that has.
3. Persists the slot table to NVS.
4. Responds 0x01 immediately — meaning accepted and will be attempted, not connected.
5. Asynchronously begins a connection attempt to the offered network (see §4.5), unless the anchor is already connected to that same SSID, in which case it stays put and only refreshes the stored password.
Response semantics changed in v0.8 (lockstep). The write no longer blocks on the WiFi connection attempt — a connect can take 10–15 s, which is longer than typical BLE write-response timeouts, and the old blocking behavior made the write unreliable in exactly the recovery scenarios it exists for. The connection outcome is delivered via the WiFi Status characteristic (Read + Notify, below), which the writer should subscribe to before writing. 0x01 now means accepted; it does not mean connected.
Roaming behavior. Because slots are tried in order (§4.5), an anchor that is currently connected will not drop a working connection to try a newly offered network. The new credentials sit in a slot and are used on the next reconnect or boot. This is what makes it safe for the app to offer credentials to healthy anchors (MOBILE_APP_SPEC §8.14).
Characteristic: WiFi Status (Read + Notify) (new in v0.8)¶
UUID: ANCHOR_WIFI_STATUS_CHAR_UUID (4A0F000E-F8CE-11EE-8001-020304050607)
Lets a connected client ask the anchor about its own network state over BLE — the channel that always works, since the anchor advertises continuously and remains connectable regardless of WiFi state (§4.3). Without this, neither the app nor the watch can distinguish "anchor is fine" from "anchor is stranded," and the only available action is a blind, potentially destructive credential overwrite.
Payload (variable length, little-endian):
[1 byte: state (see table)]
[1 byte: ssid_len]
[N bytes: ssid (UTF-8, the SSID of the slot currently in use or last attempted;
empty when state = 0)]
[4 bytes: ipv4 (network byte order; 0 when not connected)]
[1 byte: rssi (AP link RSSI encoded as rssi + 128; 0 when not connected)]
[1 byte: slots_used (0..ANCHOR_WIFI_MAX_CRED_SLOTS)]
[4 bytes: schedule_crc (uint32, CRC32 of the schedule blob currently in NVS; 0 if none.
Same value as GET /schedule, §6.2 — lets the app verify sync over BLE when the
LAN path is unavailable. MOBILE_APP_SPEC §8.16.)]
state |
Meaning |
|---|---|
0x00 |
Never provisioned — no credential slots occupied. |
0x01 |
Connecting / retrying (§4.5 retry loop is active). |
0x02 |
Connected. ipv4 and rssi are valid. |
0x03 |
Auth failure — AP found, credentials rejected. |
0x04 |
AP not found — the stored SSID(s) were not seen in a scan. |
The anchor notifies on every state transition and whenever ipv4 changes (e.g. a DHCP lease change). A client that subscribes before writing credentials will therefore observe the outcome of its own offer.
Why ipv4 is exposed here: it gives the app the anchor's address over BLE, providing a fallback path for the HTTP schedule push (§4.7) when mDNS is unavailable — which is common on guest and enterprise networks, and whenever iOS Local Network permission has been denied.
Characteristic: Anchor Settings (Write, With Response)¶
UUID: ANCHOR_SETTINGS_CHAR_UUID
Payload (binary, little-endian):
[2 bytes: max_beep_minutes (uint16)]
[2 bytes: tz_offset_minutes (int16, signed, minutes east of UTC)]
max_beep_minutes is the maximum number of minutes the anchor will beep continuously for a single watch-removed event, regardless of event end time. Default: 30. This is a device-level setting, not per-event.
tz_offset_minutes gives the anchor the timezone it needs to compute local midnight and day-of-week/day-of-month for its local schedule recalculation (§4.7). Wall-clock time itself comes from SNTP once WiFi-connected.
On write: stores settings to NVS, responds with 0x01.
Lockstep note: the payload grew from 2 to 4 bytes in spec v0.5 — update the app in the same batch as the schedule version byte.
Characteristic: Schedule Transfer Control (Write, With Response)¶
UUID: ANCHOR_SCHED_CTRL_CHAR_UUID
See Section 6.2 for the full schedule transfer protocol. The anchor uses the same protocol as the watch.
Characteristic: Schedule Transfer Data (Write, No Response)¶
UUID: ANCHOR_SCHED_DATA_CHAR_UUID
See Section 6.2.
Characteristic: Proximity Vector (Write With Response) (proximity.cpp)¶
UUID: ANCHOR_PROX_VECTOR_CHAR_UUID
The watch writes its current RF scan vector to this characteristic. The anchor synchronously computes a proximity score and stores it in the Proximity Score characteristic. See Section 6.3 for the wire format of the vector payload.
On write:
1. Deserialise the ProxScanVector from the payload (Section 6.3.1).
2. Call prox_compute_score(watch_vec) to get a ProxScoreResult (Section 4.10.3).
3. Store the result in ANCHOR_PROX_SCORE_CHAR_UUID so the watch can read it immediately.
4. Call prox_maybe_update_fingerprint(watch_vec, result) to conditionally accept the sample into the self-supervised training pipeline (Section 4.10.4).
5. Respond 0x01 on success. Respond 0x00 if the payload is malformed or zero-length.
MTU negotiation to BLE_REQUESTED_MTU (512 bytes) must be completed before this write. If the negotiated MTU is below PROX_MIN_MTU_BYTES, the watch truncates the vector to fit; the anchor accepts whatever it receives.
Characteristic: Proximity Score (Read + Notify) (proximity.cpp)¶
UUID: ANCHOR_PROX_SCORE_CHAR_UUID
The watch reads this characteristic immediately after a successful write to ANCHOR_PROX_VECTOR_CHAR_UUID. The anchor also sends a BLE notification whenever the stored value changes, allowing a connected mobile app to observe proximity scores in real time (useful for debugging and fingerprinting progress feedback).
Payload (3 bytes) — ✅ as shipped:
[1 byte: score (0–255; see ProxScoreResult.score)]
[1 byte: flags (see ProxScoreResult.flags)]
[1 byte: near_thr (this anchor's calibration-v2 demonstrated cutoff, 0–255)]
near_thr is what makes the score interpretable: the score is a similarity metric, not a posterior, so the watch's HMM emission is a bounded linear discriminant about this anchor's own calibrated cutoff rather than a fixed 128. A consumer that ignores byte 3 and compares against a constant threshold is wrong for every anchor whose calibration didn't land near the middle.
📋 Owed reconciliation. The v0.9 amendment (Part 13) specified a 3-byte payload
[score][flags][neff], unaware that calibration-v2 had already claimed byte 3 fornear_thr. Both fields are wanted. When the P2 lockstep batch lands, this characteristic becomes 4 bytes[score][flags][neff][near_thr]— grow it once, in lockstep with the app's debug/score parser, and do not add a fifth byte in the same batch. Phase 5 deliberately adds no byte here (it uses a flag bit instead) to avoid compounding this.
The characteristic is initialised to {score: 0, flags: 0, near_thr: <calibrated>} on boot and is updated synchronously before the ATT Write Response for ANCHOR_PROX_VECTOR_CHAR_UUID is sent, so a Read immediately following that Write is guaranteed to see the new value.
Characteristic: Fingerprint Transfer Control (Write With Response) (proximity.cpp)¶
UUID: ANCHOR_FINGERPRINT_CTRL_CHAR_UUID
Used by the mobile app to upload a complete pre-built fingerprint blob to the anchor. Uses the same three-phase BEGIN / DATA / END protocol as the schedule transfer (Section 6.2), with the blob format defined in Section 6.3.2.
On receiving END with a matching CRC: deserialise the fingerprint blob, replace the anchor's in-memory fingerprint and device registry, persist to NVS, respond 0x01.
On CRC mismatch: discard buffer, respond 0x00. App must retry from BEGIN.
Characteristic: Fingerprint Transfer Data (Write, No Response) (proximity.cpp)¶
UUID: ANCHOR_FINGERPRINT_DATA_CHAR_UUID
Data chunks for the fingerprint upload. See Section 6.2 (same protocol as schedule data).
Characteristic: Toggle (Read + Write, With Response)¶
UUID: ANCHOR_TOGGLE_CHAR_UUID
Controls the SG90 servo that mechanically locks or releases the anchor's strap. See Section 4.9 for full servo management behavior.
Write payload (1 byte):
- 0x00 — Close (lock): rotate servo to SERVO_CLOSED_DEGREES (180°).
- 0x01 — Open (unlock): rotate servo to SERVO_OPEN_DEGREES (160°).
Read payload (1 byte):
- 0x00 — Servo is currently in the closed/locked position.
- 0x01 — Servo is currently in the open/unlocked position.
Response codes (returned after write):
- 0x01 — Command accepted and executed.
- 0x02 — Command rejected: an enforcement event is currently active that involves this anchor (see Section 4.9 for the exact check). Only open commands can be rejected; close commands are always accepted.
Characteristic: Dock Register (Write, With Response) (phone docking, §4.11)¶
UUID: ANCHOR_DOCK_REGISTER_CHAR_UUID (4A0F000C-F8CE-11EE-8001-020304050607)
Written by the mobile app after the phone connects to this anchor, to mark its own BLE connection as "the docking phone." The anchor records that connection's handle and thereafter monitors its link RSSI to decide whether the phone is docked (§4.11). This is required because the anchor also accepts transient connections from watches (proximity queries) and from the setup app — it must know which connection to measure.
Payload (1 byte): 0x01 — register the writing connection as the docking phone. 0x00 — unregister. Responds 0x01.
Characteristic: Dock Status (Read + Notify) (phone docking, §4.11)¶
UUID: ANCHOR_DOCK_STATUS_CHAR_UUID (4A0F000D-F8CE-11EE-8001-020304050607)
Read by the watch during a phoneAway proximity query (§5.4.1) to learn whether the phone is still docked at this anchor. The anchor notifies on change.
Payload (2 bytes):
[1 byte: docked (0x01 = phone docked, 0x00 = undocked / no registered phone)]
[1 byte: rssi (last anchor↔phone link RSSI, encoded as rssi + 128; 0 if none)]
4.11.1 Broadcast Undock & Local Dock Alarm ✅¶
The problem this solves. Dock Status above is readable only over a connection, and the watch only connects on its enforcement poll. That makes an undock observable exclusively while the watch is in radio range of the anchor — and the violation phoneAway exists to catch is "the user picked the phone up and walked off with it," which ends that range within seconds. Once out of range the watch cannot tell these apart:
| walked away, phone still docked | compliant — the commitment working |
| walked away carrying the phone | violation |
Both read as "anchor unreachable," which §5.4.1 maps to dock == -1 → treated as docked. Before this section, carrying the phone out of the room was the most compliant-looking thing a user could do on this criterion, and the anchor — which had the evidence the whole time — did nothing with it.
Two independent responses, neither depending on the other.
1. The anchor broadcasts it. While a phoneAway window naming this anchor as anchorId is open, the anchor sets ANCHOR_ADV_FLAG_UNDOCKED (§4.3) whenever the phone is not confirmed on the dock. Scoped by three conditions, all required:
- The window is open — today's schedule,
criteria == phoneAway,anchorId == this anchor, not negated, insidestartTime–endTime. Outside a window an anchor with no phone on it is just an idle anchor, and flagging it would make every anchor in the house look like an unattended phone. - The phone has been seen docked at least once in this window. Otherwise "not docked" is true from the instant the window opens, before the user has had any chance to dock — the anchor would blare from window start and the watch would latch a violation against someone who has done nothing. The violation is the transition: the phone was here, and now it is not.
- The phone is not currently docked, by the same
DOCK_UNDOCK_CONFIRM_POLLS-debounced measurement Dock Status reports.
A registered phone that drops its BLE link entirely lands in the third condition, not outside it: killing Bluetooth must not be a quiet way to leave the dock. A clean unregister (0x00) at window end does clear the flag — releasing the dock is not cheating, the watch simply treats an undocked phone as in-hand.
The flag change re-stamps the advertisement immediately rather than waiting for the next beacon-slot boundary. The stop → set → start sequence does not disturb live GATT connections (§4.12 item 1), which matters because the docking phone is holding one at that exact moment.
2. The anchor alarms on its own evidence. On the same transition it starts its alarm locally, without being told to by the watch. This is a deliberate departure from §5.5.1, where every anchor beep is a watch-issued WATCH_REMOVED over UDP:
- The watch cannot see this event once the user has walked out of BLE range with the phone, so it cannot be the one to raise it.
- The UDP path needs WiFi on both devices. That is the exact dependency that left anchor escalation silently dead on the bench (§0.2 v0.16) — the watch had no saved credentials, so every
WATCH_REMOVEDdied locally atsend_to_anchors.
The anchor is holding the evidence, so the anchor rings. It stops when the phone is back on the dock, or when the window closes, and only if the alarm it is stopping is its own dock alarm — a WATCH_REMOVED alarm for a different event is not its to silence.
A dock alarm sounds CONTINUOUSLY, ignoring the event's AnchorEnforcementProfile duty cycle. BEEP_ANCHOR_ALARM (700 ms blare / 300 ms gap, loops = true) runs unbroken from the single beep_play() at alarm start until something stops it.
The profiles exist for a different situation. WATCH_REMOVED means "the watch is off the wrist," which is very often a watch charging on a nightstand, so light / medium / hard back off to 3 s on / 60 s off, 3 s / 30 s and 4 s / 10 s — 5%, 9% and 29% duty — and let the user get on with their evening. A dock violation is not ambient: the phone was on this dock, someone lifted it mid-window, and the noise is the enforcement. A 30-second gap is long enough to pick the phone up and leave the house during one.
Still bounded by both existing guards in update_beeping(): g_max_beep_minutes (30 min default, settable) and the active event's endTime.
Missing anchorProfile is not fatal here, unlike the WATCH_REMOVED path. That path is only ever requested for an anchor the app deliberately placed in beepAnchors, so a missing profile means a malformed schedule. Here the anchor acts on its own evidence for an event that names it as anchorId, and the app's Phone-Free template has never populated anchorProfile — refusing would make the dock alarm silently dead for every commitment created by the template that exists to create them. Defaults to AP_MEDIUM, which currently selects nothing audible (the duty cycle is overridden) but is carried through so the field stays meaningful if a later revision scales dock alarms by firmness.
This does not make the anchor a root of trust. It decides only whether it makes noise, on a fact it measured itself, inside a window it independently validated against its own SNTP clock — the same shape as the window check it already applies to every
WATCH_REMOVED(§4.6). The watch reaches its own verdict from the advertised flag (§5.4.1) and owns compliance, the schedule, and the clock exactly as before.
4.5 WiFi Operation¶
After successfully connecting to WiFi, the anchor:
1. Obtains an IP address via DHCP.
2. Registers itself on the local network via mDNS as <UUID>.local for discoverability.
3. Opens a UDP socket on port ANCHOR_UDP_PORT (defined constant) and listens for commands.
If WiFi disconnects, the anchor continues BLE advertising and attempts reconnection every ANCHOR_WIFI_RETRY_INTERVAL_S seconds using stored credentials.
4.5.1 Credential Slot Cycling (v0.8)¶
With up to ANCHOR_WIFI_MAX_CRED_SLOTS credential pairs stored (§4.4), the anchor cycles through them rather than retrying one pair forever.
Ordering. Slots are tried most-recently-successfully-connected first, then most-recently-offered. A slot that has connected before is always preferred over an untested offer — an anchor that works today keeps working after receiving a speculative offer.
Attempt loop. Starting from the preferred slot, the anchor attempts connection. After ANCHOR_WIFI_SLOT_ATTEMPT_LIMIT (default 3) consecutive failures on a slot it advances to the next occupied slot. Having cycled through every occupied slot without success, it sleeps ANCHOR_WIFI_RETRY_INTERVAL_S and starts the cycle again — indefinitely. The anchor never gives up permanently; a router that comes back after an outage is picked up without user intervention.
State reporting. The loop drives the WiFi Status state byte (§4.4):
- No occupied slots → 0x00.
- Attempt in flight, or between attempts within a cycle → 0x01.
- Association + DHCP succeeded → 0x02; the slot is stamped as last-successful and setup_network_services() runs (mDNS, UDP listener, HTTP /schedule endpoint).
- A full cycle completed with at least one slot rejected on authentication → 0x03.
- A full cycle completed with no stored SSID observed in a scan → 0x04.
0x03 and 0x04 are the two states that mean "a human or a peer device needs to intervene," and are what the app (MOBILE_APP_SPEC §8.14) and the watch (§5.5.3) treat as distress.
Implementation note — distinguishing 0x03 from 0x04. The current code (try_connect_wifi, WifiCredCallback) polls WiFi.status() in a blocking loop, which cannot reliably separate auth failure from AP-not-found. Move to the event-driven path: register a WiFi.onEvent() handler and classify on ARDUINO_EVENT_WIFI_STA_DISCONNECTED by wifi_err_reason_t:
- WIFI_REASON_NO_AP_FOUND → contributes to 0x04.
- WIFI_REASON_AUTH_FAIL, WIFI_REASON_AUTH_EXPIRE, WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT, WIFI_REASON_HANDSHAKE_TIMEOUT, WIFI_REASON_MIC_FAILURE → contribute to 0x03.
- other reasons (assoc failures, beacon timeout, etc.) → transient; stay 0x01 and keep retrying.
The event-driven handler also removes the 10 s blocking while loops that currently stall the loop task and the BLE callback — required anyway for the non-blocking credential-write response (§4.4).
Fresh-offer preemption. Receiving a credential write for an SSID not currently connected restarts the attempt cycle immediately rather than waiting out the retry interval — a user standing in front of a stranded anchor should see it recover in seconds. As stated in §4.4, an anchor already in state 0x02 does not drop its working connection to do this.
4.6 UDP Command Handling¶
All UDP datagrams received on ANCHOR_UDP_PORT are parsed as follows:
Packet format (binary, little-endian):
Commands:
- 0x01 — WATCH_REMOVED: The watch has been taken off during an enforcement event.
- 0x02 — WATCH_WORN: The watch has been put back on.
On WATCH_REMOVED:
1. Look up the current event from the internal daily schedule (see Section 4.7).
2. Check whether this anchor's UUID appears in the event's beepAnchors list.
3. If yes, begin the anchor enforcement beeping pattern defined by the event's anchorProfile.
4. Continue beeping until:
- A WATCH_WORN command is received for the same event UUID, OR
- The event's endTime is reached (per the internal schedule), OR
- max_beep_minutes of continuous beeping has elapsed.
5. If this anchor's UUID is not in beepAnchors, ignore the command.
On WATCH_WORN: 1. Stop all beeping immediately.
4.7 Anchor Schedule Management¶
The anchor receives the same full schedule blob as the watch (recurring + one-time events, Section 6.2), persists it to NVS on successful receipt, and computes the current day's sorted event list locally via recalculate_day (Section 5.3) — on receipt, on boot, and at each local midnight. The anchor never depends on a nightly push to know today's schedule. It uses the day's list only to:
- Determine whether it should respond to a WATCH_REMOVED command (by checking if its UUID is in beepAnchors of the current event).
- Know when to stop beeping (event endTime).
Local-time dependency: computing "today" (day-of-week / day-of-month) and local midnight requires wall-clock time plus a timezone offset. The anchor obtains time via SNTP once WiFi-connected and receives tz_offset_minutes in its Settings write (§4.4). If the anchor has no valid time (never synced since boot), it behaves as if it has no schedule — no beep windows (fail-open).
The schedule is pushed by the mobile app over WiFi using an HTTP POST to http://<anchor-ip>/schedule. See Section 6.2 for the schedule encoding.
The app pushes the schedule to all known anchor IPs unconditionally — it does not wait for or check whether any anchor is online before sending. If an anchor is offline at push time, it simply misses the update; its persisted schedule keeps recurring events correct until the next successful push. The app pushes the schedule in the following situations: - Whenever the user modifies the schedule in the app (to all known anchors). - Whenever a new anchor IP is added to the app's anchor table. - Opportunistically when the app comes to the foreground and the last successful push to an anchor is stale (older than ~12 h). (There is deliberately no midnight push — a mobile app cannot reliably execute at midnight; the anchor's local midnight recalculation replaces it.)
If the anchor has no stored schedule at all (factory-fresh, or NVS cleared), it treats beepAnchors as empty for all events and will not beep.
4.8 Anchor Beeping Patterns¶
Beeping is driven by the anchorProfile field of the active event:
| Profile | Pattern |
|---|---|
| light | Buzzer ON 3s, OFF 60s, repeat |
| medium | Buzzer ON 3s, OFF 30s, repeat |
| hard | Buzzer ON 4s, OFF 10s, repeat |
Beeping stops under the conditions listed in Section 4.6.
4.9 Servo Management¶
The anchor uses an SG90 servo on GPIO 10 to mechanically lock or release its strap.
Default State¶
On boot, the servo is commanded to the closed position (SERVO_CLOSED_DEGREES = 180°), then detached. The internally tracked servo state is initialized to closed.
ServoManager Behavior¶
The servo must never actively hold a position. The pattern for every command is:
- Attach the servo (begin sending PWM on GPIO 10).
- Write the target angle (
SERVO_CLOSED_DEGREESorSERVO_OPEN_DEGREES). - Wait
SERVO_MOVE_DURATION_MSfor the servo to physically reach the position. - Detach the servo (stop PWM output on GPIO 10).
- Update internal servo state to
openorclosedaccordingly.
Enforcement Check¶
An enforcement event is considered active and involving this anchor if the current local time falls within any event's [startTime, endTime) window in the day's schedule, AND at least one of the following is true:
- This anchor's UUID matches event.anchorId.
- This anchor's UUID appears in event.beepAnchors.
Toggle Command Logic¶
function handle_toggle_command(value: uint8) -> uint8:
if value == 0x00: // close
servo_manager_command(SERVO_CLOSED_DEGREES)
return 0x01 // always accepted
else if value == 0x01: // open
if anchor_is_in_active_enforcement_event():
return 0x02 // rejected
servo_manager_command(SERVO_OPEN_DEGREES)
return 0x01 // accepted
Auto-Close on Enforcement Start¶
When the anchor's schedule determines that an enforcement event has begun (i.e., the current time has entered a [startTime, endTime) window for an event that involves this anchor):
- If the internal servo state is open, immediately issue a close command via servo_manager_command(SERVO_CLOSED_DEGREES).
- This auto-close bypasses the enforcement check and always executes.
The anchor evaluates enforcement boundaries by checking the schedule at the start of each event window. Because the anchor does not have an RTC-callback system as sophisticated as the watch, it checks whether any event boundary has been crossed on each schedule update push and on each Toggle write. For time-accurate auto-close, the app is responsible for ensuring the anchor receives the schedule before events begin.
4.10 Anchor Proximity Engine (proximity.cpp)¶
The proximity engine runs entirely on the anchor. Its responsibilities are: continuously scanning the surrounding RF environment; storing and incrementally updating a statistical fingerprint of the anchor's location; and computing a proximity score when the watch submits its own scan vector. All functions described in this section are implemented in proximity.cpp.
4.10.1 Device Registry¶
The anchor maintains a global device registry: an ordered list of every RF emitter (BLE or WiFi) it has ever observed, identified by {mac[6], type}. Each entry has an integer index that is stable for the lifetime of the NVS storage partition. The registry is capped at ANCHOR_PROX_MAX_FINGERPRINT_DEVICES entries. When the cap is reached, new devices are silently ignored.
The registry and all fingerprint data are persisted to NVS under a dedicated namespace (prox) and loaded on boot.
4.10.2 Continuous Background Scanning (proximity.cpp)¶
The anchor runs two background tasks that keep its live scan cache current:
BLE scan task: Repeats indefinitely. Each cycle runs a passive BLE scan for ANCHOR_PROX_BLE_SCAN_DURATION_MS milliseconds, collecting all observable advertisements (not filtered by Impulse Major — all BLE devices are included). The cache entry for each seen device is updated to its most recent RSSI. Devices not seen in the current cycle are retained in the cache but their RSSI is aged: after ANCHOR_PROX_DEVICE_STALE_MS milliseconds without an update, the entry is marked stale and excluded from score computations. The BLE scan task sleeps for ANCHOR_PROX_BLE_SCAN_INTERVAL_MS between cycles.
WiFi scan task: Repeats indefinitely. Each cycle calls esp_wifi_scan_start() in station mode (the anchor does not need to be connected) to enumerate nearby APs with their BSSIDs and RSSI values. Results update the same live scan cache. The WiFi scan task sleeps for ANCHOR_PROX_WIFI_SCAN_INTERVAL_S seconds between cycles.
Both tasks share a single live_scan_cache: map<{mac, type}, {rssi, last_updated_ms}>, protected by a mutex.
4.10.3 Score Computation (proximity.cpp)¶
prox_compute_score(watch_vec: ProxScanVector) -> ProxScoreResult
This function is called synchronously when the watch writes to ANCHOR_PROX_VECTOR_CHAR_UUID. It blends two signals:
Signal A — Live Pearson Correlation:
Constructs a shared device set: the intersection of devices present in both watch_vec and the anchor's non-stale live_scan_cache. Computes Pearson correlation over this set:
$$\rho = \frac{\sum_d (w_d - \bar{w})(a_d - \bar{a})}{\sqrt{\sum_d (w_d - \bar{w})^2} \cdot \sqrt{\sum_d (a_d - \bar{a})^2}}$$
where $w_d$ is the watch's RSSI for device $d$ and $a_d$ is the anchor's cached RSSI for device $d$. $\rho \in [-1, 1]$; clamp to $[0, 1]$ (negative correlation is treated as zero proximity).
Signal B — Fingerprint Log-Likelihood:
If the fingerprint has at least PROX_MIN_DEVICE_COUNT devices with W >= PROX_MIN_FINGERPRINT_WEIGHT, compute the Gaussian Naive Bayes log-likelihood of watch_vec against the stored fingerprint. For each device $d$ in the fingerprint:
$$\log P_d = -\frac{1}{2}\log(2\pi\sigma_d^2) - \frac{(w_d - \mu_d)^2}{2\sigma_d^2}$$
where $w_d$ is taken from watch_vec if present, or the sentinel value PROX_MISSING_RSSI_DBM if absent. Normalise the total log-likelihood to $[0, 1]$ using a sigmoid scaled to the expected range for the device count, yielding $L \in [0, 1]$.
Blending:
The blend weight $\alpha$ is a function of $W_{total}$, the total cumulative weight across all fingerprint devices:
$$\alpha(W_{total}) = e^{-W_{total} / PROX_ALPHA_W0}$$
So $\alpha = 1.0$ when no fingerprint exists (pure correlation), and decays toward $0.0$ as the fingerprint accumulates weight.
$$\text{score}_{float} = \alpha \cdot \rho + (1 - \alpha) \cdot L$$
Scale to uint8: result.score = (uint8)(score_float * 255).
Low device count fallback:
If watch_vec.count < PROX_MIN_DEVICE_COUNT, set PROX_FLAG_LOW_DEVICE_COUNT in result.flags and fall back to a single raw RSSI comparison: if the watch's vector contains an entry for this anchor's own BLE MAC with RSSI >= ANCHOR_NEAR_RSSI_THRESHOLD_DBM, set result.score = 200; otherwise abstain — return PROX_STARVED_SCORE (128) with neff = 0.
✅ R1, shipped (Phase 5 pull-forward). The
elsebranch used to return 50, a confident AWAY manufactured from an absence of measurement. That is a live exploit: anything that starves the vector — a body, a duvet, walking out of range of the neighbours' APs — reads as "definitely far", which is precisely what agetAwaycriterion rewards. An absent measurement is not evidence of distance, so the starved branch now returns the criterion-neutral 128. The score-200 own-anchor branch is retained: it is asymmetric on purpose (§13.4-R1 of the engine spec), because a strong own-anchor RSSI is evidence attenuation cannot fabricate.
Set PROX_FLAG_FINGERPRINT_ACTIVE in result.flags if Signal B was used.
📋 v2 scoring (prox_compute_score2) — planned, not built. When the vector carries the v2 trailer (§6.3.1), the v1 blended score is converted to log-odds and summed with per-feature log-likelihood ratios before the final logistic → uint8:
- $\ell_{\text{pdr}}$ from the trailer's LO-slot hit counts via Beta-binomial tables (
PDR_NEAR_ALPHA_BETA/PDR_AWAY_ALPHA_BETA, replaced by trained counts once available); - $\ell_{s_{ch}}$ from the cross-channel spread against trained near/away Gaussians;
- $\ell_{ch}$ from per-channel RSSI against the per-peer-anchor side table (§4.10.8).
Each term abstains (adds 0) when its feature is marked unknown (0xFF / zero slots) or its distributions are untrained (W < PROX_MIN_FINGERPRINT_WEIGHT). Result: ProxScoreResult2 {score, flags, neff} where neff = min(trailer.neff, internal cap); PROX_FLAG_V2_TRAILER is set when trailer features contributed. The v1 prox_compute_score() entry point remains as an internal wrapper (drops neff) for host-side tests; after the P2 lockstep flash the characteristic accepts only format 0x02 — any other leading byte gets response 0x00 via the existing malformed-payload path (§6.3.1). There is no mixed-version parsing.
Note what already works without any of this: stepped-power PDR is computed watch-side, from slots the watch observed itself, and folded into the HMM locally (§5.4.1 step 8). It never needed to travel in the vector, which is why the single highest-value v2 feature shipped without the wire batch.
4.10.4 Self-Supervised Fingerprint Training (proximity.cpp)¶
prox_maybe_update_fingerprint(watch_vec: ProxScanVector, result: ProxScoreResult) -> bool
Returns true if the sample passed the gate below and was folded into the fingerprint. The caller (anchor_prox_score_and_train) sets PROX_FLAG_SAMPLE_ACCEPTED in the score result's flags on true, so the querying watch learns per-query whether its sample taught the anchor (used by the calibration burst, §4.10.7).
Called after every score computation. Accepts the sample into the fingerprint's weighted Welford accumulator if and only if the following gating conditions are all met:
result.score / 255.0 >= PROX_COLLECT_SCORE_THRESHOLD— the blended score is high enough to imply the watch is near this anchor.- The sample is unambiguous: no other anchor in the watch's vector has a raw RSSI within
PROX_COLLECT_AMBIGUITY_MARGIN_DBMof this anchor's own advertisement RSSI as seen in the watch vector. This check uses the raw RSSI of known anchor MACs extracted fromwatch_vecto detect boundary situations.
If both conditions are met, update the fingerprint for every device $d$ in watch_vec using the weighted Welford algorithm, with sample weight $w_n = \text{result.score} / 255.0$:
$$\mu_n = \mu_{n-1} + \frac{w_n}{W_n}(x_n - \mu_{n-1})$$ $$M_n = M_{n-1} + w_n(x_n - \mu_{n-1})(x_n - \mu_n)$$ $$\sigma^2_n = \frac{M_n}{W_{n-1}} \quad \text{for } W_{n-1} > 0$$ $$W_n = W_{n-1} + w_n$$
where $x_n = w_d$ (the watch's RSSI for device $d$). For devices in the registry but absent from watch_vec, substitute PROX_MISSING_RSSI_DBM as $x_n$ with a reduced weight of $w_n / 2$ — this teaches the fingerprint what "not visible" looks like at this location.
New devices seen in watch_vec that are not yet in the device registry are added to the registry (up to the cap) before updating.
Persist updated fingerprint to NVS every PROX_NVS_PERSIST_INTERVAL_S seconds (not on every sample, to limit flash wear).
📋 Motion qualification (v2) — gated on the vector trailer, so not built. A sample additionally requires trailer.motion_state ∈ {FIDGET, LOCOMOTION}; its Welford weight becomes $w_n = (\text{score}/255)\cdot\min(1, \text{trailer.neff}/\text{NEFF_TRAIN_MIN})$. Vectors reporting motion_state = UNKNOWN train only while the fingerprint is younger than PROX_TRAIN_BOOTSTRAP_W total weight (bootstrap allowance); calibration-phase vectors always qualify regardless of motion state.
Rationale, which matters more than the formula: a stationary watch parked in a fade must not pump frozen identical samples into the fingerprint and collapse $\sigma^2$ around a fading artifact. The fingerprint would then be most confident about the reading it has the least independent evidence for. This is the §3 frozen-fade failure arriving through the training path instead of the inference path.
4.10.5 App-Provided Fingerprint Upload (proximity.cpp)¶
When the mobile app transmits a fingerprint blob via ANCHOR_FINGERPRINT_CTRL_CHAR_UUID / ANCHOR_FINGERPRINT_DATA_CHAR_UUID, the anchor calls:
prox_load_fingerprint(blob: FingerprintBlob)
This completely replaces the in-memory fingerprint and device registry with the contents of the blob (Section 6.3.2), then persists to NVS. The app-provided fingerprint takes precedence over all self-supervised data accumulated prior to the upload. Self-supervised accumulation resumes immediately after the upload completes, building on top of the app-provided baseline.
4.10.6 BLE Connection Behaviour During Proximity Query¶
The anchor must remain connectable at all times (per Section 4.3). When the watch connects for a proximity query, the anchor services the GATT operations in parallel with its background scan tasks — the connection does not pause or interrupt the scan loop. MTU negotiation to BLE_REQUESTED_MTU (512 bytes) is initiated by the watch immediately after connection; the anchor accepts whatever MTU the stack negotiates.
4.10.7 App-Guided Calibration Burst¶
Motivation. The anchor's fingerprint is built exclusively by self-supervision (§4.10.4), which only advances when the watch submits a scan vector while genuinely near the anchor. In normal operation that happens rarely (enforcement polls, at most every few minutes), so a freshly-placed anchor can take days to build a useful fingerprint. Calibration accelerates this without changing the algorithm: it simply makes the watch submit vectors rapidly for a bounded window while the user walks around the anchor.
Why the watch, not the phone. The fingerprint's entire coordinate space is the raw on-air {mac[6], type} of surrounding emitters (§4.10.1). Only devices that scan with a real radio — the anchor and the watch — observe those addresses. A phone cannot author fingerprint data: iOS never exposes peer BLE MAC addresses (CoreBluetooth returns an opaque per-app NSUUID, not the hardware address) and provides no API to enumerate WiFi BSSIDs; and BLE-privacy peripherals rotate Resolvable Private Addresses on both platforms. An app-scanned fingerprint would therefore key on identifiers no anchor or watch ever sees, contributing pure noise to Signal B. Calibration accordingly runs on the watch; the phone only orchestrates and displays progress. This is the honest implementation of the app's guided walk-around calibration screen (MOBILE_APP_SPEC §8.5), which today drives a phone-side progress animation that does not actually train any anchor.
Protocol. The app, connected to both the watch and in range of the target anchor:
1. Writes a START command to WATCH_CALIB_CTRL_CHAR_UUID (§5.6) carrying the target anchor's UUID and a burst duration in seconds (clamped to [CALIB_MIN_DURATION_S, CALIB_MAX_DURATION_S]; 0 ⇒ CALIB_DEFAULT_DURATION_S).
2. The watch enters a burst session that owns its main loop for the duration: every ~CALIB_QUERY_INTERVAL_MS it runs an aligned active scan, builds its scan vector (§6.3.1), and submits it to the target anchor exactly as a normal proximity query does — driving §4.10.3 scoring and §4.10.4 training on the anchor. The watch does not sleep during the session and requires the phone link to remain up.
3. After each query the watch reads back the anchor's score result and counts samples whose PROX_FLAG_SAMPLE_ACCEPTED bit is set (i.e. that actually taught the fingerprint). It emits a progress notification on WATCH_CALIB_CTRL_CHAR_UUID (format in §5.6).
4. The session ends on duration expiry (state = done), an explicit STOP write, or phone disconnect (state = aborted) — the watch never bursts unattended.
Because §4.10.4's gate still applies, accelerated samples are only accepted when the watch is genuinely near the anchor and the situation is unambiguous; walking to a boundary simply produces un-accepted queries rather than corrupting the fingerprint. Progress shown to the user should be driven by the accepted-sample count, not elapsed time.
Companion capability (not in this revision): fingerprint backup/restore. Because a fingerprint is only ever authored in real-MAC space by an anchor, the app can also treat a fingerprint as an opaque blob it ferries — downloading it from an anchor and re-uploading it (§4.10.5) to restore after a factory reset or seed a replacement anchor in the same room. That is the sole legitimate meaning of "the app sends a fingerprint" (see §6.3.2); it requires an export path (not yet specified) and is deliberately out of scope here.
✅ Two-phase calibration (calibration-v2) — shipped, and it supersedes the amendment's leg byte.
The v0.9 amendment specified a near / away leg byte. What shipped is a fuller design: the user demonstrates the inside of the zone and then its edge, and the anchor measures where the two score distributions separate and sets its own cutoff there. There is no universal "near" threshold because there is no universal room. See CALIBRATION_V2_DESIGN.md for the full design.
- Phases:
CALIB_PHASE_NONE = 0,CALIB_PHASE_INSIDE = 1,CALIB_PHASE_EDGE = 2. The watch only ever bursts in INSIDE or EDGE; FINALIZE and ABORT are one-shot control ops (§5.6). - Deferred scoring is load-bearing. Both phases are buffered and scored after the fact, at FINALIZE, against the final fingerprint. Scoring live would be self-defeating: the INSIDE phase trains the very fingerprint it is being scored by, so $\alpha$ collapses mid-phase and the score silently changes scale. Measured on hardware — samples scored against a fingerprint of <10 devices averaged 213, against ≥20 devices averaged 158: a 55-point drift within one phase, larger than the INSIDE-vs-EDGE separation the calibration exists to measure. A percentile over a mixture of two estimators is not a percentile of anything.
- FINALIZE runs while the phone is disconnected. It opens its own link to the anchor, so it cannot run during the app session that drove the burst; the watch arms it and waits for the phone to drop (
CALIB_FINALIZE_TIMEOUT_MSsafety cap,CALIB_FINALIZE_RETRY_MSbetween attempts). This is the "Option A" concurrency constraint (§10.1), not a design preference. - It refuses rather than guessing. FINALIZE reports per-phase mean and standard deviation, the compared percentiles, Cohen's d′, the error-minimising cutoff and its misclassification rate — and rejects a calibration whose phases don't separate, attributing the failure (too few samples / distributions overlap). An anchor that admits it cannot tell is worth far more than one that invents a threshold. The result (
thr,inside_n,edge_n,conf) is surfaced in the §5.6 progress frame. - Burst timing. Each calibration query spans a full observation window, so the effective cadence is ≈
PROX_OBSERVE_WINDOW_MS+ connect overhead (~2.2 s/query).CALIB_QUERY_INTERVAL_MSwas raised 600 → 2000 and acts as a floor; the default 75 s session yields ~30 queries. App-side completion targets keyed to accepted-sample counts must assume this rate. - If a near-position phase measures TX_LO PDR < 0.8 at arm's length, the score result sets
PROX_FLAG_TXLO_MISCALand the app should offer the S1 level table. (📋 flag not yet implemented.)
4.10.8 Feature Distributions & Peer-Anchor Side Table (proximity.cpp) — 📋 planned¶
Not built. No
prox2NVS namespace and no side table exist in the code. This section is the design for the P2 lockstep batch; it depends on the v2 vector trailer (§6.3.1), which also has not shipped.
A dedicated NVS table (prox2 namespace), ≤ ANCHOR_PROX_MAX_PEER_ANCHORS (8) entries keyed by anchor BLE MAC. Per entry, for near and away classes: per-channel Welford stats $(\mu_{ch}, \sigma^2_{ch}, W_{ch})$ for ch ∈ {37, 38, 39}, Beta counts $(\alpha, \beta)$ for TX_LO PDR, and Gaussian stats for $s_{ch}$. Training sources:
- Near: vectors passing the §4.10.4 gate (motion-qualified).
- Away: vectors with $\text{score}/255 \le$
PROX_TRAIN_AWAY_THRESHOLD(0.25) and this anchor's raw RSSI in the vector ≤PROX_FAR_RSSI_THRESHOLD_DBM(or absent). Deliberately conservative — it mirrors the near gate's unambiguity requirement. - Calibration phases (§4.10.7) at weight 1.0 regardless of score, keyed by the phase.
Variance floors (PROX_MIN_VARIANCE) apply throughout. Persisted on the existing PROX_NVS_PERSIST_INTERVAL_S cadence. The main 128-entry registry and blob format (§6.3.2) are unchanged; the side table has its own compact NVS blob and is not included in the app fingerprint transfer — it is anchor-authored only, and regenerates from calibration in minutes.
4.11 Phone Docking Detection¶
Used only for phoneAway (Mode B) commitments. The anchor confirms the phone is physically docked at it, so the watch can distinguish "user is away from the docked phone" (compliant) from "the phone left the dock with the user" (a violation the watch↔anchor proximity alone cannot see).
Mechanism:
1. The mobile app connects the phone to the anchor and writes 0x01 to ANCHOR_DOCK_REGISTER_CHAR_UUID. The anchor stores that connection's handle as the docking-phone handle. The app keeps this connection alive for the whole window (the user is told to leave the app open and disable low-power mode; maintaining the link is the user's responsibility).
2. The anchor polls the RSSI of the registered connection every DOCK_RSSI_POLL_MS (default 2000 ms) via the host ble_gap_conn_rssi() call.
3. docked = (a docking-phone connection is registered and live) AND (its RSSI ≥ DOCK_RSSI_THRESHOLD_DBM, default −60 dBm). A docked phone sits on/beside the anchor, so its link RSSI is very strong; the phone moving even a few feet away, or the connection dropping (weak link / app closed / low-power), drops docked to false.
4. The anchor writes {docked, rssi} to ANCHOR_DOCK_STATUS_CHAR_UUID and notifies on change. On disconnect of the docking-phone handle, the anchor clears it and reports undocked.
Because a lost/weak link is reported as undocked (not "unknown"), the burden is on the user to keep the phone docked and the app healthy — consistent with the product's honesty framing (the anchor-docking path is the reliable one).
4.12 Beacon Schedule Task (proximity.cpp) — 🟡 shipped in 2-slot fallback¶
Why this exists. Rather than measuring amplitude and comparing it to a threshold, the anchor puts the threshold in the transmitter. Some advertising slots transmit at full power; some at a deliberately crippled BEACON_TX_LO_DBM, chosen per install so the link budget just barely closes inside the zone and fails outside it. The watch then asks one binary question per low-power slot: did anything arrive? That converts an analogue measurement into a Bernoulli trial and sidesteps RSSI reporting accuracy entirely — you do not need to know what −87 dBm means on this chip, only whether a packet made it. Measured at a real install: 97% slot-hit rate inside the zone, 0% at the edge.
A timer-driven anchor task advances a fixed slot cycle every BEACON_SLOT_MS (250 ms):
| slot | channels | TX power |
|---|---|---|
| 0/1/2 | 37 / 38 / 39 | PROX_QUERY_TX_POWER_DBM (+9) |
| 3/4/5 | 37 / 38 / 39 | BEACON_TX_LO_DBM (−6, S1a-calibrated) |
On each slot boundary the engine calls the platform seam prox_platform_set_beacon_slot(channel_map, tx_power_dbm, minor); the platform performs the advertising stop → reconfigure (channel map, TX power, Minor) → start sequence on the NimBLE host task. Requirements:
- The reconfiguration must not tear down active GATT connections or reject incoming connects (S2 acceptance test).
- TX power is set via the platform's advertising-TX-power API before restart.
- S1b fallback — this is what ships. With
BEACON_CHANNEL_CONTROL = 0the cycle collapses to 2 slots (HI/LO, all channels,slot_id ∈ {0, 3}); the watch detects this from the observed slot_id set and marks channel features unknown. PDR is unaffected — it never needed channel attribution — so this is a reduced feature set, not a broken one. - Mode C anchor pairs phase-offset their cycles by
BEACON_SCHEDULE_EPOCH_OFFSET_MS(750 ms), seeded from the anchor UUID's low bit, so TX_LO slots don't collide. - The schedule runs whenever the anchor is powered; it does not depend on WiFi state and must not perturb the §4.10.2 scan tasks (S2 measures scan-task starvation).
Two measured details that turned out to matter:
- The watch's observation window must be passive-scanned. Everything else held constant, active scanning delivered 30–95% of the advertisements that passive scanning delivered 100% of, and shifted the apparent power cliff by ~9 dB — enough to mis-set
TX_LOby three levels. The receiver misses advertisements while transmitting SCAN_REQs, and the advertiser interrupts its own schedule to answer them. - "Covered" is defined by reception, not by clock arithmetic. A cycle counts when either of its slots was heard, which alone proves the cycle happened and that the window spanned it — with no need for the watch to know the anchor's schedule phase. As the watch recedes far enough that even full-power slots are lost, the denominator shrinks and the channel abstains rather than manufacturing misses.
5. Watch Firmware¶
5.1 State Machine¶
5.1.1 Connectivity Flags¶
The watch maintains two independent boolean flags at all times:
bt_connected— true when the companion phone app has an active BLE connection.wifi_connected— true when the watch has an active WiFi connection.
These flags are updated immediately on connect/disconnect events and are independent of the activity state.
The named connectivity states from the product design map to flag combinations as follows (for documentation and app status reporting only):
| Name | bt_connected | wifi_connected |
|---|---|---|
| wild | true | false |
| away | false | true |
| disconnected | false | false |
Settings governing connectivity behavior:
| Setting | Default | Effect |
|---|---|---|
DISCONNECTED_IS_DORMANT |
true | If true, the watch enforces schedule normally when both flags are false |
AWAY_IS_DORMANT |
true | If true, the watch enforces schedule normally when only wifi is true |
When a connectivity flag becomes true and enforcement was previously suspended due to it being false, immediately run recalculate_day(today) and re-arm RTC callbacks.
5.1.2 Activity States¶
The watch has three activity states:
UNPAIRED
Factory default. The watch has never been paired with a phone.
Behavior: - Does not read from IMU. - IR worn sensor has no effect. - No schedule enforcement. - Only waits for an incoming BLE connection request.
Transitions:
- → DORMANT on first successful BLE connection. On transition: emit a short beep (UNPAIRED_BEEP_DURATION_MS) and a short vibration (UNPAIRED_VIBRATE_DURATION_MS).
This state is permanent until factory reset once any BLE connection has ever been made.
DORMANT
The watch is ready to enforce but no event is currently active. This is the nominal resting state.
Behavior:
- Periodically scans for BLE advertisements from known and unknown anchors (interval: BLE_SCAN_INTERVAL_S). An advertisement is only accepted as an Impulse anchor if it is a valid iBeacon and its Major field equals 0x4A0F (the Impulse namespace fingerprint). Advertisements failing this check are silently ignored, filtering out third-party iBeacons such as AirTags.
- On discovering an unknown anchor UUID, adds it to the seen-anchors list and notifies the app via the Seen Anchors GATT characteristic.
- Periodically scans for known WiFi networks (interval: WIFI_SCAN_INTERVAL_S). On finding a known network, attempts connection.
- Processes incoming BLE GATT writes from the app (schedule updates, settings, etc.).
- When it has completed all pending work, transitions to DORMANT_SLEEP.
- Motion interrupt re-arm delay: After processing an IMU interrupt, the firmware does not immediately re-arm the interrupt. Instead it waits MINIMUM_BLE_DELAY_DORMANT milliseconds before clearing the LIS3DH INT1 latch (which allows the next motion event to fire the ISR). While INT1 is latched HIGH no new rising edge is generated, so the interrupt is effectively muted for this period. This prevents rapid continuous movement from repeatedly resetting the DORMANT_TO_SLEEP_IDLE_MS timer and blocking the transition to DORMANT_SLEEP.
Transitions:
- → DORMANT_SLEEP when no pending callbacks within DORMANT_TO_SLEEP_IDLE_MS and IMU reports no significant motion.
- → ENFORCEMENT when an RTC callback fires for an event start time.
DORMANT_SLEEP
The watch is in ESP32 light sleep mode to conserve battery. Before entering this state, the firmware sets an RTC wakeup timer for the next event boundary (start or end time), or for midnight if no more events remain today.
Wakeup sources (any of these transitions to DORMANT): - RTC timer fires. - IMU motion interrupt fires on GPIO 4. - Incoming BLE packet received.
ENFORCEMENT
An event's time window is active and the watch is monitoring and enforcing its criteria.
Behavior:
- Polls the enforcement condition every ENFORCEMENT_POLL_INTERVAL_S seconds.
- Additionally checks the condition immediately on IMU motion interrupt (GPIO 4), as significant movement may indicate the user has complied.
- Motion interrupt re-arm delay: After processing an IMU interrupt, the firmware waits MINIMUM_BLE_DELAY_ENFORCEMENT milliseconds before re-arming the interrupt (clearing the LIS3DH INT1 latch). This limits how often motion can trigger a fresh BLE scan during normal activity, preventing battery drain from continuous movement that has no bearing on the enforcement criteria.
- If condition is NOT met: activates the event's EnforcementProfile pattern (Section 5.4).
- If condition IS met: ceases all motor/buzzer output immediately. If the user has also been still for ENFORCEMENT_IDLE_BEFORE_SLEEP_MS, enters enforcement light sleep (Section 5.4.3).
- Monitors the IR worn sensor. If the watch is determined to be not worn (Section 5.2), sends WATCH_REMOVED UDP datagrams to all anchors in beepAnchors. When worn again, sends WATCH_WORN.
Transitions:
- → DORMANT when the RTC callback fires for the event's endTime.
5.1.3 Boot-Time Recovery¶
On every boot:
1. Load stored schedule from flash.
2. Load stored settings from NVS.
3. Determine current time from RTC (if available) or request time sync from phone/NTP.
4. Call recalculate_day(today).
5. Find whether the current time falls within any event's [startTime, endTime) window.
- If yes: enter ENFORCEMENT immediately for that event.
- If no: enter DORMANT, arm RTC callback for the next upcoming event boundary.
- If no more events remain today: enter DORMANT_SLEEP until midnight.
5.1.4 Mid-Day Schedule Update¶
When the app pushes a new schedule while the watch is active:
1. Deserialize and validate the new schedule — this is the proposed schedule.
2. Run the commitment-integrity diff gate (§9.3): tightening changes and new events are accepted into the stored schedule immediately; loosening changes to settled events are quarantined into the pending queue instead of applied. (Until §9 ships, per the §9.9 phasing, this step degrades to Phase-1 active-event protection, and before that to a straight replacement.)
3. Store the resulting schedule to flash.
4. Call recalculate_day(today).
5. Cancel all existing RTC callbacks.
6. Re-arm RTC callbacks for all future event boundaries in the updated schedule. Boundaries whose time has already passed today are skipped.
7. Check whether current time falls in an active event window and enter or exit ENFORCEMENT accordingly.
5.1.5 Midnight Initialization¶
Every day at midnight (00:00:00 local time), regardless of current state:
1. Call recalculate_day(new date).
2. Cancel all existing RTC callbacks.
3. Arm RTC callbacks for every startTime and endTime boundary in today's event list.
4. Arm an RTC callback for the following midnight.
5.2 Worn Detection¶
The watch determines whether it is being worn using the ITR8307 reflective IR sensor: an IR LED emitter (GPIO 1) and a phototransistor receiver read on the ADC (GPIO 0). Skin held against the sensor reflects emitted IR back into the phototransistor, raising the measured reflection level.
Each sample pulses the emitter and subtracts an ambient baseline: the receiver ADC is read once with the emitter off (ambient IR) and once with it on (lit), and the worn decision is based on the difference lit - ambient. This rejects ambient IR (sunlight, indoor lighting) and keeps the emitter off except during the brief sample window.
Constants (all values must be defined as named constants for easy tuning)¶
IR_WORN_THRESHOLD // Minimum ambient-subtracted ADC delta (lit - ambient) that
// counts as worn. Tune empirically. Default: 300.
IR_WORN_HIGHER_MEANS_WORN // Circuit polarity. true: skin contact raises the delta
// (non-inverting readout); false: skin contact lowers it.
// Set per hardware testing. Default: true.
IR_EMIT_SETTLE_US // Emitter rise/settle time before each ADC read. Default: 500us.
IR_WORN_DEBOUNCE_SAMPLES // Number of consecutive consistent readings required to change
// worn state. Default: 5.
IR_WORN_SAMPLE_INTERVAL_MS // Time between sensor samples during debounce. Default: 1000ms.
// At 5 samples × 1000ms, this gives a 5-second debounce.
Logic¶
The worn state is a boolean, initially assumed false on boot.
function sample_reflection() -> int:
// Pulse the emitter and return the ambient-subtracted reflection level.
set_gpio(IR_EMIT, LOW)
delay_us(IR_EMIT_SETTLE_US)
ambient = adc_read(IR_REC)
set_gpio(IR_EMIT, HIGH)
delay_us(IR_EMIT_SETTLE_US)
lit = adc_read(IR_REC)
set_gpio(IR_EMIT, LOW)
return lit - ambient
function sample_worn_state() -> bool:
reflection = sample_reflection()
if IR_WORN_HIGHER_MEANS_WORN:
return reflection >= IR_WORN_THRESHOLD
else:
return reflection <= -IR_WORN_THRESHOLD
function update_worn_state():
// This function is called on a timer every IR_WORN_SAMPLE_INTERVAL_MS.
// It maintains a rolling buffer of the last IR_WORN_DEBOUNCE_SAMPLES readings.
append sample_worn_state() to rolling_buffer
if all values in rolling_buffer are the same:
new_worn = rolling_buffer[0]
if new_worn != current_worn_state:
current_worn_state = new_worn
on_worn_state_changed(new_worn)
function on_worn_state_changed(is_worn: bool):
if is_worn:
// Watch put on: send WATCH_WORN to relevant anchors if in enforcement
if activity_state == ENFORCEMENT and active_event.beepAnchors is not empty:
send_watch_worn_to_anchors(active_event)
else:
// Watch taken off: send WATCH_REMOVED to relevant anchors if in enforcement
if activity_state == ENFORCEMENT and active_event.beepAnchors is not empty:
send_watch_removed_to_anchors(active_event)
Note: The debounce applies symmetrically to both the removal and re-engagement transitions. A 5-second sustained reading change is required in both directions before the worn state changes.
See §5.4.4 for the window-start worn check (beep-anchors are notified immediately if the watch is already unworn when a window begins — transitions alone are not sufficient) and the donning-grace mechanic.
5.3 Schedule System¶
5.3.1 Storage¶
The schedule is stored in two separate structures in flash/NVS:
recurring_events: A list ofEventstructs whererecurrenceType != once.specific_events: A map from date (YYYY-MM-DD string key) to a list ofEventstructs whererecurrenceType == once.
Both are written atomically when a schedule push is received from the app.
5.3.2 recalculate_day(date) -> List\<Event>¶
This function computes the full ordered event list for a given date. It is the single source of truth for what events are active on any day.
function recalculate_day(date: Date) -> List<Event>:
// Step 1: Get recurring events that apply to this date
recurring = get_recurring_events(date)
// Step 2: Get specific (one-time) events for this date
specific = specific_events[date] ?? []
// Step 3: Merge. Specific events override recurring events with the same UUID.
merged = {} // map from UUID to Event
for event in recurring:
merged[event.id] = event
for event in specific:
merged[event.id] = event // overwrites if UUID matches
// Step 4: Remove negated events
result = [event for event in merged.values() if not event.negate]
// Step 5: Sort by startTime ascending
result.sort(by: event.startTime)
return result
5.3.3 get_recurring_events(date) -> List\<Event>¶
function get_recurring_events(date: Date) -> List<Event>:
result = []
for event in recurring_events:
if event.recurrenceType == daily:
result.append(event)
else if event.recurrenceType == weekly:
if day_of_week(date) == event.dayOfWeek:
result.append(event)
else if event.recurrenceType == monthly:
if day_of_month(date) == event.dayOfMonth:
result.append(event)
return result
5.3.4 Negate Mechanic¶
A specific (one-time) event with negate = true and a UUID matching a recurring event causes that recurring event to be deleted for that one day only. The negating event itself is also removed (Step 4 above). The net result is the recurring event simply does not appear in that day's schedule.
5.3.5 Timezone¶
The watch stores a timezone_offset_minutes value (int16, e.g. -300 for UTC-5). All startTime and endTime fields in Event structs are in local time (minutes since local midnight). All Unix timestamps in the schedule are UTC. The watch converts between UTC and local time using timezone_offset_minutes when computing RTC wakeup targets and when determining the current local time.
The timezone value is pushed by the mobile app as part of the Settings characteristic write (Section 5.6).
5.4 Enforcement¶
5.4.1 Condition Checking¶
During ENFORCEMENT state, the watch checks whether the active event's criteria is currently satisfied.
Anchor-based criteria (getAway, stayNear) — Proximity Query (proximity.cpp):**
- ✅ Observation window. Collect a
ProxScanVectorusing a passive, full-duty observation window ofPROX_OBSERVE_WINDOW_MS(1800 ms) — long enough to cover one full beacon-schedule cycle plus one slot at any phase — rather than the v0.8 700 ms reduced-duty scan. Include all observed BLE devices and WiFi APs, not just Impulse anchors. During the window the firmware concurrently samples an IMU burst (IMU_BURST_SAMPLES@IMU_BURST_HZ) and feeds it toprox_ingest_imu_burst()(§5.4.5) — the radio and the SPI accelerometer read do not contend, so the burst is free. Slot-tagged advertisements accumulate LO-slot hit/covered counts (and per-channel RSSI whenBEACON_CHANNEL_CONTROL = 1); the WiFi AP cache rules (§8.2) are unchanged.
The window must be passive — see §4.12. ENFORCEMENT_QUERY_SCAN_DURATION_MS (700) remains defined for the DORMANT-side aligned discovery scan; anchor-based enforcement queries no longer use it.
Scan-cache smoothing. One scan window is a random subset, not a census: advertisers are asynchronous with 100 ms–10 s intervals, so a short window catches a 2 s advertiser only ~35% of the time. Measured with a still watch and a still anchor, vector size swung 21–31 devices and the resulting score swung 26–159 — pure estimator noise over a frozen channel. The watch therefore keeps 40 s of per-window maxima and reports the median. This does not violate the frozen-fade rule: the fading draw is frozen when the wrist is still, but the subset each scan happens to catch is re-randomised every window regardless of motion. The cache averages only the second; N_eff accounting (§5.4.5) is untouched.
2. Look up the AnchorRecord for event.anchorId to obtain its bleMac.
3. Initiate a BLE connection to that anchor using bleMac. Negotiate MTU to BLE_REQUESTED_MTU immediately after connection.
4. Write the serialised ProxScanVector to ANCHOR_PROX_VECTOR_CHAR_UUID.
5. Read ANCHOR_PROX_SCORE_CHAR_UUID to obtain the ProxScoreResult.
6. Disconnect from the anchor.
7. Store result.score in anchor_record.lastProxScore.
8. ✅ Interpret the score through the motion-conditioned HMM. Fold the anchor's result and the watch-local evidence into prox_hmm_tick(). The HMM (engine spec §6) maintains a NEAR/AWAY posterior whose transitions are gated by the motion state — flips are effectively locked while the wrist is provably STILL and unlocked by LOCOMOTION — and returns the same three-way decision the v0.8 threshold produced, with the identical criterion-dependent fail-safe:
- NEAR / AWAY when the posterior is outside the ambiguous band.
- Otherwise → AMBIGUOUS. Apply the fail-safe rule:
- For stayNear: treat ambiguous as NEAR (avoids false-positive enforcement).
- For getAway: treat ambiguous as AWAY (avoids false-positive enforcement).
The premise, stated plainly: you cannot get farther from an anchor without walking. An RSSI excursion on a provably-still wrist is multipath, interference, or a body-shadow shift — never displacement — and the transition prior makes it structurally incapable of flipping the decision. A genuine approach necessarily involves locomotion, which simultaneously unlocks transitions and floods the integrator with spatially fresh samples.
Watch-local evidence entering the tick:
- Stepped-power PDR from the LO slots observed in step 1, as a log-LR. Abstains below 2 covered cycles.
- Connect failures via prox_note_connect_failure(), replacing the ad-hoc v0.8 rule ("failed connect + advertisement ≤ PROX_FAR_RSSI_THRESHOLD_DBM ⇒ AWAY") at its call site, so the evidence competes on the same scale as everything else instead of overriding the filter. ✅ R4: it contributes LL_CONNFAIL_AWAY_Q8 only when LOCOMOTION has been seen since the last successful connect to that anchor; otherwise exactly 0, not down-weighted. Without that witness, a stationary watch just out of comfortable range re-fails its connect every poll and marches the posterior to saturation on one observation repeated — the frozen-fade failure arriving through a different channel.
- All watch-local evidence rides the draw gate g(N_eff), summed with the anchor's verdict before the gate, never outside it.
A tick with no query still yields the held decision. On ENFORCEMENT entry for an anchor-based or phoneAway event, call prox_hmm_reset(criterion).
- For
stayNear: condition is met if watch is NEAR or AMBIGUOUS. - For
getAway: condition is met if watch is AWAY or AMBIGUOUS.
If the BLE connection to the anchor fails or times out, treat as AMBIGUOUS and apply the fail-safe rule above. Do not retry in the same poll cycle; the next scheduled poll or motion interrupt will retry.
Emission shape (a defect worth not re-introducing). The HMM emission is deliberately not
logit(score/256). That treats a live Pearson correlation as calibrated odds, claims a score of 0 is 500:1, and is nearly flat across the mid-range where most real readings live. On hardware it made the filter easy to push to NEAR and nearly impossible to bring back — a walk away produced scores of 157 and 103 that contributed literally nothing. The emission is a bounded linear discriminant about the anchor's own calibrated cutoff (near_thr, §4.4): symmetric, proportionate in the middle, capped so no single observation can claim more than it knows, with a dead zone around the cutoff so noise at the decision point cannot accumulate into certainty.
Phone-distance criterion (phoneAway) — Mode B (proximity.cpp):**
phoneAway is a phone-distance commitment: the user's phone is docked at anchorId, so proximity to that anchor is a proxy for proximity to the phone. It reuses the exact anchor proximity query above (steps 1–8), reads the anchor's Dock Status in the same connection (§4.11), and fuses the two:
- Run the proximity query to
anchorId, yielding NEAR / AWAY / AMBIGUOUS, and read the Dock Status characteristic →docked∈ {docked, undocked, unknown}. - Compute
near_phone=undockedOR (prox == NEAR). The phone is "with the user" whether the user went to the dock (NEAR) or the phone left the dock (undocked).unknowndocking (anchor didn't report, e.g. old firmware or read failure) is treated as docked — a dock read-failure must not by itself trigger enforcement (fail-open on the dock signal).undockedis true if either the connected read said so or the undock latch below is set. - Fail-open (reliability rule). Unlike
getAway/stayNear, if the watch↔anchor query itself is AMBIGUOUS (degraded/failed link) the result resolves to compliant — the product must never fire a phone-distance alarm on an uncertain connection (overview §3.2/§8). Only a confidentnear_phoneis actionable. -
Tolerance. A confident
near_phonedoes not enforce immediately — "a quick check is fine." The watch records the wall-clock time it first went NEAR (g_phone_near_since_ts, usingtime()so the grace survives enforcement light sleep). The condition is met (compliant) untilnear_phonehas held continuously forPHONE_AWAY_TOLERANCE_S(default 60 s); past that, enforcement begins. Any not-near_phonereading resets the grace timer. (A brief undock — picking the phone up to glance at it — is therefore tolerated exactly like a brief approach.) -
Undock latch + dock-watch scan (v0.17). Steps 1–4 all depend on a connected Dock Status read, which is available only while the anchor is reachable — so on its own the fusion above is blind to the exact violation the criterion exists to catch. See §4.11.1 for why, and for the anchor half.
- Dock-watch scan. While the condition is met and no donning grace is running, a
phoneAwaywindow runs a short passive scan everyDOCK_WATCH_SCAN_INTERVAL_MS(10 s) forDOCK_WATCH_SCAN_MS(600 ms), independent of the enforcement poll, looking only for the dock anchor'sANCHOR_ADV_FLAG_UNDOCKEDbit. It cannot ride the enforcement poll: that runs every 180 s while compliant and every 600 s once the HMM is confident and the wrist is STILL — andphoneAwayis in that third tier (§5.4.1 polling) — whereas the window in which the watch is still near enough to hear the anchor is however long it takes the user to pick up their phone and leave. The poll would miss it essentially always. The scan is cheap because it is nothing like a poll: no scan vector, no WiFi scan, no BLE connect, no phone-link eviction. Enforcement light sleep must not be allowed to sleep past the next dock-watch scan. - Latching. Observing the flag set latches the violation for the window; the flag is read from every received advertisement including reduced-power (LO) beacon slots, since a status bit has no RSSI semantics and dropping half the ads would double the time to notice. Latching forces an immediate condition re-check rather than waiting out the poll boundary.
- Clearing. The latch is cleared only by observing that same anchor report docked again — over the air, or by a connected read, which outranks a remembered advertisement. It is deliberately not cleared by distance or by the passage of time: walking further away, or waiting, must never end an enforcement. The only exit is putting the phone back, which requires returning to the anchor and is precisely the behaviour the commitment asks for. The latch is reset on window entry, so a phone left off the dock at the end of Monday's block is not a violation of Tuesday's.
- Scoping (security). Both directions are scoped to the active event's own
anchorId. Any device can advertise Major0x4A0F(§5.5.3 names this as the anchor-sourced-data security boundary), so a flag from any other anchor must not be able to start an enforcement — otherwise a stranger's beacon is a remote alarm trigger. Clearing is scoped identically, so a spoofed clear needs the same UUID.
The anchor treats a phoneAway window like any other event for servo purposes, and for WATCH_REMOVED beeping; the distinct interpretation lives on the watch, aside from the docking detection and the dock alarm the anchor performs (§4.11, §4.11.1).
WiFi-based criteria (getOnWifi, getOffWifi):
- Check current WiFi connection status.
- For
getOnWifi: condition is met ifwifi_connected == trueand current SSID matchesevent.wifiSSID. - For
getOffWifi: condition is met ifwifi_connected == falseor current SSID does not matchevent.wifiSSID.
Polling:
- The condition is checked on a timer every
ENFORCEMENT_POLL_INTERVAL_Sseconds (default: 30). - ✅ Third poll tier. When the condition is met and the HMM posterior is confident (outside the ambiguous band) and the motion state is STILL, the poll interval backs off to
ENFORCEMENT_POLL_INTERVAL_STILL_S(600 s). Any IA1 motion interrupt exits this tier immediately and forces a re-check, so responsiveness to actual movement is unchanged; the enforcement light-sleep duration cap (§5.4.2) uses whichever poll interval is currently in force. Not polling a STILL wrist is correct, not merely cheap — a stationary user cannot change proximity class, which is the HMM's premise; the interrupt restores full responsiveness the instant that premise breaks. - The condition is also checked immediately when the IMU motion interrupt fires on GPIO 4, as significant user movement may mean the condition has just been satisfied.
- When the condition transitions from not-met to met: all motor/buzzer output stops immediately.
- When the condition transitions from met to not-met: the event's enforcement profile begins (or resumes) from its beginning.
BLE scan strategy during enforcement:
BLE activity during ENFORCEMENT is driven by motion, not by a fixed timer, to avoid running the radio while the user is stationary.
- WiFi-based events (
getOnWifi,getOffWifi): No BLE activity is performed. The enforcement condition is determined entirely from the WiFi connection state. - Anchor-based events (
getAway,stayNear): - When the IMU motion interrupt fires, the watch immediately collects a
ProxScanVectorand performs a proximity query (steps 1–10 above) before evaluating the condition. - A fallback timer (
ENFORCEMENT_BLE_FALLBACK_SCAN_INTERVAL_S, default: 300 s) ensures a proximity query is still performed periodically even when the user is completely still. BLE_SCAN_INTERVAL_Sapplies only in DORMANT state; it is not used during ENFORCEMENT.- The BLE scan phase of the vector collection uses a bounded duration of
ENFORCEMENT_SCAN_DURATION_MSmilliseconds (default: 300 ms). DORMANT scans continue to use duration 0 (indefinite) for thorough anchor discovery. - The scan uses a reduced duty cycle: scan interval
ENFORCEMENT_SCAN_INTERVAL_MS(default: 1000 ms) with active windowENFORCEMENT_SCAN_WINDOW_MS(default: 100 ms), giving a ~10% duty cycle. DORMANT uses 200 ms / 180 ms (90%). The scanner is reconfigured to DORMANT settings on exit from ENFORCEMENT.
WiFi during enforcement:
- Anchor-based events (
getAway,stayNear): WiFi remains connected. The proximity engine requires WiFi AP RSSI values in the scan vector, so disconnecting WiFi during anchor-based enforcement is no longer done. The radio is put into modem sleep (WIFI_PS_MAX_MODEM) between active operations to reduce idle current draw. A WiFi AP scan (esp_wifi_scan_start()) is performed at the start of each proximity query cycle to refresh AP RSSI values. - WiFi-based events (
getOnWifi,getOffWifi): WiFi is kept connected since the enforcement condition depends on it, with modem sleep as above. - The
wifi_suspended_for_enforcementflag is removed from the firmware. No automatic WiFi disconnection occurs on enforcement entry.
5.4.2 Enforcement Light Sleep¶
When the enforcement condition is met and the user has been still long enough, the watch enters a light sleep to conserve battery. The watch remains in STATE_ENFORCEMENT throughout — this is not a transition to DORMANT_SLEEP.
Entry conditions (all must be true):
- condition_met == true (no buzzer/motor output needed)
- data_ready == false (no pending IMU interrupt)
- No BLE scan currently in progress
- (millis() - last_activity_ms) > ENFORCEMENT_IDLE_BEFORE_SLEEP_MS
Sleep duration: ENFORCEMENT_POLL_INTERVAL_S seconds, capped at the number of seconds remaining until the event's endTime, so the event never ends late.
Wakeup sources: identical to DORMANT_SLEEP — RTC timer, IMU motion on GPIO 4, and BLE connection if ADVERTISE_DURING_SLEEP is enabled.
On wakeup:
1. Re-arm the IMU ISR.
2. Reset last_enf_poll_ms to force an immediate condition re-check on the next loop iteration.
3. For anchor-based events: collect a ProxScanVector (BLE scan ENFORCEMENT_SCAN_DURATION_MS + WiFi AP scan) and perform a proximity query so the upcoming condition check has a current score.
4. Resume normal enforcement loop.
5.4.3 Enforcement Profiles¶
Enforcement profiles define the pattern of motor and/or buzzer output while the condition is not met. Each profile is a state machine that loops until the condition is met or the event ends.
Profiles are implemented as a data-driven table. Adding a new profile requires only adding a new entry to this table — no new control-flow code.
Profile table:
struct ProfileStep {
bool motor_on;
bool buzzer_on;
uint32 duration_ms;
}
struct ProfileDefinition {
EnforcementProfile id;
bool loops;
List<ProfileStep> steps;
uint32? floor_interval_ms; // If not null, the wait step shrinks by
// INTERVAL_DECREMENT_MS each cycle until
// it reaches floor_interval_ms.
}
const INTERVAL_DECREMENT_MS = 2000; // 2 seconds, applies to all escalating profiles
Strict Silent:
steps: [
{ motor_on: true, buzzer_on: false, duration_ms: CONTINUOUS }
]
loops: false // single continuous step
Normal Silent:
steps: [
{ motor_on: true, buzzer_on: false, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 }, // wait, decrements by 2s each cycle
]
loops: true
floor_interval_ms: 5000 // wait step floors at 5 seconds
Loose Silent:
steps: [
{ motor_on: true, buzzer_on: false, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 }, // wait, decrements by 2s each cycle
]
loops: true
floor_interval_ms: 10000 // wait step floors at 10 seconds
Strict Both:
Normal Both:
steps: [
{ motor_on: true, buzzer_on: true, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 },
]
loops: true
floor_interval_ms: 5000
Loose Both:
steps: [
{ motor_on: true, buzzer_on: true, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 },
]
loops: true
floor_interval_ms: 10000
Strict Buzz:
Normal Buzz:
steps: [
{ motor_on: false, buzzer_on: true, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 },
]
loops: true
floor_interval_ms: 5000
Loose Buzz:
steps: [
{ motor_on: false, buzzer_on: true, duration_ms: 2000 },
{ motor_on: false, buzzer_on: false, duration_ms: 30000 },
]
loops: true
floor_interval_ms: 10000
Escalating wait step logic (for all looping profiles):
Each time through the loop, the wait step's duration is reduced by INTERVAL_DECREMENT_MS. This reduction accumulates across cycles. When the wait duration would fall below floor_interval_ms, it is clamped to floor_interval_ms and stays there.
Example for Normal Silent: - Cycle 1: vibrate 2s, wait 30s - Cycle 2: vibrate 2s, wait 28s - Cycle 3: vibrate 2s, wait 26s - ... - Cycle 14: vibrate 2s, wait 5s - Cycle 15+: vibrate 2s, wait 5s (floored)
5.4.4 Donning Grace & Window-Start Worn Handling¶
Two rules (added in v0.6) that make wake-up-style events (Sunrise Lock) work. Both are generic Event mechanics, not a special mode.
Window-start worn check: on entering ENFORCEMENT for an event with non-empty beepAnchors, the watch immediately evaluates the current worn state. If the watch is not worn at window start, it sends WATCH_REMOVED to the event's beepAnchors right away — it does not wait for a worn transition. (Rationale: a watch charging on the nightstand has been unworn all night; the transition-based path in §5.2 alone would never fire, and the anchor would stay silent through the wake-up window.) The anchor needs no new behavior: it already validates the event window on receipt (§4.6).
Donning grace (donningGraceS): if an event has donningGraceS > 0, the watch suppresses its own enforcement for that many seconds after the watch is put on:
- Compute
grace_deadline = donned_at + donningGraceS, wheredonned_atis the time of the most recent not-worn → worn transition — or the window start, if the watch is already worn when the window begins (donning the watch just before the window shouldn't be punished). - While
now < grace_deadline, the enforcement condition check (§5.4.1) short-circuits to met (the same pattern as thephoneAwaytolerance): no motor/buzzer output, and the watch may enter enforcement light sleep as usual (cap the sleep at the grace deadline so expiry is not slept through). Anchor beeping is independently quieted by the WATCH_WORN transition (§5.2). - When the grace expires, force an immediate condition re-check — do not wait for the next poll boundary.
- Taking the watch off during grace cancels it (and triggers WATCH_REMOVED as normal); putting it back on starts a fresh grace period.
Worked example (Sunrise Lock: getAway from the bedroom anchor, beep on the nightstand anchor, donningGraceS = 300): at wake time the window opens with the watch on the charger → unworn at window start → the nightstand anchor sounds. The user puts the watch on → WATCH_WORN quiets the anchor and the 300 s grace begins. Still in the bedroom when the grace ends → the watch's getAway enforcement starts (vibration/buzzer per profile), and removing the watch again re-triggers the anchor. Out of the bedroom → condition met → quiet. Every parameter is user-set the night before.
Classification (§9.1): decreasing donningGraceS is a tightening; increasing it is a loosening.
5.4.5 IMU Motion Channel (proximity.cpp seams; imu.cpp glue) — ✅ shipped¶
Why this is the keystone. Averaging a frozen fade produces confidence without information (engine spec §1.2). The corollary is that the engine must know whether it is accumulating evidence or accumulating noise, and only motion can tell it. Every integrating estimator therefore tracks an effective sample count rather than a raw one:
Walking at ~1 m/s sweeps ~16 fresh fading draws per second (λ/2 ≈ 6.2 cm at 2.44 GHz). Sitting still sweeps zero, forever. A still wrist's N_eff is therefore frozen, and the engine reports low confidence — not because the readings are noisy, but because they are all the same reading.
Scope: wholly contained in ENFORCEMENT and calibration bursts. DORMANT keeps v0.7's zero-IMU-interrupt guarantee — no new interrupt source exists.
Three glue obligations on the firmware:
- On every wake from enforcement light sleep, call
prox_note_sleep_interval(slept_ms, motion_woke)wheremotion_wokeis whether IA1 was the wake cause. A motionless sleep interval is authoritative STILL evidence for its whole span. - Forward each awake IA1 firing to
prox_note_motion_interrupt(), in the existing ISR-drain path after the re-arm delay logic — do not changeMINIMUM_BLE_DELAY_ENFORCEMENTsemantics. - During each observation window, read
IMU_BURST_SAMPLESaccelerometer triples atIMU_BURST_HZover SPI and callprox_ingest_imu_burst().
The engine classifies STILL / FIDGET / LOCOMOTION / UNKNOWN and exposes prox_motion_state(). UNKNOWN is treated as LOCOMOTION — fail toward the more permissive model, never toward a frozen decision.
Classes separate cleanly on burst variance alone, measured on a real wrist (mg², S3):
IMU_STILL_VAR = 400 and IMU_LOCO_VAR = 15000 sit in the gaps. The HMM's transition probability is conditioned on the resulting state:
| Motion state | P(flip per minute) |
|---|---|
| STILL | 10⁻⁴ |
| FIDGET | 10⁻³ |
| LOCOMOTION / UNKNOWN | 0.15 |
P(flip \| STILL) is small but not zero, so sustained contradiction still wins eventually — a stuck IMU, or a watch removed and left. HMM_STILL_DRAW_PERIOD_S (43200 = 12 h) sets that timescale as a physical quantity: how long a provably-motionless wrist must sit before the engine concedes one fresh independent draw. At 12 h an all-night frozen fade cannot flip a decision, while a silently broken sensor still yields within half a day.
5.4.6 Occlusion Channel (proximity.cpp seams; watch main.cpp glue) — 📋 planned¶
Not built. No
OCC_*constants exist inproximity.h. This is the S6-gated part of Phase 5; the parts of Phase 5 that needed no S6 data (R1, R2, R4) have already shipped — see §4.10.3 and §5.4.1.
The threat. Phases 1–4 harden the engine against a noisy channel. This one is written against a channel someone is steering. Where a criterion rewards the user for appearing far — getAway, and sunrise lock in particular — 15–25 dB of body or bedding attenuation is a free, equipment-less, accidentally-reproducible AWAY spoof, and it is amplitude-indistinguishable from a 6–17× distance increase.
The governing rule is engine spec §13.0: an attenuating adversary can fabricate AWAY but never NEAR, because attenuation only ever removes signal and producing a false NEAR would require active gain. Therefore concluding far must always cost more evidence than concluding near. That asymmetry is already applied consistently in shipped code — PDR hits count +3.0 nats and misses −1.5; the self-RSSI term is worth 48 score points upward and 24 downward — and it is not tuning, it is one invariant.
The watch maintains one reference vector per enforcement window and evaluates the §13.4-R3 statistics once per observation window, immediately before the vector is written:
prox_occlusion_eval(v, &occ)returns the Q8 log-LR and fills the trailer fields (§6.3.1); the watch copies them into the vector trailer verbatim.- Reference lifecycle per engine spec §13.5 — set on confident-HMM windows with
neff >= NEFF_TRAIN_MIN; re-set after any sustained LOCOMOTION segment; expire atOCC_REF_MAX_AGE_S; discard on ENFORCEMENT entry. With no valid reference the log-LR is 0 and the engine reverts to P4 behaviour. prox_is_occluded()gates the evidence suppression of §13.4-R5 insideprox_hmm_tick(): forceneff = 0, abstain the level term, suppress connect-failure evidence, and let the transition prior continue. Suppress, never invert — inverting creates the mirror exploit.prox_occlusion_sustained_ms() >= OCC_SUSTAIN_S * 1000raisesocclusionSustainedonce per enforcement window (payload: anchor MAC, δ, MAD, censor, duration).
⚠️ Product decision required before this ships. Detection is not prevention. A user who simply stays buried produces no usable evidence, and step 3 correctly freezes the posterior — which is right for stayNear and a denial of service for getAway. The recommended resolution is that sustained occlusion counts as non-compliant for getAway-family criteria and is merely surfaced for stayNear, because the user benefits from apparent-far in one case and not the other. This is a policy and UX call — a sleeping user who rolls onto their watch is not cheating — and it is deliberately not chosen here. Record the decision in MOBILE_APP_SPEC.md alongside the occlusionSustained handler.
5.5 Anchor Communication (Watch Side)¶
5.5.1 Sending Watch-Removed / Watch-Worn¶
WATCH_REMOVED is re-asserted on every enforcement poll, not announced once (v0.16). The edge-triggered form below is still what starts the escalation, but an unworn watch in a window with beepAnchors re-sends 0x01 at each poll for as long as it stays unworn and out of donning grace. Three ways the single announcement was lost outright, all reachable and one of them observed:
- No WiFi at the instant of the edge. Anchor escalation is UDP-only, so
send_to_anchorslogsNOT SENTand gives up; an association arriving a minute later changes nothing, because the edge is gone. A watch that spends a window unworn and offline never escalates at all. (Observed on the bench 2026-08-10 — see the v0.16 change-log entry.) - A dropped datagram. This is UDP with no acknowledgement.
UDP_RETRY_COUNTcovers a busy socket, not a lost packet, andendPacket()reports success either way. - The anchor stopping on its own.
ANCHOR_MAX_BEEP_MINUTES_DEFAULTis 30, and an anchor that reboots mid-window comes back with no active alarm. Nothing ever told it to start again.
Re-assertion is idempotent by construction: the anchor answers already beeping and does nothing (§4.7 handle_udp, cmd 0x01), and every one of its own guards — schedule loaded, event known, not negated, this anchor in beepAnchors, inside the window — is re-evaluated per packet. It can therefore only ever restart an alarm that should be running. Cost is one 33-byte datagram per poll.
Because the whole path needs the association, an enforcement window whose event has beepAnchors keeps WiFi alive and reconnects if it drops. The previous rule — "WiFi is deliberately abandoned once a window opens; nothing in enforcement needs it" — was false for exactly this case, and survived only because PROX_CAPTURE_ENABLED happened to switch the reconnect on for capture builds. The shipping build was the broken one and the diagnostic build could not reproduce it.
When on_worn_state_changed fires during ENFORCEMENT for an event with non-empty beepAnchors:
function send_watch_removed_to_anchors(event: Event):
command = WATCH_REMOVED (0x01)
send_to_anchors(command, event)
function send_watch_worn_to_anchors(event: Event):
command = WATCH_WORN (0x02)
send_to_anchors(command, event)
function send_to_anchors(command: uint8, event: Event):
payload = encode_udp_command(command, watch_uuid, event.id)
for anchor_uuid in event.beepAnchors:
record = anchor_records[anchor_uuid]
success = false
// Attempt 1: direct IP (from app-provided AnchorRecord)
if record.ipAddress != null:
success = udp_send(record.ipAddress, ANCHOR_UDP_PORT, payload,
retries: UDP_RETRY_COUNT,
timeout_ms: UDP_RETRY_TIMEOUT_MS)
// Attempt 2: mDNS fallback
if not success:
ip = mdns_resolve(anchor_uuid + ".local",
timeout_ms: MDNS_RESOLVE_TIMEOUT_MS)
if ip != null:
success = udp_send(ip, ANCHOR_UDP_PORT, payload,
retries: UDP_RETRY_COUNT,
timeout_ms: UDP_RETRY_TIMEOUT_MS)
// Attempt 3: UDP broadcast
if not success:
udp_send("255.255.255.255", ANCHOR_UDP_PORT, payload, retries: 1)
// Broadcast is best-effort; no success tracking
// If still unreachable after direct IP and mDNS attempts, queue notification
if not success:
queue_unreachable_notification(anchor_uuid, record.name)
5.5.2 Unreachable Anchor Notifications¶
When an anchor cannot be reached, the watch queues a notification:
The next time bt_connected == true, the watch sends all queued notifications to the app via the Watch Status GATT characteristic (see Section 5.6).
5.5.3 Anchor WiFi Repair (v0.8)¶
Motivation. A stranded anchor is silently inert, not merely degraded: with no WiFi it never reaches SNTP, and with no valid time it behaves as though it has no schedule and will not beep at all (§4.7). For Sunrise Lock this is the worst possible failure — the alarm simply does not happen, and the user finds out by oversleeping. The phone can repair this (MOBILE_APP_SPEC §8.14), but only when the app is open and the user is nearby. The watch is better positioned for the Sunrise Lock case specifically: it spends the entire night on the charger on the nightstand, inches from the very anchors that commitment depends on.
Gate — all conditions must hold. The watch performs repair scans only when:
1. Battery > ANCHOR_REPAIR_MIN_BATTERY_MV (default 4000 mV), read via read_battery_mv() (§1). Above 4.0 V the watch is on or freshly off the charger, so the extra radio work is not competing with the day's enforcement budget. This gate is what makes the feature affordable — it is not a general-purpose background task.
2. bt_connected == false — the watch does not contend for its radio while the app is talking to it.
3. Activity state is DORMANT or DORMANT_SLEEP. Repair never runs during ENFORCEMENT; enforcement latency always wins.
Interval. ANCHOR_REPAIR_SCAN_INTERVAL_S (default 1200 s / 20 min). Overnight this yields tens of opportunities before the wake window, which is ample — the watch has all night to fix an anchor, so the interval is tuned for negligible power cost rather than speed. The repair check piggybacks on an existing DORMANT wake where possible rather than scheduling a dedicated one.
Eligibility — which anchors the watch will write credentials into. This is a security boundary, not an optimization. The watch will offer credentials only to anchors whose UUID appears in its own currently loaded schedule — as an event's anchorId, or in an event's beepAnchors list. Anchors merely seen in a BLE scan are never eligible, no matter how they advertise.
Rationale (do not relax without understanding this). Offering credentials means transmitting the user's home WiFi password into another device. Any device can advertise the Impulse iBeacon Major
0x4A0F— so "any anchor in range" would let a neighbor's anchor, or one deliberately placed outside a window by an attacker, harvest the user's WiFi password from a watch that simply walked past. Restricting to schedule-referenced UUIDs means the target is an anchor the user themselves bound a commitment to. The seen-anchors list is populated by passive scanning and is therefore not an adequate filter.
Procedure. For each eligible anchor observed in the periodic scan (§5.1.2, BLE_SCAN_INTERVAL_S):
1. Connect as a BLE central (the same role used for proximity queries, §5.4.1).
2. Read WiFi Status (ANCHOR_WIFI_STATUS_CHAR_UUID, §4.4).
3. If state is 0x02 (connected) or 0x01 (connecting), disconnect and do nothing. The watch repairs distress; it does not distribute credentials. This is deliberately narrower than the app's policy, which may offer to healthy anchors — the app acts on explicit user configuration, the watch acts autonomously.
4. If state is 0x00, 0x03, or 0x04 (distress), write each credential pair from the watch's own NVS credential list (§5.6) to ANCHOR_WIFI_CRED_CHAR_UUID, most-recently-successful first, up to ANCHOR_WIFI_MAX_CRED_SLOTS pairs. The anchor's slot deduplication (§4.4) makes repeated offers idempotent — re-offering a pair the anchor already holds updates it in place and consumes no additional slot.
5. Disconnect. Do not wait for the connection outcome; the anchor's retry loop (§4.5.1) carries it from here, and the watch has no reason to hold the link open.
Backoff. After offering to a given anchor, the watch suppresses further offers to that UUID for ANCHOR_REPAIR_BACKOFF_S (default 4 h) unless its state is observed to have changed. An anchor whose AP is genuinely gone should not be re-written every 20 minutes all night.
No reporting path (decided). The watch does not report repairs to the app; Watch Status is unchanged by this feature. The app learns that an anchor recovered by reading the anchor's own WiFi Status characteristic (MOBILE_APP_SPEC §8.14), which is authoritative anyway. This keeps the v0.8 lockstep batch to the anchor GATT only. Revisit if field experience shows users need to be told a silent repair happened.
Interaction with the app. Both healers write to the same slot table, and both are idempotent by SSID, so no coordination is required. The app is the broader mechanism (it can prompt for passwords the watch has never held); the watch is the narrow, always-present safety net for the anchors a commitment actually depends on.
5.6 BLE GATT Service (Watch ↔ Phone)¶
The watch exposes one custom GATT service.
Service UUID: WATCH_SERVICE_UUID (fixed constant, shared between firmware and app).
Characteristic: WiFi Credentials (Write, No Response)¶
UUID: WATCH_WIFI_CRED_CHAR_UUID
Payload (JSON, UTF-8):
On write:
1. Attempt WiFi connection.
2. On success: save credentials to NVS credentials list, set wifi_connected = true.
3. On failure: save credentials to NVS credentials list anyway (for future auto-connect attempts).
4. Notify app of result via Watch Status characteristic.
The watch maintains a list of saved WiFi credential pairs in NVS. It attempts to connect to any of them when WiFi scanning finds a matching network.
Characteristics: Schedule Transfer Control & Data¶
Control UUID: WATCH_SCHED_CTRL_CHAR_UUID (Write, With Response)
Data UUID: WATCH_SCHED_DATA_CHAR_UUID (Write, No Response)
See Section 6.2 for the full schedule transfer protocol.
Characteristic: Settings (Write, With Response)¶
UUID: WATCH_SETTINGS_CHAR_UUID
Payload (binary, little-endian):
[1 byte: DISCONNECTED_IS_DORMANT (0 or 1)]
[1 byte: AWAY_IS_DORMANT (0 or 1)]
[2 bytes: timezone_offset_minutes (int16, signed, e.g. -300 for UTC-5)]
[2 bytes: settle_window_min (uint16, minutes; clamped to 30–240, §9.8)]
On write: stores all settings to NVS, responds with 0x01. If a timezone change occurs, re-arm all RTC callbacks using the new offset.
Gating (§9.7/§9.8): dormancy-flag changes true→false and settle_window_min increases are loosenings — quarantined into the pending queue rather than applied; a write containing any quarantined field responds 0x03 (the other fields apply immediately). A timezone change that would place the current local time outside the currently active event's window is rejected with 0x02 (§9.7).
Lockstep note: the payload grew from 4 to 6 bytes in spec v0.5 — update the app in the same batch as the schedule version byte.
Characteristic: Seen Anchors (Read + Notify)¶
UUID: WATCH_SEEN_ANCHORS_CHAR_UUID
Payload (binary, little-endian):
[1 byte: anchor count N]
for each anchor (N entries):
[16 bytes: UUID]
[1 byte: last RSSI, encoded as (rssi + 128) to make it unsigned]
[4 bytes: last_seen Unix timestamp (uint32)]
The watch sends a BLE notification on this characteristic whenever a new anchor UUID is discovered. The app can also read this characteristic at any time to get the current full list.
Characteristic: Watch Status (Notify + Read)¶
UUID: WATCH_STATUS_CHAR_UUID
The watch pushes a notification any time any field in this payload changes.
Payload (binary, little-endian):
[1 byte: activity_state (0=dormant, 1=enforcement, 2=dormant_sleep)]
[1 byte: bt_connected (0 or 1)]
[1 byte: wifi_connected (0 or 1)]
[1 byte: worn (0 or 1)]
[1 byte: battery_pct (0–100; 0xFF if not yet available)]
[16 bytes: active_event_id (UUID of current enforcement event; zero-filled if none)]
[1 byte: condition_met (1 = enforcement condition currently met, grace active, or not
enforcing; 0 = actively alarming. Added v0.6 — lockstep.)]
[4 bytes: schedule_crc (uint32, CRC32 of the full schedule blob the watch last accepted,
identical to the END-phase CRC of §6.2; 0 if no schedule stored. Added v0.8 —
lockstep. Lets the app confirm-vs-infer sync state, MOBILE_APP_SPEC §8.16.)]
[1 byte: unreachable_anchor_count N]
for each unreachable notification (N entries):
[16 bytes: anchor UUID]
[1 byte: anchor name length L]
[L bytes: anchor name (UTF-8)]
[4 bytes: timestamp (uint32 Unix)]
After delivering queued unreachable notifications, the watch clears them from its queue.
Characteristic: Anchor IP Table (Write, With Response)¶
UUID: WATCH_ANCHOR_IP_CHAR_UUID
The app writes the current known IP addresses of all anchors to this characteristic. This is how the watch learns anchor IPs without relying solely on mDNS.
Payload (binary, little-endian):
[1 byte: entry count N]
for each entry (N entries):
[16 bytes: anchor UUID]
[4 bytes: IPv4 address (uint32, network byte order)]
[4 bytes: timestamp of IP confirmation (uint32 Unix)]
On write: updates the watch's AnchorRecord table entries. Responds with 0x01.
Characteristic: Time (Write, With Response)¶
UUID: WATCH_TIME_CHAR_UUID (4A0F0019-F8CE-11EE-8001-020304050607)
Lets the mobile app set the watch's absolute wall-clock time over BLE. This is required because the watch otherwise obtains time only from NTP, which needs an active WiFi connection; in WiFi-less setups the clock would never be set (schedule windows, phoneAway grace, and logging all depend on correct time).
Payload (binary, little-endian):
[8 bytes: utc_epoch (int64, Unix seconds, UTC)]
[2 bytes: tz_offset_minutes (int16, signed, minutes east of UTC, e.g. -300 for UTC-5)]
On write: the watch sets its system clock (settimeofday) to utc_epoch and updates timezone_offset_minutes (equivalent to the timezone field of the Settings characteristic, §5.6). It then re-arms RTC callbacks and runs recalculate_day(today) so schedule boundaries reflect the corrected time. Responds with 0x01.
The app pushes time on pairing, on every app-foreground while connected, and after any timezone change. The tz_offset_minutes here and in the Settings characteristic must be kept consistent; a write to either updates the same stored offset.
Characteristic: LED Configuration (Write With Response, Read)¶
UUID: WATCH_LED_CONFIG_CHAR_UUID
The app uses this characteristic to recolour and re-tune the LED status ring at runtime (Section 5.7). Every visible status (dormant, enforcement-idle, enforcement-alarm, and the per-wake-cause flashes) is a configurable slot; the colour and blink timing of any slot can be changed without a firmware update. The watch persists the configuration to NVS and applies it immediately.
Write payload (binary, little-endian):
[1 byte: global_brightness (0–255; passed to FastLED.setBrightness)]
[1 byte: slot count N]
for each slot (N entries):
[1 byte: slot_id (see LedStatusSlot enum, Section 5.7)]
[1 byte: R]
[1 byte: G]
[1 byte: B]
[2 bytes: blink_on_ms (uint16; 0 = steady, no blink)]
[2 bytes: blink_off_ms (uint16; ignored when blink_on_ms == 0)]
A write may carry any subset of slots (N from 0 to the number of slots); unlisted slots keep their current values. A slot listed with blink_on_ms == 0 is rendered as a steady colour. global_brightness is always applied. Unknown slot_id values are skipped.
On write: clamp/store the listed slots and brightness to NVS, re-render the ring against the current status, respond 0x01. Respond 0x00 if the payload is malformed (length shorter than 2 + N*8).
Read payload: the current full configuration, in the same layout as a write that lists every slot (i.e. N = total slot count), so the app can read back the live colours.
Characteristic: Pending Changes (Read + Notify) (commitment integrity, §9.5)¶
UUID: WATCH_PENDING_CHAR_UUID (4A0F001A-F8CE-11EE-8001-020304050607)
Exposes the watch's authoritative queue of quarantined loosening changes (§9.3–9.5), so the app can render pending state truthfully and recover it after a reinstall.
Payload (little-endian):
[1 byte: pending count N]
for each entry (N entries):
[16 bytes: event UUID (zero-filled for non-event entries)]
[1 byte: change type (0=delete, 1=loosen-modify, 2=negate-day, 3=setting change)]
[4 bytes: seconds until apply (uint32, elapsed-time basis)]
Notifies whenever the queue changes (entry added, promoted, or rejected).
Characteristic: Emergency Pass (Write With Response + Read) (commitment integrity, §9.6)¶
UUID: WATCH_PASS_CHAR_UUID (4A0F001B-F8CE-11EE-8001-020304050607)
The rolling emergency-pass ledger lives on the watch (§9.6). The app spends and inspects passes through this characteristic.
Write payload (spend):
Response:0x01 + [1 byte: passes remaining] on success; 0x02 + [0x00] if the budget is exhausted; 0x00 on malformed payload.
Write payload (set allowance):
Response:0x01 — applied immediately (allowance lowered or unchanged); 0x03 — quarantined (an allowance raise is a loosening → pending queue, §9.6).
Read payload:
[1 byte: allowance]
[1 byte: remaining]
for each spent pass still inside the rolling window:
[4 bytes: seconds until it regenerates (uint32, elapsed-time basis)]
Characteristic: Calibration Control (Write With Response + Read + Notify) (proximity.cpp, §4.10.7)¶
UUID: WATCH_CALIB_CTRL_CHAR_UUID (4A0F001C-F8CE-11EE-8001-020304050607)
Drives an app-guided calibration burst (§4.10.7). All fields little-endian.
Write payload — ✅ as shipped (calibration-v2):
STOP: [1 byte: 0x00]
START: [1 byte: 0x01][16 bytes: target anchor UUID][2 bytes: duration_s (uint16)][1 byte: phase]
phase: 1 = INSIDE, 2 = EDGE (0 = NONE is not a burst phase)
FINALIZE: [1 byte: 0x02] one-shot: compute + persist this anchor's threshold
ABORT: [1 byte: 0x03] one-shot: discard the session
duration_s is clamped to [CALIB_MIN_DURATION_S, CALIB_MAX_DURATION_S]; 0 selects CALIB_DEFAULT_DURATION_S. A START while a session is already running restarts it against the new target.
The app drives two short bursts — "walk around the anchor" (INSIDE), then "stand where you'd normally be when you're away" (EDGE) — and then FINALIZE. FINALIZE cannot run while the phone is connected: the watch must central-connect to the anchor to collect the result, which the IDF 4.4.7 concurrency limit forbids while the phone link is up (§10.1, "Option A"). The watch therefore arms FINALIZE and executes it after the phone disconnects, retrying until CALIB_FINALIZE_TIMEOUT_MS. An app that holds its link open and waits for a result will wait forever.
📋 Superseded amendment text. The v0.9 amendment specified a
near/awayleg byte here (0x01/0x02). Calibration-v2 shipped INSIDE/EDGE phases with the same byte position and added FINALIZE/ABORT. Read this block, not Part 11 of the archived amendment.
Notify / Read payload (progress) — ✅ 16 bytes as shipped:
[1 byte: state] 0 = idle, 1 = running, 2 = done (duration elapsed), 3 = aborted (STOP/ABORT or phone disconnect)
[2 bytes: accepted] samples the anchor folded into its fingerprint this session (PROX_FLAG_SAMPLE_ACCEPTED count)
[2 bytes: queries] total queries attempted this session
[1 byte: last_score] most recent proximity score (0..255)
[1 byte: last_flags] PROX_FLAG_* from the most recent query
[2 bytes: remaining_s] seconds left in the session
--- calibration-v2 FINALIZE result (valid only when have_result = 1) ---
[1 byte: fin_thr] the demonstrated cutoff the anchor computed and persisted
[2 bytes: fin_inside] accepted sample count, INSIDE phase
[2 bytes: fin_edge] accepted sample count, EDGE phase
[1 byte: fin_conf] confidence / separation quality
[1 byte: have_result] 1 once FINALIZE has returned; 0 otherwise
accepted (real learning), not elapsed time; a plausible completion target is a fixed accepted-sample count.
5.7 LED Status Indicator¶
The watch carries a ring of 12 SK6805 addressable LEDs (hardware: Section 1) used as a passive status indicator. It conveys, at a glance, what the watch is doing — whether it is asleep, resting, or actively enforcing — and why it last woke. The ring is output-only: nothing in this section may change enforcement, sleep, scanning, or radio behaviour. All colours and blink timings are runtime-configurable by the app via WATCH_LED_CONFIG_CHAR_UUID (Section 5.6) and persisted to NVS; the values below are defaults only.
The ring is driven through FastLED at LED_RING_DEFAULT_BRIGHTNESS, on LED_RING_DATA_PIN (Section 1 / Section 7). All twelve LEDs always show the same colour (the ring is a single logical indicator, not a progress dial), with the sole exception of the blink behaviour described below.
5.7.1 Status Slots¶
Each distinct thing the ring can show is a slot. A slot has an RGB colour and an optional blink (on_ms / off_ms); the app addresses slots by slot_id.
enum LedStatusSlot {
LED_SLOT_DORMANT = 0, // awake, DORMANT, nothing else to show (default: blue)
LED_SLOT_ENFORCE_IDLE = 1, // awake, ENFORCEMENT, condition currently met (default: orange)
LED_SLOT_ENFORCE_ALARM = 2, // ENFORCEMENT, condition NOT met (alarming) (default: red, blinking)
LED_SLOT_WAKE_MOTION = 3, // momentary flash: woke on IMU motion (default: green)
LED_SLOT_WAKE_TIMER = 4, // momentary flash: woke on RTC/timer (default: light blue)
LED_SLOT_WAKE_BLE = 5, // momentary flash: woke on incoming BLE (default: white)
LED_SLOT_ENFORCE_GRACE = 6, // tolerance countdown, rendered as an arc (default: yellow)
LED_SLOT_PROX_NEAR = 7, // engine verdict: NEAR (§5.7.5) (default: magenta)
LED_SLOT_PROX_AWAY = 8, // engine verdict: AWAY (§5.7.5) (default: green)
}
Slot ids are on-wire values in the app's LED Configuration characteristic (Section 5.6), so new statuses are only ever appended — renumbering silently remaps every colour a user has already configured. The read payload grows with LED_SLOT_COUNT (2 + N×8 bytes); a write may carry any subset, and unknown ids are skipped, so an older app stays compatible with a newer watch.
Default slot table (RGB before global brightness scaling):
| Slot | Default RGB | Blink (on/off ms) | Meaning |
|---|---|---|---|
LED_SLOT_DORMANT |
0, 0, 255 |
steady | Awake and dormant (the nominal resting "I'm here" colour). |
LED_SLOT_ENFORCE_IDLE |
255, 90, 0 |
steady | In an enforcement window, condition met — no output needed. |
LED_SLOT_ENFORCE_ALARM |
255, 0, 0 |
250 / 250 |
In enforcement, condition not met — actively enforcing. |
LED_SLOT_WAKE_MOTION |
0, 255, 0 |
steady* | Just woke from light sleep because of motion. |
LED_SLOT_WAKE_TIMER |
0, 180, 255 |
steady* | Just woke from light sleep on the RTC/event timer. |
LED_SLOT_WAKE_BLE |
255, 255, 255 |
steady* | Just woke from light sleep on an incoming BLE packet. |
LED_SLOT_ENFORCE_GRACE |
255, 200, 0 |
steady† | Criterion failing, a tolerance window is still suppressing the alarm. |
LED_SLOT_PROX_NEAR |
255, 0, 120 |
steady† | Engine verdict: the watch is NEAR the event's anchor (§5.7.5). |
LED_SLOT_PROX_AWAY |
0, 255, 60 |
steady† | Engine verdict: the watch is AWAY from the event's anchor (§5.7.5). |
* Wake-cause slots are shown as a momentary flash of LED_WAKE_FLASH_MS (Section 7), not continuously; see resolution rules below.
† These slots render steady; their on_ms/off_ms are accepted over the wire and ignored. ENFORCE_GRACE is drawn as a filling arc rather than a solid ring, so it carries how much time is left.
The proximity pair is magenta/green rather than red/green deliberately: red is the alarm and must not also mean "near", and a red/green pair is the one combination that fails both on camera and for the red-green colour blind.
5.7.2 Rendering Resolution¶
The ring shows exactly one slot at any instant. The watch re-evaluates which slot to display on every relevant event (sleep entry, wake, activity-state change, enforcement condition transition) using this fixed priority — highest first:
- Asleep → the ring keeps saying what state the watch is in. Neither sleep clears the ring any more; both latch a frame that survives the sleep, because the SK6805 pixels hold their last frame with no CPU involvement.
DORMANT_SLEEPleaves the analog clock (§5.7.4) lit, refreshed to the current minute right before each sleep. Enforcement light sleep (Section 5.4.2) latches the two-LED proximity pilot (§5.7.5) immediately before theesp_light_sleep_start()call. There is no separate power-enable line (GPIO 21 is the buzzer and is owned by existing firmware — the LED feature must not touch it), so "latched" is the only mechanism available and the standing cost is the lit pixels themselves: a few for the clock, two for the pilot.
This reverses the original rule, which cleared the ring for enforcement sleep. An enforcement window is mostly asleep, so a ring that went dark at every sleep was dark for most of the window — and a dark ring is indistinguishable from a watch that has stopped enforcing, which is precisely the question a user glances down to answer. Measured on the bench 2026-08-10: a getAway window polling at ENFORCEMENT_POLL_INTERVAL_MET_S showed status for a few seconds out of every three minutes.
-
Active enforcement alarm. While
activity_state == ENFORCEMENTand the enforcement condition is not met (i.e. a profile pattern from Section 5.4.3 is running), the ring showsLED_SLOT_ENFORCE_ALARM, blinking per itson_ms/off_ms. This overrides the steady state colour so the visual alarm tracks the buzzer/motor alarm. The instant the condition transitions back to met, the ring drops to priority 4. -
Wake-cause flash (momentary). On any transition out of light sleep into an awake state, the watch flashes the slot for the wake source that fired, for
LED_WAKE_FLASH_MS: - IMU motion interrupt (GPIO 4) →LED_SLOT_WAKE_MOTION(green). - RTC / event-boundary timer →LED_SLOT_WAKE_TIMER(light blue). - Incoming BLE packet/connection →LED_SLOT_WAKE_BLE(white).
The flash is non-blocking: it does not delay scanning, the proximity query, or condition evaluation — it is a colour the renderer holds until LED_WAKE_FLASH_MS has elapsed, after which the ring falls through to priority 4 (or to priority 2 if by then the watch is in an active alarm). If the watch goes back to sleep before the flash elapses, priority 1 wins and the ring goes off.
Note on wake sources by state: per Section 8.4, motion is a wake source only during ENFORCEMENT; in DORMANT_SLEEP the watch wakes on the RTC timer (and BLE, when reachable) only. The wake-cause flash applies only in ENFORCEMENT — in DORMANT the ring is owned by the always-on analog clock (§5.7.4, priority 4), so a dormant wake shows the refreshed clock rather than a flash.
- Steady activity colour. When awake with none of the above active, the ring shows the colour for the current activity state:
-
DORMANT→ the analog clock (§5.7.4). -ENFORCEMENT, condition met → the proximity verdict (§5.7.5): magenta NEAR, green AWAY,LED_SLOT_ENFORCE_IDLEorange when the engine has not decided or the verdict is stale. -UNPAIRED→ ring off (no status to show until paired). This is a rendering choice, not a separate slot.
So a typical getAway enforcement wake looks like: green flash (motion) → red-blinking with a magenta pilot in the gaps for as long as the watch can see you next to the anchor → solid green the moment it agrees you have left → two green LEDs latched through the next sleep. In DORMANT the ring simply shows the analog clock the whole time (§5.7.4) — it stays lit through sleep and ticks once per minute; there is no wake flash.
5.7.5 Proximity verdict display¶
Compliance and proximity are different facts, and until v0.16 the ring only ever showed the first. On getAway they are inverted (NEAR is the failing state), on stayNear they agree, and during donning grace (§5.4.4) compliance is asserted regardless of either. A single orange "condition met" therefore conflated "it thinks I'm away", "it hasn't decided", and "grace is holding it quiet" — three states a user has no other way to tell apart, and the reason a bench session could not answer "what does the watch currently think?" without a serial cable.
The ring now carries, for the whole of an enforcement window, the verdict enforcement actually acted on — after the §13.0 NEAR floor and after PROX_V2_AUTHORITATIVE has picked a side. Not the raw v0.8 score and not the HMM's own decision: showing either would display something the watch did not do.
- Condition met → the ring is solid in the verdict's colour.
- Alarming → the red blink is unchanged, but its dark half carries a two-LED pilot in the verdict's colour instead of going fully black. An alarm is the moment the user most needs to know why it is ringing, and "the watch can see the anchor" versus "the watch never reached it" are different problems with the same red ring.
- Asleep → the same two-LED pilot, latched (priority 1 above).
- Stale or undecided →
LED_SLOT_ENFORCE_IDLEorange. A verdict older thanENFORCEMENT_POLL_INTERVAL_MET_S + 60 sis shown as undecided rather than as fact: a window that has just opened has not polled yet, and a stale answer displayed as current is worse than admitting ignorance.
The ring remains output-only (§5.7): none of this may change enforcement, sleep, scanning, or radio behaviour.
5.7.3 Implementation Notes¶
- The renderer is a single function, e.g.
led_render(LedStatusSlot active_slot), plus a smallled_update()ticked from the main loop that (a) resolves the current slot via the priority rules, (b) advances the alarm blink phase, and (c) expires the wake-cause flash. No status logic lives in the BLE or enforcement modules — they callled_note_wake(cause)/led_note_state_change()and let the renderer decide. - Slot colours, brightness, and blink timings load from NVS on boot (falling back to the defaults above) before the first render, so the very first wake already reflects any app customisation.
- The ring must be cleared before enforcement light-sleep entry to avoid leaving an LED lit through that sleep; this is the only hard ordering requirement relative to the existing sleep code.
DORMANT_SLEEPis deliberately not cleared — it leaves the analog clock (§5.7.4) lit through sleep.
5.7.4 Analog clock display mode (always-on DORMANT face)¶
Because the 12-LED ring maps naturally onto a clock face (one LED per hour position), the watch renders the current time directly on the ring via led_show_time(hour, minute). This is the watch's default DORMANT display: the clock is always on. It is driven from the DORMANT branch of led_update() (priority 4, Section 5.7.2), which repaints it once per minute — only when the displayed minute changes, or when a higher-priority state (alarm, unpaired-off) has clobbered the ring and it must be redrawn. The clock is not shown during ENFORCEMENT (the enforce/alarm slots own the ring there) or while UNPAIRED (ring off).
Because the SK6805 pixels latch their last frame, the clock stays lit with no CPU involvement through DORMANT_SLEEP (Section 5.7.2 priority 1); the firmware refreshes it to the current minute right before each sleep and again on wake. Keeping the clock lit — rather than lighting the ring only on a wrist-raise — is what let look-to-wake (and its motion wake source) be removed entirely (§8.4).
- LED index → clock position: the ring is wired so index
0is the 12-o'clock LED's neighbour; the renderer maps a clock positionp(0 = 12 o'clock) to LED index11 - p, so positions advance clockwise around the physical ring. - Hour hand: the LED at
hour % 12lit red. - Minute hand: the LED at
minute / 5(nearest 5-minute mark) lit green. - Overlap: when the hour and minute hands fall on the same LED, that single LED is lit yellow instead of stacking colours.
- No second hand. The ring updates only on the minute; there is no per-second animation (an earlier build swept a blue second hand — removed for battery, since it forced the CPU awake every second).
- All other LEDs are off. Brightness uses the configured ring brightness. The hand colours are currently fixed in code (not yet exposed as configurable slots).
6. Inter-Device Communication Protocols¶
6.1 UDP Command Packet (Watch → Anchor)¶
All UDP messages from the watch to anchors use this fixed 33-byte format:
[1 byte: command]
0x01 = WATCH_REMOVED
0x02 = WATCH_WORN
[16 bytes: watch UUID]
[16 bytes: event UUID]
No response is expected from the anchor. The watch uses the retry logic defined in Section 5.5.1.
6.2 Schedule Transfer Protocol¶
Used for both BLE (watch) and HTTP (anchor). The schedule is encoded as a binary blob and transferred using a 3-phase protocol.
Phase 1: BEGIN¶
Write to the control characteristic / HTTP endpoint with:
Phase 2: DATA¶
Write sequential chunks to the data characteristic. Each chunk is at most BLE_MTU_BYTES - 3 bytes (to fit BLE ATT packet). For HTTP, the body is the full blob in one request.
Schedule binary encoding (little-endian):
[1 byte: format version (currently 0x02; receivers reject unknown versions)]
[2 bytes: event count N (uint16)]
for each Event (N entries):
[16 bytes: UUID (raw bytes)]
[8 bytes: referenceDate (int64 Unix timestamp)]
[2 bytes: startTime (uint16, minutes since midnight)]
[2 bytes: endTime (uint16, minutes since midnight)]
[1 byte: recurrenceType]
[1 byte: dayOfWeek (0 if unused)]
[1 byte: dayOfMonth (0 if unused)]
[1 byte: criteria]
[1 byte: enforcementProfile]
[1 byte: anchorProfile (0xFF if null)]
[1 byte: negate (0 or 1)]
[2 bytes: donning_grace_s (uint16 seconds; 0 = none. See §5.4.4. Added v0.6, same lockstep batch)]
[1 byte: anchorId presence flag (0=absent, 1=present)]
[16 bytes: anchorId UUID (zero-filled if absent)]
[1 byte: wifiSSID length L (0 if absent)]
[L bytes: wifiSSID (UTF-8, omitted if L=0)]
[1 byte: beepAnchors count M]
for each beepAnchor (M entries):
[16 bytes: anchor UUID]
Phase 3: END¶
Write to the control characteristic with:
On receiving END:
- Compute CRC32 of the received buffer.
- If mismatch: discard buffer, respond 0x00. App must retry from BEGIN.
- If matches: check the leading format version byte (unknown version → discard, respond 0x00), then:
- Anchor: deserialize, persist per §4.7, respond 0x01.
- Watch: run the §9.3 commitment-integrity diff gate and respond:
- 0x01 — accepted in full.
- 0x03 — accepted, but one or more loosening changes were quarantined into the pending queue (app should read the Pending Changes characteristic, §9.5).
- 0x04 — rejected in full: the push would loosen the currently active event (§9.3 Phase-1 rule; under the full diff gate such changes are quarantined and reported via 0x03 instead).
Version-byte note (lockstep): the leading version byte was added in spec v0.5. A v1 parser would misread it as the low byte of the event count, so the app's
ScheduleEncoder, the watch, and the anchor must all update together — coordinate the flash.
ABORT command:
Discards any in-progress transfer immediately.HTTP endpoint for anchors¶
The app pushes the schedule to each anchor at:
POST http://<anchor-ip>/schedule
Content-Type: application/octet-stream
Body: <full schedule blob (no framing, entire blob in body)>
The anchor responds:
- 200 OK with body 0x01 on success.
- 400 Bad Request with body 0x00 on CRC or parse failure.
The CRC is appended to the HTTP body as the final 4 bytes (little-endian uint32) after all event data.
Sync verification (v0.8). A GET http://<anchor-ip>/schedule returns 200 OK with a 4-byte body: the CRC32 (little-endian uint32) of the schedule blob the anchor currently holds in NVS, or 0x00000000 if none. This lets the app confirm — rather than infer — that a given anchor is up to date, and catches an anchor that fell back to a stale persisted schedule after a reset (MOBILE_APP_SPEC §8.16). The anchor also mirrors this value in its WiFi Status characteristic (§4.4) so the app can verify over BLE when the LAN path is unavailable.
6.3 Proximity Protocols (proximity.cpp)¶
6.3.1 Proximity Vector Payload (Watch → Anchor)¶
Written by the watch to ANCHOR_PROX_VECTOR_CHAR_UUID. All fields little-endian.
[1 byte: device count N (0 to PROX_MAX_DEVICES)]
for each device (N entries):
[6 bytes: mac (BLE MAC address or WiFi BSSID, big-endian as seen on-air)]
[1 byte: type (0x00 = BLE device, 0x01 = WiFi AP)]
[1 byte: rssi (signed int8, dBm; cast to uint8 for wire: rssi_wire = rssi + 128)]
Total: 1 + N × 8 bytes
Maximum at PROX_MAX_DEVICES=60: 481 bytes — fits within a 512-byte negotiated MTU.
The watch assembles the vector immediately before initiating the GATT connection. It includes every BLE device and WiFi AP observed during the preceding scan cycle. If the total device count exceeds PROX_MAX_DEVICES, the watch truncates to the PROX_MAX_DEVICES entries with the strongest RSSI values. If the negotiated MTU is below PROX_MIN_MTU_BYTES, the watch further truncates the vector to fit within negotiated_mtu - 3 bytes before transmitting; the firmware logs a warning.
The anchor deserialises RSSI values as: rssi_dbm = (int8)(rssi_wire - 128).
✅ The above is what is on the wire today. There is no version byte and no trailer:
prox_serialize_vector()emits exactly[count][N × 8]. Everything below is 📋 planned and describes the P2/P5 lockstep batches. Do not write a parser against it and do not assume a device sends it.
📋 Format v2 (P2 lockstep batch — v1 parsers retired in the same flash):
[1 byte: format version = 0x02]
[1 byte: device count N (0..PROX_MAX_DEVICES)]
N × device entries (8 bytes each, identical to v0.8):
[6 bytes: mac][1 byte: type][1 byte: rssi+128]
[1 byte: trailer flags] bit0 CALIB_AWAY; bit1 CHANNELS_VALID (0 under the S1b fallback)
[1 byte: motion_state] 0=STILL 1=FIDGET 2=LOCOMOTION 3=UNKNOWN
[1 byte: neff] effective sample count, capped 255 (Q4 → integer floor)
[1 byte: anchor feature count K (0..4; target anchor first, then rivals)]
K × anchor feature entries (12 bytes each):
[6 bytes: anchor BLE MAC]
[1 byte: pdr_hits] TX_LO slots heard in the integrator's current window
[1 byte: pdr_slots] TX_LO slots covered (0 ⇒ PDR unknown/abstain)
[1 byte: s_ch] cross-channel spread, dB, clamped 0..63; 0xFF unknown
[3 bytes: r37, r38, r39] per-channel TX_HI RSSI as rssi+128; 0 = unseen
Max size at N=60, K=4: 2 + 480 + 4 + 48 = 534 bytes — 22 over a 512 MTU, so at full occupancy the watch trims device entries (weakest RSSI first) to fit negotiated_mtu − 3, never the trailer; PROX_MIN_MTU_BYTES unchanged. The anchor rejects version bytes it doesn't know with response 0x00 (the existing malformed-payload path).
📋 Format v3 (P5 lockstep batch — occlusion; applied on top of v2):
The trailer gains 6 bytes after neff, before the anchor-feature count, and the version byte becomes 0x03:
[1 byte: occ_flags] bit0 OCCLUSION_SUSPECT (watch verdict, latched per §13.5)
bit1 REF_VALID (0 ⇒ every field below is unknown)
[1 byte: delta_db] common-mode offset vs reference, dB + 128; 0x00 = unknown
[1 byte: mad_db] differential dispersion (MAD of residuals), dB; 0xFF = unknown
[1 byte: censor_u8] rank-monotonicity of dropouts, 255 = perfectly monotone; 0xFF = unknown
[1 byte: chflat_db] per-channel spread of the offset, dB; 0xFF = unknown
(always 0xFF while BEACON_CHANNEL_CONTROL = 0 — i.e. always, today)
[1 byte: shared_dropped] high nibble |S| (shared, saturating 15), low nibble |D| (dropouts)
Max size at N=60, K=4 grows to 540 bytes; the existing trim rule absorbs it unchanged. The watch sends the raw statistics as well as its verdict, so the anchor can retrain distributions from field data without a firmware change and S6-style corpora can be gathered from production units.
Phase 5's score-side change is a flag bit only, no length change: flags gains bit 5 PROX_FLAG_OCCLUDED, so the anchor echoes the watch's latched verdict to the notify consumer without it parsing the vector. Adding no byte there is deliberate — see the reconciliation note in §4.4.
6.3.2 Fingerprint Blob (App → Anchor)¶
Transmitted by the mobile app to the anchor using the BEGIN / DATA / END protocol (Section 6.2) via ANCHOR_FINGERPRINT_CTRL_CHAR_UUID and ANCHOR_FINGERPRINT_DATA_CHAR_UUID. The CRC32 covers the entire blob body (everything after the BEGIN frame). All fields little-endian.
[2 bytes: device count N (uint16, 0 to ANCHOR_PROX_MAX_FINGERPRINT_DEVICES)]
for each device (N entries):
[6 bytes: mac (BLE MAC or WiFi BSSID)]
[1 byte: type (0x00 = BLE, 0x01 = WiFi AP)]
[4 bytes: mu (float32, mean RSSI in dBm)]
[4 bytes: sigma_sq (float32, variance in dBm²; must be > 0)]
[4 bytes: W (float32, cumulative Welford weight; 0.0 if no samples yet)]
Total per entry: 19 bytes
Total blob: 2 + N × 19 bytes
Example at N=60: 1142 bytes — requires chunked transfer.
On deserialisation, the anchor replaces its device registry with the N entries in order. Any previous self-supervised data is discarded. Entries with W == 0.0 are loaded into the registry but are excluded from Signal B (fingerprint log-likelihood) until W >= PROX_MIN_FINGERPRINT_WEIGHT; they still participate in Signal A (live correlation) immediately.
7. Constants & Tunables¶
All of the following must be defined as named constants in the firmware. Values marked "(tunable)" are expected to require adjustment during hardware testing.
// Timing
ENFORCEMENT_POLL_INTERVAL_S = 30 // (tunable) seconds between condition checks
BLE_SCAN_INTERVAL_S = 30 // seconds between BLE anchor scans in DORMANT state only
ENFORCEMENT_BLE_FALLBACK_SCAN_INTERVAL_S = 300 // (tunable) fallback proximity query interval during ENFORCEMENT
// for anchor-based events when no motion is detected
ENFORCEMENT_SCAN_DURATION_MS = 300 // (tunable) BLE scan duration for vector collection during
// enforcement; 300 ms covers 3 anchor advertisement windows
ENFORCEMENT_IDLE_BEFORE_SLEEP_MS = 3000 // (tunable) ms of no motion before entering enforcement sleep
WIFI_SCAN_INTERVAL_S = 60 // seconds between WiFi scans in dormant
DORMANT_TO_SLEEP_IDLE_MS = 5000 // ms of inactivity before entering dormant-sleep
ANCHOR_WIFI_RETRY_INTERVAL_S = 30 // anchor WiFi reconnect attempt interval
ANCHOR_SEEN_TIMEOUT_S = 10 // seconds after which an anchor is considered unseen
// for discovery purposes (does not affect proximity scoring)
// Anchor WiFi credential slots (§4.4, §4.5.1)
ANCHOR_WIFI_MAX_CRED_SLOTS = 4 // credential pairs stored in anchor NVS; offers are
// non-destructive until all 4 are occupied
ANCHOR_WIFI_SLOT_ATTEMPT_LIMIT = 3 // consecutive failures on one slot before advancing
// Watch-side anchor WiFi repair (§5.5.3)
ANCHOR_REPAIR_MIN_BATTERY_MV = 4000 // watch only repairs anchors above this battery voltage
// (i.e. on/near the charger) — read_battery_mv(), §1
ANCHOR_REPAIR_SCAN_INTERVAL_S = 1200 // 20 min between repair checks while gated conditions hold
ANCHOR_REPAIR_BACKOFF_S = 14400 // 4 h suppression per anchor UUID after an offer, unless
// its WiFi Status state is observed to change
// UDP
ANCHOR_UDP_PORT = 5555 // (tunable) UDP listen port on anchors
UDP_RETRY_COUNT = 3 // attempts before marking anchor unreachable
UDP_RETRY_TIMEOUT_MS = 500 // ms timeout per UDP attempt
MDNS_RESOLVE_TIMEOUT_MS = 1000 // ms timeout for mDNS resolution
// Anchor settings
ANCHOR_MAX_BEEP_MINUTES_DEFAULT = 30 // default max continuous beeping per event
IDENTIFY_BEEP_DURATION_MS = 800 // duration of identify beep
// Anchor servo
SERVO_CLOSED_DEGREES = 180 // PWM angle for the closed/locked strap position
SERVO_OPEN_DEGREES = 160 // PWM angle for the open/unlocked strap position
SERVO_MOVE_DURATION_MS = 500 // (tunable) time to wait after commanding angle before detaching
// Watch pairing feedback
UNPAIRED_BEEP_DURATION_MS = 200 // beep on first BLE connection
UNPAIRED_VIBRATE_DURATION_MS = 300 // vibrate on first BLE connection
// Worn detection (all tunable — ITR8307 behavior must be validated on hardware)
IR_WORN_THRESHOLD = 300 // (tunable) min ambient-subtracted ADC delta for worn
IR_WORN_HIGHER_MEANS_WORN = true // (tunable) circuit polarity; false if readout inverted
IR_EMIT_SETTLE_US = 500 // emitter rise/settle before each ADC read
IR_WORN_DEBOUNCE_SAMPLES = 5 // consecutive samples required to change worn state
IR_WORN_SAMPLE_INTERVAL_MS = 1000 // ms between reflection samples
// Enforcement profile escalation
INTERVAL_DECREMENT_MS = 2000 // ms to subtract from wait step each cycle
// BLE
BLE_REQUESTED_MTU = 512 // MTU requested during negotiation on every GATT connection
// (both watch–anchor and app–watch/anchor). The stack negotiates
// down if the peer cannot support this value.
ENFORCEMENT_SCAN_INTERVAL_MS = 1000 // (tunable) BLE scanner slot interval during enforcement
ENFORCEMENT_SCAN_WINDOW_MS = 100 // (tunable) BLE scanner active window during enforcement (~10% duty)
MINIMUM_BLE_DELAY_DORMANT = 15000 // (tunable) ms to wait before re-arming the IMU interrupt in DORMANT;
// prevents rapid movement from blocking entry to DORMANT_SLEEP
MINIMUM_BLE_DELAY_ENFORCEMENT = 30000 // (tunable) ms to wait before re-arming the IMU interrupt in ENFORCEMENT;
// limits how often continuous movement can trigger a fresh proximity query
// Schedule
MAX_EVENTS_PER_DAY = 64 // maximum events recalculate_day will return
// LED status ring (Section 5.7) — colours/timings below are NVS defaults, overridable at runtime
LED_RING_DATA_PIN = 10 // (hardware-rev dependent) data line for the SK6805 ring.
// Current rev: GPIO 10 (vibration motor disabled by PCB switch).
// The "next rev moves this to GPIO 7" plan is retired — that ESP32
// board was never built. The pin share is designed out on the nRF
// board instead (ring P0.02, motor P3.03); see Section 1.3.
// ONLY this constant changes between revs; never touch motor code.
LED_RING_COUNT = 12 // number of LEDs in the ring
LED_RING_DEFAULT_BRIGHTNESS = 40 // (tunable) FastLED master brightness, 0–255
LED_WAKE_FLASH_MS = 600 // (tunable) duration of the momentary wake-cause flash before the
// ring settles to the steady activity colour
LED_ALARM_BLINK_ON_MS = 250 // (tunable) default ENFORCE_ALARM blink on-time
LED_ALARM_BLINK_OFF_MS = 250 // (tunable) default ENFORCE_ALARM blink off-time
// Default slot colours (R, G, B) — see LedStatusSlot enum, Section 5.7
LED_DEFAULT_DORMANT_RGB = {0, 0, 255} // blue — awake & dormant
LED_DEFAULT_ENFORCE_IDLE_RGB = {255, 90, 0} // orange — enforcing, condition met
LED_DEFAULT_ENFORCE_ALARM_RGB = {255, 0, 0} // red — enforcing, condition not met (blinks)
LED_DEFAULT_WAKE_MOTION_RGB = {0, 255, 0} // green — woke on motion
LED_DEFAULT_WAKE_TIMER_RGB = {0, 180, 255} // light blue — woke on RTC/timer
LED_DEFAULT_WAKE_BLE_RGB = {255, 255, 255} // white — woke on incoming BLE
// ── Proximity engine (proximity.cpp) ──────────────────────────────────────────
// Watch-side vector assembly
PROX_MAX_DEVICES = 60 // maximum BLE + WiFi entries in a ProxScanVector;
// if more devices are seen, keep the 60 with strongest RSSI
PROX_MIN_DEVICE_COUNT = 8 // minimum number of devices in a ProxScanVector to use the
// proximity engine; below this the watch falls back to
// single-anchor raw RSSI (Signal A only, no fingerprint)
PROX_MIN_MTU_BYTES = 256 // minimum negotiated MTU (bytes) for the proximity vector write;
// if negotiated MTU < this value, vector is truncated to fit and
// firmware logs a warning. Set to fit at least PROX_MIN_DEVICE_COUNT
// entries: 3 (ATT header) + 1 (count) + 8*8 = 68 bytes minimum.
// Watch-side score interpretation
PROX_CONFIDENCE_THRESHOLD_U8 = 170 // (tunable) score threshold (0–255) above which the watch treats
// the anchor as NEAR; below (255 - this value) treated as AWAY;
// between the two bounds is AMBIGUOUS. 170 ≈ 0.667 of full scale.
ANCHOR_NEAR_RSSI_THRESHOLD_DBM = -70 // (tunable) fallback RSSI cutoff used only when
// PROX_FLAG_LOW_DEVICE_COUNT is set in the score result
// Anchor-side score computation
PROX_MISSING_RSSI_DBM = -100 // sentinel RSSI (dBm) substituted for devices absent from a
// watch vector during fingerprint log-likelihood computation
PROX_MIN_FINGERPRINT_WEIGHT = 5.0 // minimum cumulative Welford weight W for a device to contribute
// to Signal B (fingerprint log-likelihood)
PROX_ALPHA_W0 = 2000.0 // (tunable) total fingerprint weight at which the blend factor α
// has decayed to e^-1 ≈ 0.37 (i.e. Signal B contributes ~63%);
// at W_total=0 α=1.0 (pure correlation), at W_total>>W0 α→0
// Anchor-side self-supervised training
PROX_COLLECT_SCORE_THRESHOLD = 0.75 // (tunable) minimum blended score (0.0–1.0) required to accept a
// watch vector as a self-supervised training sample
PROX_COLLECT_AMBIGUITY_MARGIN_DBM = 10 // (tunable) minimum dBm margin by which this anchor must exceed
// any competing anchor's raw RSSI (as seen in the watch vector)
// for the sample to be considered unambiguous
PROX_NVS_PERSIST_INTERVAL_S = 300 // seconds between NVS fingerprint persist operations; limits
// flash wear while allowing recovery on unexpected reboot
// Watch-side app-guided calibration burst (§4.10.7)
CALIB_QUERY_INTERVAL_MS = 600 // (tunable, watch) minimum gap between burst proximity queries;
// each query also runs a ~ENFORCEMENT_QUERY_SCAN_DURATION_MS scan
CALIB_MIN_DURATION_S = 15 // (watch) lower clamp on a requested burst duration
CALIB_MAX_DURATION_S = 180 // (watch) upper clamp; bounds the forced-awake power cost
CALIB_DEFAULT_DURATION_S = 75 // (watch) duration used when the app requests 0
// Anchor-side continuous background scanning
ANCHOR_PROX_BLE_SCAN_INTERVAL_MS = 2000 // (tunable) ms between anchor BLE scan cycles
ANCHOR_PROX_BLE_SCAN_DURATION_MS = 500 // (tunable) duration of each anchor BLE scan cycle (ms)
ANCHOR_PROX_WIFI_SCAN_INTERVAL_S = 30 // (tunable) seconds between anchor WiFi AP scan cycles
ANCHOR_PROX_DEVICE_STALE_MS = 10000 // ms without an RSSI update before a cache entry is marked stale
// and excluded from score computations
ANCHOR_PROX_MAX_FINGERPRINT_DEVICES = 128 // maximum entries in the anchor device registry / fingerprint;
// new devices beyond this cap are silently ignored
// Mode B — phone-distance (phoneAway) commitments
PHONE_AWAY_TOLERANCE_S = 60 // (tunable, watch) grace the user may be near/holding the phone
// before enforcement; brief "quick checks" don't fire
DOCK_RSSI_THRESHOLD_DBM = -60 // (tunable, anchor) anchor↔phone link RSSI at/above which the
// phone counts as docked; below (or link lost) → undocked
DOCK_RSSI_POLL_MS = 2000 // (tunable, anchor) how often the anchor samples the docking-phone
// link RSSI and updates Dock Status
7.1 Proximity engine v2.1 constants (proximity.cpp)¶
Values below are as they stand in proximity_engine/src/proximity.h, which is the authority — the header carries the measurement that set each one, and those comments are load-bearing. Where the v0.9 amendment proposed a different value, the amendment's number is shown struck through in the comment.
// ── Beacon schedule (anchor; §4.12) ──────────────────────────────────────
BEACON_SCHEDULE_ENABLE = 1
BEACON_CHANNEL_CONTROL = 0 // S1b fallback ships: 2-slot power-only, no channel features
BEACON_SLOT_MS = 250 // (S2 fallback: 500; scale OBSERVE window to match)
BEACON_ADV_INTERVAL_MS = 50
BEACON_TX_LO_DBM = -6 // S1a-measured on hardware (amendment proposed -21)
BEACON_SCHEDULE_EPOCH_OFFSET_MS = 750 // Mode C anchor-pair phase offset
// ── Observation & features (watch) ───────────────────────────────────────
PROX_OBSERVE_WINDOW_MS = 1800 // full-duty PASSIVE; >= 6×SLOT + SLOT + margin
PROX_V2_AUTHORITATIVE = 1 // v2 decision is BINDING (shadow period over)
CALIB_QUERY_INTERVAL_MS = 2000 // was 600; each burst query spans a full observation window
PROX_STARVED_SCORE = 128 // criterion-neutral; replaced the old score-50 branch (R1)
PROX_MIN_DEVICE_COUNT = 8 // starvation guard — a security boundary (§13.4-R1)
PROX_CM_MIN_SAMPLES = 6 // common-mode estimate floor
PROX_FP_MIN_PRESENT = PROX_CM_MIN_SAMPLES
// Signal B's own floor. Pinned to CM_MIN so that whenever
// Signal B speaks at all, it speaks offset-invariantly.
PROX_FP_FLOOR_TRUST = 0.5f // Signal B's authority scales with what it actually saw
PROX_SELF_MAX_GAIN_U8_PER_DB = 4 // cap: a narrow calibration must not produce a violent term
// ── IMU motion channel (watch; §5.4.5) ───────────────────────────────────
IMU_BURST_SAMPLES = 32
IMU_BURST_HZ = 50
IMU_STILL_VAR = 400 // (20 mg RMS)^2 — S3-measured (amendment: TBD)
IMU_LOCO_VAR = 15000 // (~122 mg RMS)^2 — S3-measured gap (amendment: TBD)
IMU_STALE_MS = 10000 // (amendment proposed 5000)
NEFF_LOCO_PER_S = 8 // WHOLE DRAWS per second, not Q4 — see the deviation note below
NEFF_FIDGET_PER_S = 2
NEFF_TRAIN_MIN = 8
INTEG_STILL_WEIGHT = 26 // ~0.10
// ── HMM (watch) ──────────────────────────────────────────────────────────
HMM_PFLIP_STILL = 1e-4 // per-tick, time-scaled between irregular ticks
HMM_PFLIP_FIDGET = 1e-3
HMM_PFLIP_MOVE = 0.15
HMM_STILL_DRAW_PERIOD_S = 43200 // 12 h — how long a motionless wrist sits before the engine
// concedes one fresh independent draw. Not in the amendment.
LL_CONNFAIL_AWAY_Q8 = -768 // -3.0 log-LR in Q8; requires a LOCOMOTION witness (R4)
// ── Poll tiers (watch; §5.4.1) ───────────────────────────────────────────
ENFORCEMENT_POLL_INTERVAL_STILL_S = 600 // met + HMM-confident + STILL
// ── Anchor scan hygiene ──────────────────────────────────────────────────
ANCHOR_PROX_WIFI_MAX_SCANS = 8 // esp_wifi_scan_start() leaks ~1.5 KB/call under BLE coex on
// the C3 and used to OOM-panic the anchor every ~20 min.
// Do not remove this cap without a replacement.
ANCHOR_PROX_WIFI_STALE_MS = 7 days // per-emitter-class freshness: a bolted-down AP seen by a
// stationary anchor IS a constant
// ── 📋 NOT IMPLEMENTED — P2 and beyond ───────────────────────────────────
PROX_TRAIN_AWAY_THRESHOLD = 0.25
PROX_TRAIN_BOOTSTRAP_W = 50.0 // motion_state=UNKNOWN vectors may train below this W_total
ANCHOR_PROX_MAX_PEER_ANCHORS = 8
PDR_NEAR_ALPHA_BETA = {8, 2} // Beta prior for TX_LO PDR | near
PDR_AWAY_ALPHA_BETA = {1, 9}
CPL_WINDOW_S / CPL_BIN_MS / CPL_MIN_MOTION_BINS = 12 / 250 / 8 // coupling detector (P3)
CSI_PING_FRAMES = 4 // (P4 — provisional; do not implement before S4 passes)
CSI_DEF_FS_NEAR_MU / _SIGMA = 0.18 / 0.10
CSI_DEF_FS_AWAY_MU / _SIGMA = 0.45 / 0.18
// ── 📋 NOT IMPLEMENTED — occlusion (P5, provisional until S6) ────────────
OCC_ENABLE = 1 // 0 ⇒ byte-identical to P4
OCC_REF_MAX_AGE_S = 900 // reference-vector expiry
OCC_MIN_SHARED = 6 // |S| below this ⇒ delta/MAD abstain
OCC_MIN_DROPOUTS = 3 // |D| or |R| below this ⇒ censor term abstains
OCC_DELTA_DB = -10 // common-mode drop making occlusion plausible. MUST sit above
// the bedding-only regime (~3-5 dB) or every sleeper trips it.
OCC_MAD_DB = 4 // residual dispersion below this ⇒ homogeneous
OCC_CENSOR_MIN_U8 = 204 // 0.80 rank-monotonicity
OCC_CHFLAT_DB = 5 // offset spread below this ⇒ absorptive
OCC_LOGLR_TAU_Q8 = 512 // +2.0 nats ⇒ latch OCCLUDED
OCC_CLEAR_TAU_Q8 = 128 // +0.5 nats ⇒ clear latch (hysteresis)
OCC_SUSTAIN_S = 120 // continuous OCCLUDED ⇒ reportable event
LL_LEVEL_MAX_Q8 = 384 // cap on the separated absolute-level term
Two deviations from the amendment that are deliberate and must not be "fixed" back:
near_thrin the score result. The amendment's emissionlogit((score+0.5)/256)is centred on score 128, which silently discards calibration-v2's per-anchor demonstrated cutoff — a score of 180 would read NEAR-ish for an anchor whose demonstrated cutoff is 210. The emission is centred on the anchor's own decision point instead (§4.4, §5.4.1 step 8).NEFF_LOCO_PER_S/NEFF_FIDGET_PER_Sare draws per second, not pre-scaled Q4. Read as Q4 they credit 0.5 draws per second of walking — 32× below the rate the engine spec's §1.3 derives (λ/2 = 6.2 cm at ~1 m/s ≈ 16/s) — and no walk-in accumulates enough evidence to flip promptly.N_effis stored Q4; the rates are whole draws.
Both are recorded in code comments and in prox_v2_p1_notes.md.
8. Power & Radio Optimization (Watch)¶
The radio (BLE + WiFi) dominates the watch's energy budget; the MCU spends most of the day in light sleep with the radio off (ADVERTISE_DURING_SLEEP = false). The estimates below assume ~8 h/day inside enforcement windows (user mostly compliant, so the watch light-sleeps between polls) and ~16 h/day idle. Absolute figures are order-of-magnitude; the ranking is what matters.
8.1 Radio cost ranking (per day)¶
| Rank | Operation | Cadence | ~Energy/day | Notes |
|---|---|---|---|---|
| 1 | WiFi AP scan in scan-vector build | per proximity query | ~40 mAh | Only if WiFi associated. Blocking active scan. APs are stationary → mostly wasted. |
| 2 | WiFi connected idle (modem sleep) | continuous during anchor enforcement | ~40 mAh | Only if associated. DTIM wakeups. |
| 3 | Pre-query active BLE scan | per proximity query | ~12–17 mAh | Largest BLE consumer. Full-duty + active TX. |
| 4 | GATT proximity connection | per proximity query | ~3 mAh | connect + MTU + write + read + disconnect. |
| 5 | Continuous advertising (peripheral) | while awake | ~2–5 mAh | Modest. |
| 6 | DORMANT anchor-discovery scan | 300 ms / 60 s, motion-gated | ~1–3 mAh | Only while awake. |
| 7 | Enforcement fallback BLE scan | low-duty, periodic | <1 mAh | Redundant once each poll refreshes the cache. |
| — | DORMANT_SLEEP (radio off) | 16 h | ~2 mAh | Light-sleep baseline. |
WiFi dominates when associated; otherwise the per-query active BLE scan dominates.
8.2 Implemented optimizations¶
These are mandatory watch behaviors, not just suggestions:
-
WiFi AP scan caching + association gate. WiFi APs are physically stationary, so the watch does not rescan them on every proximity query. AP scan results are cached and reused for
PROX_WIFI_SCAN_INTERVAL_MS(default 5 min); a fresh scan runs only when the cache is stale. The watch skips the WiFi scan entirely when not associated (WiFi.status() != WL_CONNECTED) — anchor proximity is computed from BLE alone in that case. (Addresses ranks 1–2.) -
Bounded pre-query scan. The cache-refresh scan that precedes each query is bounded by
ENFORCEMENT_QUERY_SCAN_DURATION_MS(default 700 ms, reduced from 1500 ms). Device counts far exceedPROX_MIN_DEVICE_COUNTin typical environments, so the shorter scan preserves fidelity while roughly halving the largest BLE cost. (Addresses rank 3.) -
Adaptive enforcement poll interval. When the enforcement condition is met and stable, the watch backs the poll interval off to
ENFORCEMENT_POLL_INTERVAL_MET_S(default 180 s); when not met, it polls atENFORCEMENT_POLL_INTERVAL_S(default 60 s). The IMU motion interrupt still forces an immediate re-check, so responsiveness to the user actually moving is unchanged — only redundant polling of a still, compliant user is reduced. (Addresses ranks 3–4.) -
No redundant fallback scan. The standalone periodic enforcement fallback scan is removed: every poll (and every motion-triggered check) already runs a fresh aligned scan immediately before building the vector, and the watch light-sleeps between polls when compliant, so a separate background scan served no purpose. (Addresses rank 7.)
8.3 Optimization tunables¶
PROX_WIFI_SCAN_INTERVAL_MS = 300000 // (tunable) ms between actual WiFi AP scans; cached results are
// reused in between. APs are stationary, so this can be large.
ENFORCEMENT_QUERY_SCAN_DURATION_MS = 700 // (tunable) bounded duration of the pre-query cache-refresh scan
ENFORCEMENT_POLL_INTERVAL_S = 60 // (tunable) poll cadence while the condition is NOT met
ENFORCEMENT_POLL_INTERVAL_MET_S = 180 // (tunable) backed-off poll cadence while the condition IS met
ENFORCEMENT_IDLE_BEFORE_SLEEP_MS = 30000 // (tunable) still-time before entering enforcement light sleep
DORMANT_TO_SLEEP_IDLE_MS = 1500 // (tunable) idle time in DORMANT before dropping to DORMANT_SLEEP
WIFI_SCAN_INTERVAL_S = 120 // (tunable) DORMANT WiFi reconnect-scan cadence
8.4 Motion-wake gating & idle reachability¶
Wrist-worn motion is the watch's largest potential battery drain: at the original sensitivity, casual movement woke the watch every few seconds, and each wake held the CPU awake long enough that an active user could keep it out of sleep most of the day (estimated tens to hundreds of mAh/day). The wakeups bought nothing outside an enforcement window. This is addressed in three parts. (An earlier revision added a fourth, look-to-wake — a narrow orientation-gated wrist-raise wake source — but it was removed in v0.7: keeping the analog clock always lit on the ring (§5.7.4) means the watch never needs a glance to light it, so DORMANT_SLEEP carries no IMU interrupt at all.)
-
High-pass-filtered motion interrupt. The LIS3DH interrupt generator runs through its high-pass filter (
CTRL_REG2HPIS1), soMOTION_THRESHOLD_MGmeasures dynamic acceleration with gravity removed — consistent in every wrist orientation, instead of the original gravity-relative threshold that was hair-trigger in some resting positions and dead in others. Because motion now only matters during enforcement (part 2), the threshold andMOTION_DURATION_MSare tuned for responsiveness — low enough that normal-pace movement (e.g. walking away from a stayNear anchor) reliably triggers a re-check, with a short duration filter so brief gait peaks still register — rather than for idle battery, which motion no longer affects. -
Motion is a wake source only during ENFORCEMENT. In
DORMANT_SLEEP, general high-pass motion (IA1) is not a wake source: the edge ISR is detached and the INT1 GPIO wake is disabled entirely, so the watch wakes on the timer/advertise-heartbeat and BLE only. Motion received while awake in DORMANT is cleared and ignored without resetting the idle timer. During an enforcement window, motion still wakes the watch and forces an immediate condition re-check (responsiveness preserved exactly). -
Idle reachability without motion. Because a still watch no longer wakes on motion, two mechanisms keep it reachable by the phone app: - No idle sleep while connected. Neither
DORMANT_SLEEPnor enforcement light sleep is entered whilebt_connectedis true, so an in-progress configuration session is never dropped by the radio powering down. - Advertise heartbeat. While disconnected,DORMANT_SLEEPcaps its wake timer atDISCONNECTED_ADV_HEARTBEAT_MS(default 6 s). The watch surfaces each interval, advertises during its brief awake window, and accepts a connection if a phone is scanning — so a still, idle watch is always reachable within ~one heartbeat, with no button or motion required. The same heartbeat wake refreshes the always-on analog clock (§5.7.4); the visible face changes only once per minute.
Removed: look-to-wake (wrist-raise). An earlier revision re-introduced a fourth part — a narrow orientation-gated wrist-raise wake source (LIS3DH generator IA2) that lit the ring only when the watch was held face-up. It was removed in v0.7 along with its LOOK_* tunables, LOOK_WAKEUP_ENABLED, and the IA2 register setup. Its purpose was to light the LED ring on a glance; keeping the analog clock always lit (§5.7.4) subsumes that purpose while removing the wrist-raise wake source entirely, so DORMANT_SLEEP now arms no IMU interrupt at all. Motion (IA1) survives only as the ENFORCEMENT re-check wake source (part 2).
This is a latency-vs-battery tradeoff: a shorter heartbeat connects faster but costs more idle power (the watch is awake during each heartbeat window). A full BLE-controller-wake implementation via esp_pm automatic light sleep would make idle reachability essentially free (sub-mA, zero latency) and is the recommended future refactor.
MOTION_THRESHOLD_MG = 48 // (tunable, imu.cpp) dynamic accel to trigger the ENFORCEMENT
// re-check; low so normal-pace movement reliably registers
MOTION_DURATION_MS = 20 // (tunable, imu.cpp) motion must persist this long to count;
// short so brief gait peaks still register
DISCONNECTED_ADV_HEARTBEAT_MS = 6000 // (tunable) max DORMANT_SLEEP interval while disconnected, so the
// watch periodically advertises, is reachable by the app, and
// refreshes the always-on analog clock (§5.7.4)
8.5 Proximity v2.1 deltas¶
The observation window raises the per-query cost of rank 3 (the pre-query scan) by ~2.6× — 1800 ms full duty against 700 ms. It pays for itself:
- The STILL poll tier cuts query frequency by 3.3× (600 s vs 180 s) for the met-and-still bulk of a typical window, which is where nearly all queries occur for a compliant user. Net budget neutral-to-positive there.
- During violations (60 s polls) the 2.6× applies unmitigated — but violations are short by product design, and a user in violation is the one case where spending battery is obviously correct.
- The IMU adds zero idle cost. No new interrupt sources (IA1 usage unchanged), bursts execute inside already-awake observation windows, DORMANT untouched.
- Anchor side: schedule reconfiguration CPU is negligible and TX_LO slots reduce mean radiated power. Anchors are mains-powered anyway.
The framing that matters: not polling a STILL wrist is correct, not merely cheap. A stationary user cannot change proximity class — that is the HMM's premise, not an approximation — and the IA1 interrupt restores full responsiveness the instant the premise breaks.
9. Commitment Integrity — The Watch as Root of Trust¶
Principle: the watch, not the app, authoritatively enforces the product's self-binding asymmetry — instant to tighten, slow to loosen. The app is an authoring terminal: it is deletable, reinstallable, and replaceable by a generic BLE tool, so any loosening guarantee implemented only app-side can be bypassed in minutes. The watch therefore validates every proposed change itself: the app proposes, the watch disposes. The app mirrors these rules for previews (MOBILE_APP_SPEC §8.9), but only the watch's verdict is binding.
This is anti-impulse hardening, not anti-adversary security. A determined user with a flashing cable always wins; the goal is that no escape is available in the moment, from the phone, in a few taps (impulse_overview.md: "hard to beat on impulse," never "impossible to cheat").
9.1 Canonical tighten/loosen classification¶
Every proposed change to an event is classified tightening, loosening, or non-comparable. Non-comparable ⇒ treated as loosening (conservative: if the watch can't prove a change binds at least as hard, it is not instant). This table is the single canonical definition; the app implements identical rules so its previews match the watch's verdicts.
| Field changed | Tightening | Loosening | Non-comparable |
|---|---|---|---|
| Event added | always | — | — |
| Event deleted | — | always | — |
Window (startTime/endTime) |
new window ⊇ old (starts earlier and/or ends later) | new window ⊆ old | partial shift (e.g. both start and end move later) |
| Recurrence | new occurrence set ⊇ old (e.g. weekly→daily, same window) | new set ⊆ old (daily→weekly) | different days (weekly Tue→Thu) |
profile |
≥ in both dimensions of the partial order below | ≤ in both | otherwise |
criteria |
— | — | any change |
anchorId / wifiSSID target |
— | — | any change |
beepAnchors |
superset | subset | overlapping-but-different sets |
anchorProfile |
hard > medium > light | reverse | — |
donningGraceS |
decrease | increase | — |
One-time negate added against a recurring event |
— | always (cancels a day) | — |
EnforcementProfile partial order — two independent dimensions:
- Strictness: strict > normal > loose.
- Output: both > buzz and both > silent; buzz vs silent are non-comparable.
A profile change is tightening only if ≥ in both dimensions (and > in at least one); loosening only if ≤ in both; anything else is non-comparable.
Multi-field edits: an edit is tightening only if every changed field classifies as tightening; a single loosening or non-comparable field makes the whole change a loosening.
9.2 On-watch settle state¶
Alongside the schedule blob, the watch persists per event (NVS):
- last_edit_elapsed — elapsed-time stamp of the last accepted change to this event.
- settled_baseline — snapshot of the event as of its most recent settle (absent for events that have never settled).
An event settles when SETTLE_WINDOW_MIN (default 120) minutes of elapsed time pass with no accepted change; at that instant its current state is copied into settled_baseline.
Elapsed-time basis: all integrity timers — settle windows, pending apply_after, pass-window aging — count monotonic elapsed time using a persisted elapsed-time accumulator spanning reboots (the same pattern as the usage instrumentation's persisted elapsed base), never wall-clock time. A clock write therefore cannot accelerate any integrity timer.
9.3 Schedule-diff gate¶
Replaces blind replacement in §5.1.4. On receiving a CRC-valid, version-valid schedule blob over BLE:
- Deserialize as the proposed schedule.
- Diff against the current schedule per event UUID; classify each change per §9.1.
- Partition the changes:
- Apply immediately: tightenings; new events; any change to an unsettled event whose resulting state is still ≥ its
settled_baseline(or that has no baseline yet — the free first-setup window). - Apply immediately (no-escape exception): loosenings of an event that is not currently active and whose next occurrence starts more thanLOOSEN_FREE_HORIZON_H(24) hours away — loosening a far-future commitment grants no in-the-moment escape. - Quarantine: all other loosenings (including below-baseline edits of unsettled events), as pending-queue entries withapply_after = now_elapsed + LOOSEN_DELAY_H(24) hours. - New current schedule = old schedule + immediately-applied changes. Store to flash,
recalculate_day(today), re-arm callbacks. - Append quarantined changes to the pending queue (NVS, capacity
PENDING_QUEUE_MAX). If the queue is full, further loosenings are dropped (not applied); the app sees the truth via §9.5. - Respond per §6.2:
0x01full accept /0x03partial quarantine.
Pending-entry semantics: a quarantined entry stores the full proposed post-change state (the complete new Event, a deletion marker, a negate date, or a setting value) — not just a classification — so promotion needs no app involvement. A subsequently accepted change to the same event (or setting) cancels any pending entries for it: the newer intent wins, and re-requesting the loosening restarts its 24 h. (Canceling a pending loosening only ever binds harder, so this rule opens no escape.) Cancellations are reported via §9.5, and the app must surface them.
Phase-1 minimum (ship first): before the full diff engine exists, the watch must at minimum reject any push that would delete, shorten, weaken, or negate the currently active event — compare the active event only; if it changed loosening-wise, discard the entire push and respond 0x04. This single check closes the "push an empty schedule at 6am" bypass.
9.4 Autonomous promotion¶
The watch promotes its own pending queue with no app involvement: on every wake (RTC boundary, midnight, boot), it applies any pending entries whose apply_after has been reached, then re-runs recalculate_day and re-arms callbacks, and notifies §9.5. Promotion therefore happens no earlier than its nominal time — a sleeping watch may promote slightly late, never early. Entries whose effect has already expired at promotion time (e.g. a negate-day whose date has passed) are dropped, not applied.
9.5 Pending Changes characteristic¶
See §5.6 (WATCH_PENDING_CHAR_UUID, …001A) for the wire format. The queue exposed there is the authoritative pending state; the app renders it and can rebuild its own view from it after a reinstall.
9.6 Emergency pass ledger¶
The rolling pass budget (MOBILE_APP_SPEC §8.10) lives on the watch, so clearing app data cannot replenish passes. Ledger: allowance (pass_allowance, default PASS_BUDGET_DEFAULT = 2), plus elapsed-time stamps of spends within the last PASS_WINDOW_DAYS (7) days.
On a SPEND write (§5.6, …001B): if spends-in-window < allowance, apply a one-day negate for the given event/date immediately — deliberately bypassing §9.3 (the pass is the sanctioned escape valve, spendable even on the currently active event), record the spend, respond success + remaining. Otherwise respond exhausted. Raising pass_allowance is a loosening ⇒ routed through the pending queue (change type 3); lowering it applies immediately.
9.7 Time hardening¶
- A Time write (§5.6,
…0019) whose effect would end or skip the currently active enforcement window (new local time falls outside the active event's window while the previous time was inside) is rejected with0x02. All other time writes are accepted normally. - The same guard applies to timezone changes arriving via the Settings characteristic (§5.6): a
tz_offset_minuteschange that would place the current local time outside the active event's window is rejected with0x02. - NTP, when available, takes precedence over app-supplied time.
- Integrity timers are immune to clock changes regardless (elapsed-time basis, §9.2).
9.8 Settings gating¶
Settings writes route through the same classifier. For the dormancy flags (§5.1.1, where true = keep enforcing in that connectivity state): true→false is a loosening ⇒ quarantined (pending type 3); false→true is a tightening ⇒ immediate. Growing SETTLE_WINDOW_MIN (a longer free-edit window) is a loosening ⇒ quarantined; shrinking it is immediate. The settle-window value is clamped to [SETTLE_WINDOW_FLOOR_MIN, SETTLE_WINDOW_CEIL_MIN] = [30, 240] minutes on write. A Settings write containing any quarantined field responds 0x03 (non-gated fields apply immediately); an all-immediate write responds 0x01.
9.9 Phasing¶
| Phase | Scope |
|---|---|
| 1 | Active-event protection only: reject pushes that loosen the currently active event (0x04). Smallest possible change; closes the worst bypass. |
| 2 | Full diff gate (§9.1–9.3), on-watch settle state (§9.2), pending queue + autonomous promotion (§9.4), Pending Changes characteristic (§9.5). |
| 3 | On-watch pass ledger (§9.6), time hardening (§9.7), settings gating (§9.8). |
Until a phase ships, the app's mirrored policy (MOBILE_APP_SPEC §8.9/§8.10) is the only enforcement of that slice — acceptable during development, not for release hardware.
9.10 Constants¶
SETTLE_WINDOW_MIN = 120 // (gated-configurable, §9.8) minutes without edits before an event settles
SETTLE_WINDOW_FLOOR_MIN = 30 // lowest allowed settle-window setting (minutes; clamp on write)
SETTLE_WINDOW_CEIL_MIN = 240 // highest allowed settle-window setting (minutes; clamp on write)
LOOSEN_DELAY_H = 24 // hours a quarantined loosening waits before promotion
LOOSEN_FREE_HORIZON_H = 24 // loosenings of events starting further out than this apply immediately
PASS_BUDGET_DEFAULT = 2 // emergency passes per rolling window (raising = gated, §9.6)
PASS_WINDOW_DAYS = 7 // rolling pass-window length, elapsed-time basis
PENDING_QUEUE_MAX = 16 // max quarantined entries; further loosenings are dropped when full
SCHEDULE_FORMAT_VERSION = 0x02 // leading version byte of the schedule blob (§6.2)
10. Deferred Items & v0.8 Coding-Agent Handoff¶
10.1 Deferred — not in v0.8¶
- Toolchain: bump off the frozen ESP-IDF 4.4.7 platform (deferred; do eventually). The build currently resolves to PlatformIO
platform = espressif32 @ 6.12.0→ Arduino-ESP32 2.0.17 → ESP-IDF 4.4.7, which is the newest of a dead line: Espressif stopped maintaining the official PlatformIOespressif32platform, and the registry publishes no framework past3.20017(there is no 2.0.18 / IDF 4.4.8 package for PlatformIO at all). IDF 4.4.7 has a WiFi-scan-under-BLE-coexistence heap leak on the ESP32-C3:esp_wifi_scan_start()leaks ~1.5 KB/call, which ran the anchor to OOMPANICevery ~20 min (found via the NVS crash-ring; §diagnostics). It is fixed in IDF 4.4.8 (changelog: "Fixed scan get AP number issue", "Fixed memory leak issue when BLE SCAN and other BLE events coexist" [#13747], "…BLE do not update SCAN status correctly for coexistence module on ESP32C3") and in the 5.x line. Current mitigation (shipped): the anchor's WiFi AP scan is bounded — 250 s interval,ANCHOR_PROX_WIFI_MAX_SCANS = 8, then the scan task self-terminates — turning the unbounded leak into a one-time ~12 KB cost (proximity impact is negligible: the anchor is stationary and its live WiFi RSSI is stale-gated out of Signal A between scans; the fingerprint learns WiFi APs from watch vectors). Eventual fix: migrate to the community pioarduino platform fork on Arduino 3.x / IDF 5.x (e.g.platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13-1/platform-espressif32.zipfor IDF 5.3.2), which fixes the leak at the source; thenANCHOR_PROX_WIFI_MAX_SCANScan go back to0(unlimited). Not a version bump but a real migration — Arduino 3.x has breaking API changes (notably the LEDC/servo API:ledcSetup/ledcAttachPin/ledcWrite(channel,…)→ledcAttach(pin,…), used by the anchor servo), and NimBLE-Arduino + the WiFi manager want re-validation against IDF 5.x. Do it on its own branch, off the lockstep batch.
The same upgrade would also improve guided calibration (§8.5). On IDF 4.4.7's old NimBLE, the watch cannot do a central connect to the anchor while it holds the phone (peripheral) link — the two-role case crashes the host (assert ble_hs_timer_exp during service discovery). The shipped workaround (Option A) is that the watch queries the anchor only while the phone is disconnected: the app writes START, drops the watch link, the watch bursts autonomously, and the app reconnects afterward to read the result. That costs the live donut/score during the burst (it falls back to an elapsed-time ring, per §8.5's documented fallback). IDF 5.x's NimBLE handles concurrent peripheral+central far more robustly, so after the migration calibration could hold the phone link throughout and restore the live score/accepted-sample feedback — removing the disconnect-during-burst dance. Track this alongside the MAX_SCANS=0 revert as things to reclaim post-upgrade.
Toolchain-bump spike (2026-07) — result: do NOT bump the watch; the only reason to upgrade would be to upgrade the anchor. A throwaway spike (spike/toolchain-bump) upgraded the watch to pioarduino 55.03.311 (Arduino 3.3.11 / IDF 5.5.5, NimBLE-Arduino 2.5.0) and reproduced the three-way concurrency (peripheral + central + WiFi) to test the claim above. The calibration crash is NOT fixed. With the config adjusted so the central connect actually succeeds (CONFIG_BT_CTRL_BLE_MAX_ACT raised 3→5; at 3 the controller rejected every connect with HCI "Memory Capacity Exceeded", rc=519), the watch still panics in ble_hs_timer_exp → ble_gap_update_next_exp — the same NimBLE host-timer path as IDF 4.4.7 — ~4 s after the phone connects, every time. So the assumption above ("IDF 5.x's NimBLE handles concurrent peripheral+central far more robustly") does not hold for NimBLE-Arduino 2.5.0; Option A stays. The bump does fix the WiFi-scan heap leak (watch ran ~27 min with WiFi+BLE, heap flat at ~35 KB), but the watch's exposure to that leak is already small (scan caching) and the leak is primarily an anchor problem that already ships a mitigation. The bump also introduces new watch-side blockers on this hardware: a BOD boot brownout (had to disable BOD), heap exhaustion (had to cut MAX_EVENTS_PER_DAY 64→24), WiFi ESP_ERR_WIFI_STATE (Arduino 3.x rejects stacked/concurrent WiFi.begin()), and a BLE advertising start race. Net: the watch stays on IDF 4.4.7 + Option A. The only reason to take the pioarduino/IDF 5.x migration is for the anchor — to reclaim the heap-leak fix at the source and set ANCHOR_PROX_WIFI_MAX_SCANS=0 — which is a separate migration with its own breaking changes (LEDC/servo). Full spike write-up, decoded backtrace, and config in tests/version_bump.md.
- Status-characteristic version/length prefix (deferred; do NOT add in v0.8). Watch Status (
…0016) and anchor WiFi Status (…000E) have no version or length prefix, so every field addition is an exact-byte lockstep change with zero graceful degradation — v0.8 alone addsschedule_crcto Watch Status, the third such growth. This is acceptable only because every device is flashed together and backward compatibility is out of scope. If OTA or staged rollouts ever become real, prepend a 1-bytestruct_version(or au8 length) to both characteristics so an older parser can detect and skip unknown trailing fields. Deferred deliberately: adding it now buys nothing while all devices move in lockstep, and it would itself be another wire change to coordinate. Revisit when mixed-firmware fleets become possible.
10.2 Build order (v0.8 firmware portion)¶
The anchor WiFi rework is the foundation the app work depends on; do it first and in this order. Full cross-device order (firmware + app) lives in MOBILE_APP_SPEC §0.3.
- Anchor WiFi refactor → event-driven (§4.4, §4.5.1). Replace the blocking
WiFi.status()polls intry_connect_wifi/WifiCredCallbackwith aWiFi.onEvent()handler that classifies disconnect reasons into the0x03/0x04distress split. This unblocks everything else and removes the 10 s stalls. - 4-slot credential store (§4.4). NVS slot table with SSID dedup + LRU eviction; non-blocking credential-write response (
0x01= accepted); slot-cycling retry loop (§4.5.1). - WiFi Status characteristic
…000E(§4.4) includingschedule_crc, plusGET /scheduleCRC readback (§6.2) and theschedule_crcfield in Watch Status…0016(§5.6). These are the sync-verification surface the app's §8.16 consumes. - Watch-side anchor repair (§5.5.3). Depends on
…000Eexisting (step 3) and on the battery ADC (§1, already functional). Gate, interval, schedule-referenced eligibility, backoff.
10.3 Clarifying notes for the implementer¶
- Lockstep flash. Steps 3's wire changes (Watch Status +4 bytes, new
…000E, newGET) must land on watch, anchor, and app together — a v1 parser misreads a grown Watch Status payload. Coordinate the flash with the app'sScheduleEncoder/status-parser update. - Battery ADC is real despite the stale in-code comment (§1) —
read_battery_mv()is authoritative; correct the comment when you touch that file. schedule_crcsemantics: it is the CRC32 of the full schedule blob the device accepted/stored, identical to the §6.2 END-phase CRC — not a recomputation over today's derived event list.0means no schedule; a genuine blob whose CRC is0is astronomically unlikely and harmless (reads as stale, triggers a re-push).- Watch repair vs. app offers differ by design: the watch offers only to distress anchors it has in its schedule; the app may offer to healthy anchors too. Don't "simplify" them to match — the asymmetry is the security boundary (§5.5.3 rationale).
10-A. Proximity v2.1 roadmap (coding agent)¶
Execute phases strictly in order. Every phase ends green on its acceptance tests before the next begins. All algorithm code lands in proximity_engine/src/proximity.cpp|.h; glue lands in the named firmware files only.
Where the work actually stands (2026-08): S0 spikes done except S4/S5/S6. P1 complete and authoritative. P2 partially complete — the anchor beacon schedule (2-slot fallback), the watch observation window, watch-local PDR, and calibration-v2 have all landed; the wire batch (vector v2, prox_compute_score2, §4.10.8 side table) has not started. P3/P4 not started. P5 partially pulled forward — R1, R2 and R4 shipped; R3/R5 are S6-gated and dark.
Phase S0 — Spikes (throwaway branch spike/prox-v2; write-ups in prox_v2_spikes.md)¶
| Spike | Question | Pass criterion | Status |
|---|---|---|---|
| S1a | Which BLE adv TX-power levels does the C3 + this stack actually emit, and at which level does the 1–4 ft link close while ~10 ft does not? | A level with ≥90% slot-hit at 3 ft and ≤30% at 10 ft LOS | ✅ Done — set BEACON_TX_LO_DBM = -6. Write-up: spike_s1a_txlo.md |
| S1b | Can advertising be restricted to a single channel per slot on this NimBLE host? | Slot-tagged adverts observed on exactly one channel | 🟡 Available, but the on-air pass criterion was never checked → shipping the §4.12 item-3 power-only fallback, BEACON_CHANNEL_CONTROL = 0 |
| S2 | Does a 250 ms adv stop/reconfig/start cycle keep the anchor connectable and its scan tasks fed? | 100 consecutive proximity connects succeed during schedule; scan-cache staleness unchanged | ✅ Done |
| S3 | LIS3DH burst thresholds: STILL/FIDGET/LOCOMOTION confusion on ≥2 real wrists | STILL false-LOCOMOTION < 20%, LOCOMOTION false-STILL ≈ 0 | ✅ Done — classes separate on burst variance alone (§5.4.5) |
| S4 (pre-P4) | Reversed-link CSI: (a) grep the anchor's target IDF-5.x sdkconfig for CONFIG_ESP_WIFI_CSI_ENABLED; if absent, the route is Arduino-as-IDF-component with an owned sdkconfig; (b) anchor CSI RX + BLE + STA stable 60 min on IDF 5.x; (c) watch ESP-NOW TX burst beside NimBLE on stock 4.4.7 |
Flat heap, no WDT/panic, tagged CSI frames end-to-end | ❌ Not run |
| S5 (pre-P4) | Anchor APSTA + FTM responder + BLE under 4.4.7: stable, and does FTM range within ±2 m indoors? | 60 min stability + ranging sanity | ❌ Not run |
| S6 (pre-P5) | Distributions, not feasibility. Log full vector + per-channel RSSI + motion at ~1 Hz, ≥3 min per condition, ≥2 wrists × ≥2 homes, over engine spec §13.7 conditions (a)–(g). Do thresholds exist that separate occlusion from displacement? | ≥90% detection on (c) arm-under-torso; ≤5% false positive across (a),(b),(e),(f) | ❌ Not run — this is what blocks R3/R5 |
Phase P1 — Watch-only inference upgrade — ✅ COMPLETE¶
Files: proximity.cpp/.h (integrator, motion channel, HMM, LUTs), imu.cpp (burst read + seam calls), watch main.cpp (sleep-interval note, poll tier, prox_hmm_reset on ENFORCEMENT entry, step-8 replacement).
Work items, all landed: LUT generator (tools/gen_prox_luts.py) for logit8 / neff_gain / logsumexp_corr; the motion channel (§5.4.5); the motion-gated integrator; the two-state HMM with time-scaled transitions and connect-failure evidence via the seam; poll-tier logic and its light-sleep-cap interaction; and the coloc pipeline swap (EWMA → integrator, hysteresis machine → HMM, so Modes B/C share the decision layer).
Acceptance (host-side, proximity_engine/tests/, pure C, no hardware) — all green:
- Frozen-fade: constant deep-fade RSSI + STILL → posterior never flips; variance report re-inflates.
- Walk-approach: synthetic path-loss trace, σ = 5 dB fading + LOCOMOTION → flips within 3 ticks of crossing, zero flips during still segments.
- Teleport-rejection: 20 dB step with STILL throughout → no flip for ≥ 1/PFLIP_STILL tick-seconds equivalent.
- IMU-stale: stale IMU ⇒ behaviour equals a PFLIP_MOVE-only HMM (≈ v0.8 with smoothing).
- On hardware: shadow logging, then the PROX_V2_AUTHORITATIVE flip. Both done.
Phase P2 — Beacon schedule + feature pipeline — 🟡 IN PROGRESS¶
Files: anchor main.cpp (schedule timer + platform seam, §4.12), proximity.cpp/.h (Minor encode/decode, window feature extraction, vector v2 build/parse, prox_compute_score2, side table + away gate, distributions), watch main.cpp (observation window, trailer assembly), app (vector/score/calibration parser — MOBILE_APP_SPEC.md batch).
Order within P2: (1) ✅ anchor schedule + Minor — harmless to v0.8 watches, which ignore Minor; (2) ✅ watch window + slot attribution + local feature extraction, logged only; (3) ❌ vector v2 + score2 + side table, flashed as the lockstep batch; (4) ✅ calibration two-phase + away training (shipped as calibration-v2).
Remaining work is step 3, and it is the one that needs the app to move in the same flash. Reconcile the score characteristic to 4 bytes at the same time (§4.4).
Acceptance:
- Slot attribution: at a fixed near position the three channels agree within ~6 dB; across the room, spread visibly grows. (Blocked while BEACON_CHANNEL_CONTROL = 0.)
- PDR monotonicity: median PDR over 20 windows ≥0.9 at 2 ft, ≤0.3 at 10 ft. ✅ Exceeded — 97% inside / 0% at the edge.
- Wire: round-trip fuzz test of vector v2 encode/parse (host-side); anchor rejects bad version bytes.
- End-to-end: two-phase calibration populates both distributions and prox_compute_score2 separates the calibration positions by ≥40 score points at neff ≥ 8.
- Regression: with BEACON_SCHEDULE_ENABLE = 0, byte-identical behaviour to P1.
Phase P3 — Mode C sharpening + coupling detector — ❌ not started¶
Per-channel δ with epoch offset; coupling detector (CPL_*) added as a coloc factor; PROX_FLAG_TXLO_MISCAL surfacing. Acceptance: dorm-geometry bench test — phone parked 10 ft, watch at desk: zero false NEAR over 2 h including a deliberate stationary-in-fade placement; phone picked up and pocketed while walking: NEAR within 2 ticks.
Phase P4 — Reversed-link CSI + FTM — ❌ do not pre-build (S4/S5 gate)¶
CSI (reversed link; engine spec §3.5). Prerequisite: the anchor's pioarduino/IDF 5.x migration (§10.1 — its own branch, first) built with CONFIG_ESP_WIFI_CSI_ENABLED=y. Then:
- Watch (stock 4.4.7, TX only): implement
prox_platform_espnow_ping(channel, CSI_PING_FRAMES); call it at the start of each observation window when the target anchor's WiFi channel is known. Frames carry the watch MAC + a nonce. Channel source: same-AP association when available, else the anchor's WiFi Status channel byte. - Anchor: register the CSI callback; filter on the querying watch's MAC; reduce each frame in-callback to $(\mu_{sc}, \text{FS}, \text{notch})$ in fixed point (α-max-β-min amplitude, no sqrt, raw CSI never buffered); feed
prox_ingest_csi(). Two new LR terms inprox_compute_score2with §4.10.8-style trained distributions. Each term abstains when no tagged frames arrived. - Wire (P4 lockstep batch): WiFi Status
…000Eappends[1 byte: wifi_channel](1–13; 0 = not connected) afterschedule_crc. No proximity-vector change — the features are anchor-side. - Acceptance: with the beacon schedule disabled (isolating CSI), score separation between the two calibration positions improves measurably over the PDR-off/spread-off baseline; anchor heap flat over 24 h of query load; a watch with no same-channel path degrades silently.
Why CSI is headroom rather than a solution: a single 20 MHz channel spans only ~1–5 coherence bandwidths, so its subcarriers are heavily correlated. Its unique contribution is fine channel shape, not extra independent draws.
FTM: trigger-gated ambiguity-dwell tie-breaker via prox_ingest_ftm(); anchor APSTA FTM responder; gated entirely on S5. Wire TBD at that point.
Phase P5 — Occlusion & tamper resistance — 🟡 R1/R2/R4 shipped; R3/R5 S6-gated¶
Read engine spec §13 in full before starting. §13.0's asymmetry rule — AWAY is forgeable, NEAR is not, so far must always cost more evidence than near — governs every decision in this phase.
Order within P5, deliberately front-loading the parts that need no S6 data and no wire change:
- ✅ R1 — starved-vector abstention (anchor): the score-50 branch is gone; returns
PROX_STARVED_SCORE(128) withneff = 0andPROX_FLAG_LOW_DEVICE_COUNT. Score-200 own-anchor branch retained — asymmetric on purpose. - ✅ R4 — connect-failure motion witness (watch): contributes
LL_CONNFAIL_AWAY_Q8only with a LOCOMOTION witness since the last successful connect; otherwise exactly 0, not down-weighted. - ✅ R2 — offset-invariant Signal B (anchor): median-offset $\hat\delta$ over present fingerprint-active devices; shape term evaluated at $x_i - \hat\delta$; censored devices imputed at $\mu_i + \hat\delta$ rather than
PROX_MISSING_RSSI_DBM; absolute level re-admitted as a separate capped term that abstains under OCCLUDED. - ❌ R3 — occlusion classifier (watch): reference-vector lifecycle per §13.5, the four statistics (δ, MAD, C, F) in integer histogram form, log-LR combination with latch hysteresis.
- ❌ R5 — evidence suppression (watch): under OCCLUDED force
neff = 0, abstain the level term, suppress connect-failure, let the transition prior continue. Suppress, never invert — inverting creates the mirror exploit. - ❌ Wire + event surfacing (§6.3.1 v3), and the §5.4.6 product decision resolved by a person before ship.
Acceptance (host-side):
- Blanket-attenuation invariance: take a recorded near vector; subtract a uniform 20 dB from every entry and censor everything below −95 dBm. The score must not cross to the far side of the anchor's cutoff, and (with R3) OCCLUDED must assert.
- Displacement must not trip: replay a recorded genuine walk-to-next-room trace — OCCLUDED stays clear; assert on MAD and C individually so a regression names which statistic drifted.
- Starvation abstains: a vector trimmed below PROX_MIN_DEVICE_COUNT returns 128 / neff = 0, and the resulting HMM emission is exactly 0. The own-anchor-strong case still returns 200.
- Connect-failure without a motion witness contributes exactly 0, and with one contributes LL_CONNFAIL_AWAY_Q8 — a direct regression test on the L2 exploit.
- Signal B affine invariance: a fingerprint-scored vector shifted by a uniform −15 dB changes score by less than the emission dead zone.
- Stale-reference safety: a legitimately relocated user whose reference is not re-set must not read as permanently occluded.
- Regression: with OCC_ENABLE = 0, byte-identical behaviour to P4.
Acceptance (hardware): the S6 corpus replayed through the shipped classifier at its final thresholds, meeting S6's own pass criterion; plus one full night of shadow logging on a real sleeper — a compliant user who rolls onto their watch must not generate a false AWAY, which is the outcome this phase most needs to be right about.
Cross-cutting rules for the agent¶
proximity.cppmodularity is inviolate. No HMM/integrator/feature code outside the shared module; firmware files implement only the documented seams.- No FPU on hot paths. Any new
log/exp/sqrtat tick rate is a defect — use the LUTs. - Fail-open bias preserved. Every new abstention/unknown path must resolve toward the v0.8 criterion-satisfying behaviour; grep-test that AMBIGUOUS handling call sites are unchanged.
- Shadow-then-flip. Each phase runs its new decision path logged and non-authoritative on hardware before its authority flag flips.
- Parity table + change log updated at each phase landing, per §0's rules; P2's app-side changes are flagged lockstep in
MOBILE_APP_SPEC.md§0. - Capture, don't conclude. When you add an engine entry point, add a
CAP_*record for it (engine README §9) — replay completeness is by construction, andCAP_*ids are append-only.
End of specification v0.10