Skip to content

Impulse Firmware Specification — nRF54LM20A / Zephyr

Version: 0.23 (Draft — the anchor half of §5.5.1 is proven on v5 and the status ring names each enforcement state (v0.23); the watch's §1.6.1 escalation latch and the §4.5 self-blinding bypass are fixed (v0.22, host-tested); the port exists, boots on Board V1, two Board V1s range against each other, a real Sunrise Lock enforces end to end on hardware, and the §5.5.1 removal chain now runs end to end on v5: IMU stillness → BLE down → WiFi up → a real WATCH_REMOVED on the wire. BLE and WiFi do not coexist and are time-divided in firmware (§1.6), and §1.6.2's anchor fallback is verified on hardware. The v5 respin exists and ranges; the product firmware still defaults to Board V1 — §2) Supersedes, for the nRF build: firmware_spec_v2.md §1, §4.10, §4.12, §5.4.1, §5.4.5, §5.4.6, §5.5.2, §6.3, §7.1, §8, §10-A Inherits, unchanged: everything else in firmware_spec_v2.md — see §0.2 Companion: MOBILE_APP_SPEC.md (the app side of the GATT contract; §4.8 here is lockstep with it) Open issues: Concepts/NRF Redesign.md — everything this document had to assume, decide, or leave open, with what would settle each. Read it before acting on any number below.

Status: the port exists and runs on Board V1. Between 2026-08-29 and 2026-08-30 this document stopped being a plan. The BM20C was brought up bare-metal over SWD (2026-08-29), an NCS application was written against NCS v3.4.0 (overnight 2026-08-30), and on 2026-08-30 it booted on Board V1: Zephyr drivers drive the real pins, phone_sim exercises the GATT surface unmodified in both roles, a schedule survives a reboot, and the §9 integrity gate quarantined a real loosening over a real wire. Channel sounding is measured, not assumed — Board V1 ↔ nRF54L15-DK, and against a line-of-sight ground-truth sweep the ifft estimator is correctly scaled (slope 0.993) and accurate to ±0.20 m after one constant offset. The application lives in ImpulseFirmware/nrf_firmware/, which since 2026-09-12 is a git repo with its own remote (ImpulseFirmwareV2) — it is no longer the unbacked-up single directory earlier versions of this banner warned about.

What that does not mean. Read §0.4 row by row rather than reading this banner as "done". Chunk J (hard override) still has an undecided mechanism. Chunk I (WiFi) is no longer unbuilt — as of 2026-09-11 both roles associate on a real WM02C and the §5.5.1 WATCH_REMOVED path is written, with the watch half decoded off the wire (v0.13). The product loop now closes: two Board V1s range against each other, and a real burst drives a real cond_met. What are now the sharpest open items are that the initiator ranges whatever matches the iBeacon prefix rather than the anchor event.anchorId names — wrong anchor, with two in a house — that the anchor half of WATCH_REMOVED has never been exercised, that charger_detect never asserts on this board (§2), so charge state has no input and the fallback inference is unwritten, and that the V1 anchor has no cell fitted at all, so §7's ≥90 min holdover is not merely unmeasured but currently unmet. Every number in §11 that is not a wire format or a pin is still a starting value with its reasoning attached, and the CS calibration constant is specific to one board pair. Running list of everything unverifiable without hardware: nrf_firmware/HARDWARE_TEST_PLAN.md. Reasoning: docs/agent-notes.md, 2026-08-29 and 2026-08-30. CS data and analysis: ImpulseFirmware/nrf_cs_test/RESULTS.md and docs/hardware-notes/cs_tuning_plan.md.

firmware_spec_v2.md remains the complete and accurate reference for the ESP32-C3 firmware, which is still the only firmware that exists.


0. What this document is, and what it replaces

0.1 Scope

Board V1 replaces the ESP32-C3 with Fanstel BM20C (nRF54LM20A — Bluetooth radio and host MCU) and WM02E/WM02C (WiFi over QSPI). That changes the MCU family, the RTOS, the build system, the debug transport, the radio stack, and — because the nRF54LM20A supports Bluetooth Channel Sounding — deletes the entire RF-fingerprint proximity engine.

It changes almost nothing about what Impulse does. Schedules, enforcement profiles, commitment integrity, the wire formats, and the app-facing GATT surface are MCU-independent and survive intact. This document is deliberately thin on those and points at v2 instead, because two copies of the schedule wire format that drift apart is the exact failure this workspace keeps hitting.

One PCB, two roles. The watch and the anchor are now the same board. The anchor is that board in a different case with a servo soldered to the U1 pads (signal on P1.02 — corrected in v0.8); the watch is the same board on a strap. This is why §2's pin map carries a servo signal on a wearable. It also means launch-plan.md §5's FCC scoping — "one PCB, two host configurations, two exposure categories" — is now literally true rather than aspirational.

0.2 Inherited from firmware_spec_v2.md, unchanged

These sections are normative for the nRF build exactly as written. Do not restate them here; do not fork them. If one needs to change for nRF, it gets a delta section in this document and a pointer added there.

v2 section Covers Why it survives the port
§3.1–§3.3 Enumerations, Event struct, AnchorRecord Pure data. No platform surface.
§4.2 Anchor identity, first boot, factory reset NVS-shaped, not NVS-specific — see §8 for the storage substitution.
§4.3 BLE advertising, iBeacon, Major 0x4A0F filter Advertising payloads are radio-independent.
§4.4 Anchor GATT service Minus the proximity characteristics — see §4.8.
§4.5–§4.9 WiFi operation, UDP commands, schedule management, beeping, servo Behaviour, not platform.
§4.11 Phone docking detection Still RSSI-based. See §5.
§5.1 Watch state machine Deltas only, in §6.1.
§5.2 Worn detection Same ITR8307 part, same lit-minus-ambient method. New pins (§2), new ADC API.
§5.3 Schedule system, recurrence, midnight recalc Unchanged, but read §3 on clock accuracy.
§5.4.2–§5.4.4 Enforcement profiles, escalation, donning grace Unchanged.
§5.5.1, §5.5.3 Anchor comms, watch-side anchor WiFi repair Unchanged.
§5.6 Watch GATT service Minus the calibration characteristic — see §4.8.
§5.7.1–§5.7.3 LED status ring semantics Unchanged. §5.7.4 changes — see §6.2.
§6.1, §6.2 UDP command packet, schedule transfer protocol Frozen. Byte-identical, including SCHEDULE_FORMAT_VERSION = 0x02.
§9 (all) Commitment integrity — the watch as root of trust The most important chapter in the project and entirely platform-independent. Port it behaviour-for-behaviour.

The rule for this table: if you are about to write nRF code that contradicts an inherited section, you have found either a porting bug or a spec bug. Stop and resolve which, rather than diverging quietly.

0.3 Deleted

Not deprecated — deleted. No compatibility path, no runtime fallback, no dead code left behind.

What Where it lived Why it goes
The entire proximity engine proximity_engine/ (~4,650 lines), v2 §4.10, §5.4.1 steps 1–8, §10-A Channel sounding measures distance directly. The engine existed to infer distance from RSSI and RF fingerprints, and every part of it — the motion-conditioned HMM, the draw-gated integrator, N_eff, log-LR fusion, the LUTs — is machinery for making that inference trustworthy. With a real measurement, it is all answer to a question nobody is asking any more.
IMU motion channel for proximity v2 §5.4.5 Existed to gate fading-draw accounting. The IMU stays as a wake source (v2 §8.4) and for worn/still detection; only its proximity role goes.
Beacon schedule / stepped-power PDR v2 §4.3 beacon slots, §4.12 A workaround for RSSI's unreliability.
RF fingerprints and calibration-v2 v2 §4.10.7, CALIBRATION_V2_DESIGN.md Per-anchor demonstrated cutoffs were needed because the score had no physical units. A distance in metres does not need calibrating against a demonstrated zone. §4.7 replaces it with something far smaller.
Proximity vector / score / fingerprint GATT characteristics v2 §4.4, §6.3 Nothing produces or consumes them. Lockstep with the app — §4.8.
WiFi scanning for proximity v2 §4.10.1 WiFi itself stays, for the getOnWifi/getOffWifi criteria and anchor HTTP. Only its use as a fingerprint coordinate goes — which also retires the esp_wifi_scan_start() leak and the ANCHOR_PROX_WIFI_MAX_SCANS cap that worked around it.

RSSI survives in exactly one place: phone docking (§5). It is a presence question at a fixed short range against a device that cannot do channel sounding, which is the one job RSSI was ever adequate for.

What to salvage before deleting. The engine's code goes; two things in it should not. (1) The threat-model asymmetry — concluding AWAY requires more evidence than concluding NEAR — is a security property of an adversarial device, not an artefact of RSSI being noisy. It is carried into §4.4. (2) The test corpus (proximity_engine/tests/, corpus/) encodes failure modes, not algorithms. Keep the ones that describe situations — walk-in, teleport rejection, frozen evidence — and re-point them at the CS backend. See docs/agent-notes.md, 2026-08-08.

0.4 Parity

Read this table, not the banner. ✅ means exercised on Board V1 and observed, not merely written. Chunks with no code left are marked 📋; a row that mixes both says so in its note.

Area Watch Anchor Notes
NCS project skeleton, boots, blinks (§10 chunk A) ✅ ✅ Boots on Board V1, 2026-08-30. NCS v3.4.0 pinned in west.yml; board target impulse_v1/nrf54lm20a/cpuapp defined in-tree. Both role images build (watch 415 KB / 30% RAM, anchor 241 KB / 21% RAM) and both have run. There is no UART — console and shell are over RTT (rtt.sh), and three RTT traps each present as a dead board (agent-notes.md, 2026-08-30)
Pin map + drivers: LED ring, buzzer, motor, IMU, IR, buttons (§2, chunk B) ✅ ✅ Zephyr drivers written and exercised 2026-08-30. Ring (SPIM30), buzzer (needs NRF_GPIO_DRIVE_H0H1 — it sags below its own input threshold at standard drive), motor, wake button, IMU (enumerates over SPI), servo, and ADC. The SAADC AIN mapping was found empirically — AIN0 battery, AIN1 IR, and this part has only 8 inputs; it is in no datasheet we hold. Battery reports over BLE. Worn-detect threshold still unset from a real wrist — raised 150 → 200 mV with a 140 mV release on 2026-09-10, but from the no-wrist side only, because the enclosure covers the IR window. On v5 the IR pair is abandoned and worn detect is IMU stillness instead (§6.4, built 2026-10-13): the receiver moved pin AND the enclosure's crosstalk is 75–90 mV against a 200 mV threshold, so the optical path cannot work there. The source is a build-time choice defaulting per board revision, and the firmware prints which one is in use at boot. charger_detect (P1.03) never asserts — verified 2026-09-10 across a real charge event; suspected an unwetted BM20C pad, deliberately not chased, and the battery-derivative fallback is unwritten. Motor and servo pins corrected in v0.8 — they were transposed
Storage: NVS → Zephyr settings/NVS (§8, chunk C) 🟡 🟡 Round-trips on hardware 2026-08-30 — a schedule survives a reset — but only after two real defects. struct impulse_schedule (14,352 B) and struct impulse_integrity (21,192 B) both exceed the 4096-byte NVS sector, so every save silently returned -EINVAL and nothing was ever persisted; now one record per event / settle / pending entry, with stale records explicitly deleted. The elapsed base is written every 10 min, so a power cycle rewinds integrity timers by at most that. Untested: the size-mismatch guard against a corrupted record
Clock, RTC, wake scheduling (§3, chunk D) 🟡 — LFXO confirmed running, by measurement. On nRF54L the legacy CLOCK.LFCLK registers read all zero and that is not the RC fallback §3.2 warns about — the GRTC manages its own source. The authoritative build signal is CONFIG_NRF_GRTC_TIMER_SOURCE_LFXO=y; the empirical one is +34 ppm drift over 140 s against host wall clock, crystal territory, and under a second across an 8 h sleep (CLOCK_GUARD_S = 5). The wall clock now survives a reboot (2026-09-10): impulse/wall is written every 60 s and on every app time-sync, restored at boot behind a 2025-01-01 sanity floor, and treated as a lower bound — so a commitment can start late but never early. Kept in a separate key from the elapsed base, because §9.2 requires integrity timers on a monotonic basis. Wake scheduling and DORMANT_SLEEP itself are still unbuilt
BLE peripheral + GATT surface (§9, chunk E) ✅ ✅ Working on hardware 2026-08-30, both roles: phone_sim drives them unmodified — the acceptance criterion §10 sets. Watch: scan, status, time, settings, schedule push through the §9 gate, pending queue with a live countdown. Anchor: iBeacon advertising with major 0x4A0F, identity from FICR DEVICEID, settings, schedule, and the §4.9 strap lock refusing to open during a commitment while close is always accepted. calib-v2 is absent on both, confirming the §4.8 deletion. Five defects were found here that a clean build cannot show, four of them the same rule: do no real work and send no notification on a Bluetooth callback context. WiFi creds and the anchor IP table are now consumed rather than accepted and dropped (2026-09-12); LED config is still unconsumed. The Anchor IP Table characteristic was declared write-only and is now write+notify with a CCC — see §4.8 on why that is a spec clarification rather than a wire change. Concurrency with CS PASSES (2026-09-10) — the case that broke the ESP32 build (v2 §10.1). phone_sim reads the full GATT surface at MTU 498 while the CS engine holds a scan. Partly tested with ranging in progress (2026-10-22): a GATT discovery and a characteristic write on the ranging link left CS clean (rej_range=0 rej_qual=0 relay=0 miss_ifft=0, bursts every 2–3 s, distance stable). The full app-facing surface has still not been read during a procedure
Schedule + enforcement (chunk F) 🟡 🟡 Schedule model, §6.2 blob codec, §5.3 day resolution and the §5.4.3 profile table written, host-tested, and driving real pins — sampling P3 during a live normal_both window shows a ~2 s burst with motor=1 buzzer=1. Anchor beep state machine covers all three profiles but cannot be triggered remotely (no WM02C populated). Escalation measured to its floor on hardware (2026-09-10): 26.1 → 24.3 → 22.1 → 20.0 → 17.9 → 16.1 → 14.0 s, then flat at ~5.0 s for 26 cycles, matching floor_interval_ms = 5000 with a constant ~2 s burst. The criterion-dependent fail-safe is confirmed in both directions: stayNear fails closed, getAway fails open. §5.5.1 anchor escalation is now built (2026-09-12) and the anchor beep can at last be triggered remotely: credentials and the anchor IP table arrive over BLE, persist, and the watch unicasts WATCH_REMOVED (0x01) / WATCH_WORN (0x02) to each anchor in beep_anchors. Verified by decoding real 33-byte datagrams on the LAN — window-start unworn escalation, both worn transitions, and per-poll re-assertion at the 60 s cadence, which §5.5.1 requires because a single announcement was lost three ways in the ESP32 field build. The anchor half is written but has never been exercised — that board was mid-rework. And the TRIGGER is worn detection, which remains unverified because the IR window sits behind the enclosure, so the transport can be complete and still never fire. Untested: whether output stops immediately when the condition goes met. v0.22 (2026-10-25): escalation moved out of main.c into net/escalation.c under the §1.6.1 policy; the latch, the cold-open-always-WiFi bug and the self-blinding getAway bypass (§4.5) are fixed and covered by host regression tests (320 checks), and impulse csmock drives transitions on the bench. Two things break the watch→anchor JOIN that are not watch firmware, found the same night: no v5 anchor image with the buzzer compiled in has ever been built here (IMPULSE_BUZZER_DISABLED defaults to y), and the nRF anchor has no clock source — no SNTP, no time characteristic — so it resolves WATCH_REMOVED against whatever an RTT impulse time last set, and answers NOT_MINE if that has drifted out of the window. v0.23 (2026-10-25, afternoon): the anchor half is proven on v5 with a human listening — anchor-cswifi-buzz, clock set by hand, beeped on a real WATCH_REMOVED and stopped on WATCH_WORN, over UDP and then over BLE from a real watch. The silent Sunrise Lock was the anchor's clock, 9 h 03 min slow. Still open: how the anchor learns the time (a decision, not built), and a natural IMU unworn on the watch (board #2 never reached one)
Channel sounding (§4, chunk G) 🟡 initiator 🟡 reflector CS is proven to work on this silicon — Board V1 ↔ nRF54L15-DK, live distances at ~9 Hz (nrf_cs_test/RESULTS.md). The product firmware now carries a real backend adapted from Nordic's ras_initiator, behind CONFIG_IMPULSE_CS_BACKEND, plus a host-tested fusion layer. Accuracy is now measured against ground truth: over a line-of-sight sweep of 11 stations from contact to 7 m with the board rotating, ifft fits 0.993 · true + 0.97 m, R² 0.9881, residual 0.20 m sd — correctly scaled, needing one constant offset. ifft is the primary estimator; rtt is a relay-attack bound, not an accuracy instrument; phase_slope − ifft divergence is the quality signal. Now running inside the product firmware (2026-09-10) — the watch image boots with CS backend: nrf-cs-ras — but it ranges against whatever advertises RAS rather than event.anchorId — a correctness bug with two anchors in a house — and it owns its own central connection. Reflector written and running, 2026-09-10 (src/proximity/cs_reflector_nrf.c, cs_reflector.conf): the anchor boots with CS backend: nrf-cs-reflector, registers the Ranging Service and advertises 0x185B in its scan response, confirmed over the air, with the anchor's own GATT surface intact alongside it. It has now been proven to reflect, and the product loop closes (2026-09-10/11): two Board V1s range against each other — a single line-of-sight point at ~61 cm gave ifft a median of 1.32–1.36 m at 0.02–0.04 m sd over 150+ samples, and a live enforcement window produced 172 estimates in 60 s driving a real cond_met. The BM20C↔BM20C offset is materially different from the DK pair's, so IMPULSE_CS_IFFT_OFFSET_CM moved 97 → 72; the old DK value errs in the UNSAFE direction, reporting the watch ~25 cm closer than it is. The slope (0.993) is still the DK's and one point cannot separate slope from offset — a multi-station sweep is still owed. The CS link is gated on enforcement (2026-09-11): the engine parks disconnected and not scanning until a commitment needs a distance, and releases the anchor within ~5 s of the window ending. It previously held the link forever, which stopped the anchor advertising and presented for two days as a dying anchor. Accepted consequence: the first measurement of a window ABSTAINS while ~10 samples accumulate, which for stayNear fails CLOSED — do not "fix" it by reporting compliance while the buffer fills. Still open: the initiator ranges whatever matches the iBeacon manufacturer prefix, not event.anchorId — with two anchors in a house it measures the wrong one. Open items in priority order: docs/hardware-notes/cs_tuning_plan.md
Commitment integrity §9 (chunk H) 🟡 — §9.1 classifier, §9.3 diff gate, §9.4 promotion, §9.6 pass ledger, §9.7 time guard written and host-tested. §9.8 settings gating queues but does not apply
WiFi via WM02C (chunk I) 🟡 🟡 Up on BOTH boards, 2026-09-11. Built with ./build.sh <role> --cs --wifi; the sysbuild symbols fail silently if omitted (CONFIG_WIFI_NRF70 is a sysbuild symbol — the app conf cannot set it). Both associate on 2.4 GHz WPA2-PSK: watch −58 dBm, anchor −65 dBm, WiFi 4. BLE and WiFi do NOT yet coexist (corrected v0.18). 224 CS procedures ran on the watch with the link COMPLETED, and the anchor advertises its iBeacon and RAS while associated — but on 2026-09-12, with both radios active, both died within 5–13 minutes, repeatedly, while the application kept running; the same anchor rebuilt BLE-only ran 27+ min clean. The four coex wires are routed but CONFIG_NRF70_SR_COEX is off unless coex.conf is opted in, so nothing arbitrates the shared band, and enforcement testing since has used BLE-only builds. The owner's direction — WiFi and BLE mutually exclusive in time rather than PTA, with WATCH_REMOVED carried over the CS link and WiFi kept for the out-of-range case — is now a design and is built: §1.6. The watch no longer holds WiFi across a window (it associates only after a BLE write to the anchor has failed), and the anchor no longer holds WiFi for its whole uptime (it switches only inside an open window after ANCHOR_BLE_SILENCE_S of BLE silence, then duty-cycles so a returning watch can still reconnect). The coexistence failure was corroborated a second time on 2026-10-13, on v5 board #3: 4 m 40 s of both radios up, then three consecutive lost pings with nothing in the log. Now joined on hardware (2026-10-22): an anchor entered the §1.6.2 fallback, associated and took a DHCP address 3.2 s later, acted on a WATCH_REMOVED (beep start) and stopped on a WATCH_WORN — the datagram being byte-identical to what the watch firmware was observed emitting. Board #2's WM02C works once its SPI pins are resoldered, so two v5 boards now have WiFi. The one seam left is a watch whose schedule names a real anchor rather than the demo's synthetic 0xA0.. UUID, which needs a schedule push from the app. RAM was 99.46% and is now 54.4% (watch 284,424 B; anchor 268,718 B) — the overage was heaps sized for a throughput device, chiefly NRF70_RX_NUM_BUFS at its default of 48, so no respin is needed for RAM. Dropping 5 GHz saves nothing (byte-identical build); NRF_WIFI_2G_BAND is kept for propagation. Certified gain/backoff Kconfigs are a grant condition (§1.1). DHCP/IP now works and persists: v5 board #3 associated on WPA2-PSK and took 192.168.1.152 by DHCP (2026-10-13), answered ICMP, received real 33-byte §4.6 datagrams on port 5555 — dispatching WATCH_WORN and rejecting a wrong-length packet — and came back associated after a reset from credentials in NVS, which is what §5.5.1 needs so an association is not lost at the instant of an edge. Untested: reconnection after AP loss. Reasoning: agent-notes.md, 2026-09-11 (three entries)
Hard override (chunk J) 📋 — Mechanism undecided — Concepts/Safety.md
Anchor battery backup + mains-loss behaviour (§7) — 📋 Running on battery is required. Decided v0.9: mains loss is reported to the user by the app, not to the watch; the watch's behaviour is unchanged; holdover ≥ 90 min. No longer coupled to moved-anchor detection (§4.6) — that was the earlier expectation and it was wrong. On the V1 anchor the requirement is currently UNMET, not merely unmeasured (2026-09-11): it reads batt=0% with AIN0 at 38–53 mV against ~2070 mV on the watch, i.e. no usable cell is fitted. It runs on external power and resets when that glitches — one reset observed at 372 s of uptime. Holdover cannot be measured on this board until a cell is fitted

0.5 Change log

v0.23 — 2026-10-25 (afternoon) — the anchor half is proven, the anchor clock was the silent Sunrise Lock, and the ring now names each state

Owner present, SWD moved between boards. No wire-format change.

  • Anchor half of §5.5.1 proven on v5 with a human listening. Board #3 ran anchor-cswifi-buzz with its clock set. In its own window, with no watch present, it entered the §1.6.2 fallback, leased an address in about 10 s, ACKed a host-sent WATCH_REMOVED with 0x81 | 75b2aeb2… | event | 00, beeped, and stopped on WATCH_WORN (0x82 … 00) 28 s later.
  • The full join ran end to end over BLE, with the owner listening (15:13, real schedule on both boards, watch watch-cswifi-still, worn driven by wornset). Watch unworn → WATCH_REMOVED delivered over BLE within 1 ms → ring magenta → the anchor beeped. It was re-sent every 60 s. Watch worn → WATCH_WORN over BLE → beep stopped. That is the §5.5.1 seam the brief asked to prove, minus a natural IMU edge (next bullet).
  • The silent Sunrise Lock is explained: the anchor clock was 9 h 03 min slow (restored_from_flash=1). The restore is "last saved value", so time spent unpowered becomes clock error, and nothing re-syncs it (chunk F row; the clock-source decision is still open). NOT_MINE for an inactive event is now LOG_WRN; it was LOG_DBG, which hid this.
  • §6.4 false "worn": isolated one-sample spikes. On a still desk both v5 boards show single IMU samples of 421–1072 mm/s² every few minutes, against a 0–192 floor and a 400 threshold. Each one restarts the 300 s still clock, so the watch rarely reaches unworn and the 14:22 Sunrise Lock window ran as "worn, near, alarming". New log worn: still clock restarted at N s by motion X, a rate-limited worn: IMU read failed warning, and a worn: line in impulse status. The detector itself is unchanged pending a decision (Todos).
  • New §6.5: one ring look per enforcement state. escalation.last_delivered_cmd and impulse_udp_last_ack_result() were added to feed it. Seen on hardware: yellow blinking (grace at window open), red solid (NEAR at 0.47 m), magenta solid (anchor told).
  • Fixed: the donning grace's end now forces a poll. condition_met held the grace's "met" until the next poll, which showed as 3 s of green ring and no alarm beside the anchor of a getAway window.

v0.22 — 2026-10-25 — the escalation latch and the self-blinding bypass are fixed, and the watch duty-cycles its radios

Overnight, unattended, one SWD wire on the watch and none on the anchor. No wire-format change, so nothing is lockstep. Four behaviour changes and one build option, all on the watch; the pure-logic half of each is host-tested (320 checks, up from 258, each new regression test confirmed to fail with its fix reverted). What ran on hardware and what did not is in the bullets — read them before reading this as verified.

  • §1.6.1 rewritten: the watch has a radio policy. It used to decide BLE-or-WiFi from the radios' current state on every call, including states its own previous call had created — a one-way latch that kept WiFi up and ranging off until the 10-minute give-up. "No BLE link" is now concluded only after BLE has held the air for ESCALATION_BLE_PATIENCE_S (20 s) without one; WiFi dwells are bounded by ESCALATION_WIFI_DWELL_S (60 s) and end early on a §4.6.1 ACK; a pending command is re-sent every 5 s while WiFi holds an address, rather than once per 60–180 s poll. Fixes three of the overnight brief's bugs at once: the latch, the cold window open that could never take the BLE path, and the watch holding WiFi for a whole window (the §1.6.1/§1.6.2 asymmetry). src/net/radio_policy.c + new src/net/escalation.c.
  • §4.5 gains a rule: self-inflicted blindness is not evidence. While the watch has quiesced BLE for WiFi, and 15 s after, no abstention is charged and no new link loss is detected. This closes a real getAway bypass — observed beside the anchor as abstain_run=6 failsafe=1 cond_met=1, ring green all window. The fail-safe direction and the 2:1 dwell ratio are unchanged.
  • §1.6 gains a guard. net_wifi.c will not associate while BLE is on the air (advertising, app link, or the CS ACL link, which the engine is now woken to drop at once instead of up to 5 s later), and logs an invariant violation if it ever finds them together. When they overlapped, the nRF7002 reported "wrong password" with a correct passphrase (agent-notes.md, 2026-10-25).
  • DHCP was being restarted faster than it could complete. net_wifi.c restarted it every 8 s, inside Zephyr's random RFC 2131 initial delay of up to 10 s, so each restart discarded an exchange that had not sent its DISCOVER yet: ~27 s and four attempts per address after a 1.5 s association. Now NET_DHCPV4_INITIAL_DELAY_MAX=2 and a 20 s restart interval.
  • New build option CONFIG_IMPULSE_MOTOR_DISABLED (default n, build.sh --no-motor), the twin of IMPULSE_BUZZER_DISABLED: motor output compiled out, pin left high-Z, note_shaking() compiled out with it so §6.4's mask is not fed phantom vibration. Exists because the motor shook the SWD connector loose twice on 2026-10-24. Must be off in a release image.
  • New bench command impulse csmock <cm|lost|off> (bring-up shell only, never BLE): replaces the link observation the enforcement layer reads, so getAway/stayNear transitions and the lost-link rule can be driven without moving boards. Honours the radio — no bursts unless enforcing on BLE.
  • impulse status now prints the radio state (wifi_on_air, blind_by_choice, link_ready), the escalation counters (pending, delivered over BLE / UDP, WiFi entries, give-up) and the dwell runs. impulse csmock block holds ranging off so the WiFi path can be forced with an anchor next to the watch.
  • §4.6.1: an ACK now has to come from the anchor it is for. The watch matched an ACK on flag + event UUID only, so any other anchor answering a broadcast — or one answering NOT_MINE — stopped retries to an anchor that had never heard it. Seen on hardware when a host-side mock anchor's ACK was taken as delivery to the real one. Other anchors' addresses are still learned; a NOT_MINE from the right anchor is logged as a warning, since retrying cannot change it.
  • DHCP, measured: in three of three escalations on board #2 the first exchange after association never completed and the restart got an address in ~0.5 s. The restart interval is now 5 s; edge-to-first-datagram fell from 20–27 s to 8.5 s (association 1.5 s), and removal edge to ACK was 12.7 s.

Verified on hardware, v5 board #2 as watch (watch-cswifi-still, motor and buzzer compiled out), against v5 board #3 as anchor (untouched firmware, reached only over BLE), with a real schedule naming the real anchor 75b2aeb2… pushed by phone_sim to both: - Cold open, unworn: window start → escalation 0x01: delivered over BLE to D0:30:2F:4C:7E:C5 1.9 s later, no WiFi. Before the fix this case could only fall to WiFi. - The bypass is closed: unworn beside the anchor, getAway formed NEAR (have=1 near_run=26 abstain_run=0) and stayed cond_met=0 — the case that read abstain_run=6 failsafe=1 cond_met=1 the night before. - The latch is gone: ranging blocked → WiFi; 60 s dwell / 20 s re-listen cycles, seven times; block released mid-dwell → dwell finished, BLE back, link in 1.1 s, REMOVED over BLE 2 s later, NEAR re-formed 5 s after that with abstain_run still 0. - WATCH_WORN over BLE on the edge with a 60 s donning grace; §5.5.1 re-assertion every 60 s while unworn and none while worn; getAway and stayNear transitions driven by csmock in both directions (AWAY after 4 bursts, NEAR after 2), lost-link silence within 1.2 s and AWAY at 10 s; CS releases the anchor 70 ms after the window ends (was up to 5 s); no DEFERRED and no INVARIANT VIOLATED line in ~45 min of logs. - WiFi send → §4.6.1 ACK from a host mock anchor (tools/mock_anchor.py) → WiFi released 100 ms later.

NOT verified, and why — the anchor half of the join. No SWD on the anchor all night. The real anchor never answered a single datagram over ~10 minutes of watch WiFi dwells, and kept advertising continuously 8 minutes into BLE silence — which it would only do if it did not believe a window naming it was open, despite accepting the schedule push (0x01) and tz −240. The likely cause is that the nRF anchor has no clock source (no SNTP; no time characteristic; HTTP schedule push is dead with NET_TCP=n): it resolves windows against whatever an RTT impulse time last set, restored from flash as a lower bound across each power glitch. If so, its BLE WATCH_REMOVEDs were answered NOT_MINE too. Separately, no v5 anchor image with the buzzer compiled in has ever been built here. Either alone makes a Sunrise Lock silent. Both need a human: Todos.md.

v0.21 — 2026-10-22 — the removal chain is proven on hardware end to end, and the watch stops believing its own vibration

Two additive wire changes, neither lockstep with the app: §4.6.1 adds an anchor→watch reply to the §4.6 UDP command, and §4.4.1 adds one read-only anchor characteristic (whose UUID collides with a deleted one — see §4.4.1). One behaviour change (§6.4), a new bench command, and the first hardware verification of §1.6.2. (This line read "No wire-format change" until /parselogs corrected it on 2026-10-23, against net_udp.c and ble_anchor.c.)

  • New shell command impulse wipe, exposing impulse_storage_factory_reset() — which had been written and reachable from nowhere. It clears the schedule, §9 integrity records, pending queue, elapsed base and timezone while keeping WiFi credentials and learned anchor IPs. Without it, a first schedule push from the app onto a board that has ever held one is quarantined by §9's self-binding delay, answers 0x03, and is indistinguishable from a rejected write with no way forward short of erasing the chip.
  • New §4.6.1: §4.6 commands are acknowledged. The anchor replies 34 bytes carrying its own UUID, the echoed event UUID and an applied/not-mine result; the watch retries up to 3× at 250 ms and stops on the first matching ack. This closes the real hole — WATCH_WORN was one-shot over a transport losing half its broadcasts — and learns the anchor's current address from the reply's source, which fixes staleness without mDNS. v2 §5.5.1's mDNS rung is deliberately still not ported: it removes no dependency (both paths need the anchor on WiFi) and rides the same lossy multicast. Anchor half verified on hardware across all five cases, including a broadcast answered by unicast.
  • New §4.4.1: the anchor states its own last known IPv4 over a new read-only characteristic …4a0f001c. It is the last known address, not the live one, because §1.6.2 guarantees WiFi is down whenever a BLE read can arrive. Anchor side verified on hardware: it saves on acquisition, the value survives a reset, and reacquiring the same address correctly does not rewrite storage. The watch-side read is NOT implemented — it was attempted and withdrawn because it never ran and its ref-counted form leaked a bt_conn reference per ranging connection; §4.6.1's ack supersedes it anyway. §4.4.1 records what to do differently if it is revisited.
  • §6.4 gains alarm masking. The vibration motor shares a PCB with the accelerometer, so an alarm was being read as the wearer moving — which meant a watch that was alarming could never be declared off the wrist, the one moment it most needs to be. IMU samples taken while the motor or buzzer drives, and afterwards until the accelerometer reports the board is still again, are now discounted: baseline dropped, still clock frozen, capped at SETTLE_MAX_MS. The freeze, as against the resync path's running clock, is what stops a long alarm manufacturing a removal; §6.4 carries the table. A first version used a fixed 2 s tail and was measured too short — a ~100 ms motor pulse still reset a 13 s still clock — so the ringdown is now ended by the sensor rather than by a constant. Verified on v5 board #3: a motor command that previously reset the clock to 0.989 s now carries 143.966 s through to 155.970 s. Host-covered (258 checks).
  • §5.5.1 now runs end to end on real hardware (v5 board #3, watch-cswifi-buzz): 300 s of stillness → worn -> 0 (imu-stillness) → BLE off the air → association on WPA2-PSK → 192.168.1.152 → a real 33-byte 0x01 WATCH_REMOVED decoded off the wire on a host, re-asserting every 60 s. The ~3 s of deferred — no association yet between the edge and the first datagram is exactly what the per-poll re-assertion exists to cover.
  • §1.6.2's anchor fallback is verified on hardware (board #2, anchor-cswifi): 90 s of BLE silence inside an open window → BLE off, listening on WiFi → a 120.02 s dwell → WiFi released — BLE back on the air with advertising resumed (anchor advertising (iBeacon major 0x4A0F)) → back to WiFi after 30.01 s of re-listen. It also held up with a dead RPU: the policy is purely time-based and never consults association state, so a WiFi chip that never answers costs dwell time and nothing else — the heartbeat and BLE both survived.
  • The chain is now joined on hardware. Board #2's WM02C came alive once its SPI pins were resoldered, so an anchor could be run with working WiFi: radio: no BLE from a watch for 90s → association → 192.168.1.65 3.2 s after the decision → beep start (profile 1) on a WATCH_REMOVED, then cmd: WATCH_WORN — stopping alarm / beep stop. The 33 bytes were byte-identical to what the watch firmware was observed emitting, replayed from a host, and every §4.6 guard was evaluated for real (both roles' demos use the same event UUID, and the anchor's demo names itself in beepAnchors). Association is comfortably faster than the 120 s dwell. Unicast does not share the broadcast path's loss — 3/3 both ways, against roughly half for 255.255.255.255 — which is a reason to provision anchor IPs rather than lean on the broadcast attempt.
  • The one seam left is a watch whose schedule names a real anchor: the watch's demo puts the synthetic 0xA0.. UUID in beepAnchors, which a real anchor rightly rejects. Closing it needs a schedule pushed by the app (or phone_sim, which needs BlueZ), or a bench command to set the beepAnchor UUID. Everything either side of that seam is proven on hardware.
  • Measured: broadcast UDP loss is heavy on this LAN — about half of the 255.255.255.255 fallback datagrams never arrive. WATCH_REMOVED re-asserts every poll and shrugs it off; WATCH_WORN is sent once on the edge and was observed lost 2 of 4 times, which leaves the alarm sounding after the watch is back on. §6.4 calls a spurious removal self-correcting, and it only is if the stop message arrives. Fix is pending and cheap: the command is idempotent, so send the worn edge a few times.

v0.20 — 2026-10-13 — WiFi is confined to the removal scenario on both roles, and v5 gets an IMU worn detector

Two behaviour changes, no wire-format change. Both are built, both roles build for both revisions, and the pure-logic halves of both are covered by the host suite (244 checks).

  • New §1.6, radio arbitration. BLE and WiFi are now mutually exclusive in time, enforced by the firmware rather than by PTA (CONFIG_NRF70_SR_COEX stays off). The watch no longer brings WiFi up at window start and hold it for the window — it associates only inside escalate_to_anchors(), after the BLE write to the anchor has failed, which is the only case WiFi exists for. The anchor no longer holds WiFi for its entire uptime; it stays on BLE and switches only when a window naming it is open and the watch's BLE link has been silent for ANCHOR_BLE_SILENCE_S, then duty-cycles WiFi against BLE so a watch that returns can still reconnect and say WATCH_WORN. §1.6.1 and §1.6.2 carry the three clauses that are load-bearing and why each one is there. The coexistence constraint itself was corroborated a second time the same day on v5 board #3 — 4 m 40 s with both radios up, then three consecutive lost pings with no error, no disconnect and the heartbeat still printing.
  • New §6.4, IMU stillness worn detection. On v5 the §5.2 IR pair cannot work — the receiver moved from P1.29 to P1.16 so ADC_CH_IR names an unrelated input, and the enclosure reflects the emitter back at 75–90 mV against a 200 mV threshold. The v5 worn signal is now the accelerometer: no movement for WORN_IMU_STILL_S (300 s) means nobody is wearing it, and any movement above WORN_IMU_MOTION_MMS2 (400) restores worn on the next sample. The threshold is set from a measurement of the sensor's own noise floor on board #3 — L1 jitter of 114–153 milli-m/s² on a motionless desk, which is an order of magnitude larger than it looks. The source is a build-time choice (IMPULSE_WORN_SOURCE_IR / _IMU) defaulting to IMU on impulse_v5 and IR on impulse_v1, so the two in-service v4 boards keep the optical detector. This retires the v0.19 note that WATCH_REMOVED cannot fire on a v5 build — its trigger now exists there. §6.4 is explicit that the IMU detector is strictly weaker than IR in one direction (it catches a nightstand, not a watch carried unworn) and should be replaced when the optics are fixed.
  • No ✅ in §0.4 moved. Both changes are 🟡: the stillness detector's transition has been exercised on hardware but the §1.6 anchor fallback has never delivered a real WATCH_REMOVED end to end, and nothing here has been tried against a wrist.

v0.19 — 2026-10-06 — the v5 respin exists, and six pins moved

Documentation only; no behaviour and no wire format changed. The respin drawn on 2026-09-14 (OCT-10) was built, and two v5 boards were brought up and ranged against each other on 2026-10-05 (agent-notes.md, 2026-10-05). §2 claimed to be transcribed from Impulse.kicad_sch, which is now the v5 schematic — Board V1 was archived as ImpulseNRFV4.kicad_pcb — so its own source no longer agreed with its table. §2 now says which board it describes and carries a v5 delta table, re-verified for this change by re-exporting the netlist and reading module U7's pads, and against the new board definition boards/impulse/impulse_v5/ (uncommitted in nrf_firmware at the time of writing).

  • §2's main table is left as Board V1, deliberately. The product firmware still builds for impulse_v1 by default (build.sh --board v1), and both V1s still run it. Overwriting V1's map would make the spec wrong for the boards the firmware targets.
  • Two v5 defects are not pin-map rows and are recorded here so they are not lost: the SAADC channels (CONFIG_IMPULSE_ADC_CH_BATTERY = AIN0, CONFIG_IMPULSE_ADC_CH_IR = AIN1) were swept on V1 and read unrelated pins on v5, so on v5 battery percentage is wrong and worn detect never asserts — §5.5.1 WATCH_REMOVED cannot fire on a v5 build. And IMPULSE_CS_IFFT_OFFSET_CM (72, one V1 point) does not transfer: v5 reads 0.25–0.55 m long at a known 49 cm. Neither constant was moved; both need a v5 measurement first.
  • No §0.4 marker moved. Every ✅ in that table was observed on Board V1, and still is. Nothing has yet been exercised on v5 except channel sounding in the nrf_cs_test rig, where the link held 604/604 s at 0 dBm with a 99.9% procedure yield.

v0.18 — 2026-09-14 — correction: BLE and WiFi do not yet coexist

Documentation only; no behaviour and no wire format changed. v0.13 recorded "BLE and WiFi coexist" from 224 CS procedures completing with WiFi associated. The next day's longer runs disproved it: with both radios active both went silent within 5–13 minutes, repeatedly, while a BLE-only build of the same anchor ran 27+ minutes clean (agent-notes.md, 2026-09-12, "BLE and WiFi cannot both run on Board V1"). §0.4's chunk I row now says so, and drops "both radios up for hours" from its untested list — they do not stay up for minutes. The header version is also brought level with this change log; v0.14–v0.17 were logged without bumping it.

  • The chunk I marker stays 🟡: association, the RAM result and the WATCH_REMOVED transport all still stand. What is unresolved is whether the radios can share a board at all without arbitration.
  • The owner's direction — mutually exclusive in time, WATCH_REMOVED over the CS link — is recorded as a direction. §5.5.1 still specifies a UDP transport, and it is not changed here.

v0.17 — 2026-09-13 — dwell is counted in bursts, not polls

At 01:01 a watch beside its anchor produced 17 bursts at 0–15 cm and never formed a NEAR verdict: NEAR_DWELL needed two consecutive polls 60 s apart, and the link dropped every ~25 s, so getAway sat in its fail-open (flashing green) for the whole window. Owner approved the change.

  • Every completed burst is ingested as it arrives and the criterion is settled on the same main-loop pass (impulse_enforcement_burst_update). NEAR_DWELL = 2 and AWAY_DWELL = 4 are now counted in bursts (~3.6 s each on this pair); the 2:1 asymmetry is unchanged.
  • The poll no longer measures when the backend reports link telemetry. It charges one abstention for an interval that produced no burst (impulse_enforcement_poll_abstain_if_idle), so a silent radio still reaches the §4.5 fail-safe. Backends without telemetry keep the poll path.
  • Consequence: walking back in after AWAY re-alarms in two bursts rather than two 180 s polls.
  • No wire format changed. Host tests: test_burst_dwell.

v0.16 — 2026-09-13 — a lost ranging link is AWAY after 10 s, with a 2 s silencing grace

Product decision by the owner, taken over a stated objection (§4.6), after the 2026-09-12/13 Sunrise Lock tests showed getAway essentially cannot register a user leaving: channel sounding needs the link, so walking out destroys the measurement, and AWAY_DWELL counted once per 60 s poll was unreachable — replaying all three walk captures through the as-built logic concluded AWAY zero times. Behaviour changed (§4.5); no wire format changed, so nothing here is lockstep.

  • Lost link = AWAY. No channel-sounding activity for CS_LINK_LOST_DETECT_MS (1.2 s) is a lost link; lost for LINK_LOST_AWAY_MS (10 s) concludes AWAY directly, bypassing AWAY_DWELL. For getAway that is compliance; for stayNear it fails the commitment.
  • Lost-link grace, getAway only. On detection the motor and buzzer stop (inside 2 s); the LED ring stays red. A measurement completed after the loss at or inside AWAY_ENTER_CM resumes output at once, from the start of the profile. Otherwise AWAY is concluded at 10 s. One grace per LINK_GRACE_COOLDOWN_MS (120 s) from its start; the 10 s rule has no cooldown.
  • Detection is not the BLE disconnect. The CS link's supervision timeout is 4 s, outside the 2 s budget. Activity is any CS subevent result, aborted ones included.
  • Implementation: enforcement.c (impulse_enforcement_link_update), proximity.c (impulse_prox_force_away), cs_backend_nrf.c (activity stamp, measure() stops waiting on a silent link). Host tests: test_link_loss.

v0.15 — 2026-09-12 — a crash can no longer latch the motor on, and the LED ring shows enforcement state

No wire format changed, so MOBILE_APP_SPEC.md is untouched and nothing here is lockstep. Reasoning and raw reads: docs/agent-notes.md, 2026-09-12 ("The watch halts on a SoftDevice Controller assert mid-ranging").

  • LED ring (new behaviour, both roles). During an enforcement window the ring is solid red while the criterion is unmet and flashes green (500 ms on / 500 ms off) while it is met — donning grace included. Outside a window it stays dark, so the §6.2 rule that the ring is off in DORMANT is unchanged. On the anchor, red means its removal alarm is sounding and green means a window involving it is open and quiet. The short-press clock keeps priority while shown. Verified from the pixel buffer: dark in DORMANT, green flashing while met and in grace, solid red while unmet (a stayNear window failing closed). Status-over-clock priority on a button press not yet exercised on hardware.
  • Fatal errors reboot with outputs off (new). Zephyr's default handler halts forever, and a SoftDevice Controller assert did exactly that mid-ranging with the vibration motor latched on. The handler now switches motor and buzzer off first, records reason/PC/LR in retained RAM, flushes logs and reboots; the next boot reports the record. Verified with impulse crash. CONFIG_IMPULSE_HALT_ON_FATAL restores the halt for bench builds only.
  • Hardware watchdog (new). wdt31, 30 s, fed from the main loop, paused only while a debugger holds the CPU halted. It is the only mechanism that recovers a halted or interrupt-locked CPU. Verified with impulse hang: reset cause watchdog, motor off.
  • Reset cause is logged at every boot (new) — power-on, brownout, watchdog, software or pin — so an unexpected reset can be classified instead of guessed at.
  • The wall clock survives a warm reset (new). It is mirrored into retained RAM every loop pass, so a crash, a watchdog reset or the reset button restores it to about a second; only a power loss falls back to the once-a-minute flash save. Before this, a reboot mid-window left the clock up to 60 s stale — 45 s observed, shifting the window end by the same amount. Verified: −1 s before a warm reset, −2 s after. That test ran before any periodic flash save, so without it the watch would have booted with no clock at all and enforced nothing.
  • Open, upstream: SoftDevice Controller assert 23, 587. Not in v3.4.0's documented assert table; the nearest v3.4.0 fixes (DRGN-28655, DRGN-28736) are already included. Seen during channel sounding (main mode 2, sub-mode 1) after sustained dropped subevent results. Needs a Nordic support case. A plausibly matching fix, DRGN-29669 (assert when an ACL connection with active CS procedures is disconnected), is on nrfxlib main but in no release yet. The application-side mitigation is to never disconnect with procedures still enabled; a link that is lost rather than closed still depends on the controller fix.

v0.14 — 2026-09-12 — the donning grace must be EARNED, and enforcement output must stop on every exit

Three defects found running real Sunrise Locks on Board V1. No wire format changed, so MOBILE_APP_SPEC.md is untouched and nothing here is lockstep. Reasoning: docs/agent-notes.md, 2026-09-12 (two entries).

  • §5.4.4 donning grace is now conditional on the watch being WORN at the window start. It used to open on every window regardless. A watch sitting on the nightstand — which is the entire Sunrise Lock premise — therefore entered its window already inside a grace it had not earned, and stayed silent for donning_grace_s; at the §5.4.4 maximum of 1800 s that is the whole alarm. This is the likeliest cause of a Sunrise Lock that "just didn't fire"; the windows that did fire had donning_grace_s = 0. Donning later still opens a fresh grace, which is what the feature is actually for. Behaviour change — a non-zero grace now means something different at the window boundary than it did.

  • §5.4.1 "all output stops immediately on the transition to met" now holds on every exit. The condition check had three short-circuits — donning grace, no active event, and the phoneAway fail-open — that reported compliance without stopping the output profile, so the vibration motor kept running while the device reported cond_met=1. Verified on hardware by reading the pad, not the struct.

  • §4.6 the anchor now survives the RAS reflector's teardown. On a BLE disconnect the host runs its connection teardown on the system workqueue; for a Channel Sounding link that reaches bt_ras_rrsp_free(), which drains the RRSP workqueue with K_FOREVER after a k_work_cancel() that cannot stop an already-running handler. A reflector caught mid-send blocks on an ATT buffer the dead connection will never return, and the system workqueue is wedged permanently — while the main loop, logger and heartbeat all keep running, so the anchor reports itself healthy while being invisible to every scan. Advertising restart moved to a private workqueue, and a new CONFIG_IMPULSE_SYSWORKQ_WATCHDOG cold-reboots on a stalled system queue.

v0.13 — 2026-09-12 — the product loop closes: two boards range, WiFi is up on both, and WATCH_REMOVED is proven on the wire

Three sessions (2026-09-10 evening, 2026-09-11, and overnight into 2026-09-12). No wire format changed, so MOBILE_APP_SPEC.md is untouched and nothing here is lockstep. Full reasoning: docs/agent-notes.md (six entries, 2026-09-11 and 2026-09-12) and nrf_firmware/HARDWARE_TEST_PLAN.md.

  • §0.4 chunk G — the reflector reflects, and the product loop closes end to end. Two Board V1s range against each other: at ~61 cm line-of-sight, ifft reads a median 1.32–1.36 m at 0.02–0.04 m sd over 150+ samples, and a live enforcement window produced 172 estimates in 60 s driving a real cond_met. IMPULSE_CS_IFFT_OFFSET_CM moved 97 → 72 — the DK-derived constant errs in the unsafe direction, reporting the watch ~25 cm closer than it is, and CS offsets are antenna-delay dominated so a DK value was never going to transfer to a BM20C pair. The slope (0.993) is still the DK's; one point cannot separate slope from offset, so the multi-station sweep in cs_tuning_plan.md is still owed and every threshold resting on it is provisional.
  • §0.4 chunk G — the CS link is now gated on enforcement. The engine parks disconnected and not scanning until a commitment needs a distance, and releases the anchor within ~5 s of the window ending. It previously held the link open forever; a connectable advertiser stops advertising once connected, so this presented for two days as "the anchor keeps dying while powered" when the anchor was healthy throughout. Accepted consequence: the first measurement of a window ABSTAINS while ~10 samples accumulate, which for stayNear fails CLOSED (§4.5). Do not "fix" it by reporting compliance while the buffer fills.
  • §0.4 chunk I — WiFi is up on BOTH roles, 📋 → 🟡. Built with --cs --wifi; the sysbuild symbols fail silently if omitted. Watch and anchor both associate on 2.4 GHz WPA2-PSK, and BLE and WiFi coexist — 224 CS procedures with the link COMPLETED. RAM went 99.46% → 54.4%: the overage was heaps sized for a throughput device, not fixed structures, so no respin is needed for RAM. Dropping 5 GHz saves nothing (byte-identical build).
  • §0.4 chunk F — §5.5.1 anchor escalation is built and the watch half is proven on the wire. Credentials and the anchor IP table arrive over BLE, persist, and the watch unicasts the 33-byte WATCH_REMOVED / WATCH_WORN packets to each anchor in beep_anchors, re-asserting every enforcement poll as §5.5.1 requires. Verified by decoding real datagrams on the LAN. The anchor half is written and untested, and the trigger — worn detection — is still unverified behind the enclosure.
  • §4.8 — the Anchor IP Table is write and notify. A clarification of v2 §5.6's wording, not a contract change: the clients always subscribed first, the firmware was the one that was wrong, and no client moves.
  • §7 — the V1 anchor has no cell fitted, so the ≥90 min holdover is currently unmet rather than unmeasured. batt=0%, AIN0 at 38–53 mV, and a reset observed at 372 s of uptime when external power glitched.
  • Still open, and now the sharpest correctness bug: the initiator ranges whatever matches the iBeacon manufacturer prefix rather than the anchor named by event.anchorId. With two anchors in a house it holds and measures the wrong one.

v0.12 — 2026-09-10 — a full hardware pass on the watch found a remote reboot, a stuck motor, and a clock that does not survive a reset

First session driving the watch image with the CS backend actually compiled in (cs.conf), against phone_sim over a real BlueZ adapter. No wire format changed, so MOBILE_APP_SPEC.md is untouched. Seven defects fixed, one left open as a product decision. Full reasoning: docs/agent-notes.md (two entries, 2026-09-10) and nrf_firmware/HARDWARE_TEST_PLAN.md.

  • §0.4 chunk F — escalation is now measured, not asserted. A live normal_both window steps its wait 26.1 → 24.3 → 22.1 → 20.0 → 17.9 → 16.1 → 14.0 s and then holds flat at ~5.0 s for 26 cycles, matching floor_interval_ms = 5000 with a constant ~2 s burst. The criterion-dependent fail-safe is confirmed on hardware in both directions: stayNear fails closed, getAway fails open.
  • §0.4 chunk E — peripheral + central concurrency PASSES, the case that broke the ESP32 build (v2 §10.1). The full GATT surface reads at MTU 498 while the CS engine holds a scan. Still untested with a ranging session actually in progress.
  • §10's claims fence is CLEARED. The LED ring and the vibration motor have now been driven simultaneously with a human watching, which is the condition launch-plan.md §10 and BLK-03 both turn on.
  • §6.2 — the LED ring is indexed COUNTER-CLOCKWISE. Rendering 09:30 put the hour marker at 3 o'clock while the minute marker stayed at 6 — a mirror about the 12–6 axis, not a rotation, which is what distinguishes the two. Pixel 0 is at 12 o'clock; the fix is a subtraction in ring_pos(), not an offset constant.
  • §5.2 — worn detection gains hysteresis and its threshold moves 150 → 200 mV, with a release at 140 mV. It had a single threshold, so a delta resting near it chattered once a second, and each flip drives the donning grace and WATCH_REMOVED. Still an estimate: the IR window is covered by the enclosure, so it is set from the no-wrist side only (case-on crosstalk measured at 75–90 mV, versus 64 mV bare — the case raises the floor). Biased high deliberately: reading "worn" on a nightstand is far worse than reading "not worn" on a wrist.
  • §3 — THE WALL CLOCK NOW SURVIVES A REBOOT (fixed later the same day). The decision recorded below was taken: the clock is persisted. impulse/wall is written every 60 s and immediately on an app time-sync, and restored at boot behind a sanity floor of 2025-01-01. It is treated as a lower bound, never as authority — it lags by up to 60 s plus off-time, so a commitment can start late but never early, and an app sync always supersedes it. Deliberately kept in a SEPARATE key from the elapsed base: §9.2 requires integrity timers on a monotonic basis precisely so that writing the wall clock cannot accelerate a quarantine, and persisting the wall clock must not contaminate that. Verified on hardware: restored_from_flash=1 after a reset, and the enforcement window correctly re-entered. The original finding, for the record:
  • §3 — the wall clock did not survive a reboot. g_utc_base is a RAM-only static initialised to 0, so after any reset the watch believes it is 1970, matches no window, and enforces nothing until the app re-pushes the time — silently. Until 2026-09-10 this was also remotely triggerable, because the CS backend rebooted the watch on any BLE disconnect (below). That half is fixed; the fail-open is not. Persisting the clock means trusting a stale clock after a power cut, which interacts with §9.7's time guard, so this needs a product decision rather than a patch. Anything time-gated should be treated as not surviving a reboot until it is made.
  • A Nordic sample's sys_reboot() reached production. disconnected_cb() in the CS backend was carried over verbatim from ras_initiator, where rebooting is the restart mechanism for a one-shot flow. Its callbacks are BT_CONN_CB_DEFINE — global — so the engine also adopted the phone's connection as its ranging peer. The general rule, worth carrying to chunk I: sample connection callbacks assume a single-role device, and this device is both.
  • Outputs are now cleared by state, not by exit path. impulse_profile_stop() clears the profile struct and touches no pin, so any exit other than "window ended" left the vibration motor latched on indefinitely (observed: P3 OUT = 0x08 for 36 s).

  • §2 — charger_detect (P1.03) does not work on this board, and is not going to be made to. Across a real USB-C plug event the battery climbed 94% → 97% — so the TP4057 was charging — while the pin sat at its pull-up level throughout. Not a polarity error: the line never moves. The suspected cause is an unwetted BM20C pad, on a board that already had four on 2026-08-29, so it is probably not a design fault and probably does not generalise to other units. Decision (the author, 2026-09-10): do not chase it. Charge state is to be inferred from the battery-voltage derivative, which is what v2 §6.3 did before Board V1 added this input — and that inference is not written yet, so charge state is currently unavailable rather than merely degraded. P1.03 remains [from schematic] and has never been driven.

  • §4 — the anchor's CS reflector exists and runs. cs_reflector_nrf.c, adapted from NCS's ras_reflector with three deliberate departures (no second bt_enable(), no sys_reboot() on disconnect, and no assumption of a single link — an anchor is a peripheral to both the phone and the ranging watch, and the two are indistinguishable by role). The anchor boots with CS backend: nrf-cs-reflector and advertises 0x185B, confirmed over the air. Nothing has ranged against it, which needs a second board.

Parity table brought into line, 2026-09-11 (/parselogs). The change log above was written on the night but §0.4 was not moved with it, which is the failure §0's own rules exist to prevent. Chunk G's reflector column goes 📋 → 🟡, and the notes on chunks B, D, E and F now carry the hardware results rather than the predictions they replaced.

Not changed, deliberately: every constant in §11 except the worn threshold, and the CS calibration constant, which is still Board V1 against a DK rather than a second Board V1. The reflector now exists, but no ranging procedure has ever completed against it, and that is the largest remaining gap in the product loop.

v0.11 — 2026-08-31 — the port runs on the board, and channel sounding is measured rather than assumed

Recorded by /parselogs from the workspace rather than from a daily log; there has been no log since 2026-08-21. Two sessions on 2026-08-30 — a first Zephyr boot on Board V1, then a channel-sounding campaign — moved six rows of §0.4 and answered the one item §11 marked "check this first." No wire format and no behaviour changed, so MOBILE_APP_SPEC.md is untouched and nothing here is lockstep.

  • §0.4 — six rows move. Chunk A 🟡 → ✅ (both role images boot). Chunk D 📋 → 🟡 (LFXO confirmed by a +34 ppm drift measurement, and the legacy CLOCK.LFCLK registers reading zero is not the failure §3.2 predicts). Chunk C, E, F and G rows corrected — each had been written before the hardware session and had gone stale inside a day. The anchor's GATT, iBeacon identity and §4.9 strap-lock refusal are all verified on hardware; its CS reflector is not, which is now the largest single gap.
  • §11 — CS method availability and API shape is answered, and it was the right thing to have flagged. NCS v3.4.0's ras_* samples, step mode MODE_2_SUB_MODE_1, single antenna path; the DK's controller reports HCI 6.3. Sampling runs at ~10 Hz at contact and collapses to under 2 Hz past 5 m, which is the opposite of convenient: a burst takes longer the further away the anchor is.
  • §11 — ifft is correctly scaled, which could not be assumed. An earlier occluded capture suggested the estimators differed by a scale factor of ~1.45, which would have meant at most one of them could be calibrated with a single number. The line-of-sight ground-truth sweep supersedes that: the 1.45 described occlusion, not the instrument. The estimator relationships in the firmware are now the LOS ones, so occlusion registers as divergence — which is what makes divergence usable as the CS_QUALITY_MIN floor.
  • §11 — burst size is now measured, not chosen. Resampling the real per-station data gives a burst-median error sd of 0.19–0.57 m at N = 10 against 0.28–0.82 m at N = 5. The NEAR/AWAY band is 1.5 m wide, so N = 10 fits inside it and N = 5 does not.
  • §8 — a storage defect worth reading even if you never touch this port. Both security-critical structures exceed an NVS sector, so settings_save_one() returned -EINVAL on every write and nothing was ever persisted — a device that runs perfectly, accepts commitments, quarantines loosenings, and loses all of it on reboot, which is itself a loosening. It was masked by err |= ... over negative errnos. Report the first failure, never the OR.

Not changed, deliberately: every constant in §11 keeps its starting value. The CS calibration constant is provisional — offsets are antenna-delay dominated and every number so far is Board V1 against a DK, not against a second Board V1. And §10's claims-fence condition is untouched: the ring and the motor have still never been driven simultaneously with a human watching. Full reasoning in docs/agent-notes.md (2026-08-30, two entries) and ImpulseFirmware/nrf_cs_test/RESULTS.md.

v0.10 — 2026-08-30 — Board V1 exists, three actuators work, and the planned LED-ring driver cannot work

The first entry in this document's life written from a bench rather than from reasoning. Board V1 was brought up on 2026-08-29 over SWD from an nRF54L15-DK, bare metal, no Zephyr. Nothing about behaviour changed; what changed is that four rows of §2 have been exercised and one planned implementation choice has been disproved.

  • §2 — the LED ring cannot be driven by PWM, and this is normative. PWM20/21/22 live at 0x500Dxxxx and cannot reach P0.02, which sits in the 30 domain at 0x5010xxxx. Measured directly: PWM reported a full DMA AMOUNT and SEQEND every frame while the pad saw 0 highs out of 199 samples. The row said "needs a PWM/PIO-driven WS2812 driver" and now says SPIM30, with T1H = 0.75 µs — the value the fitted chain actually decodes as a one, which is above the SK6805 datasheet's nominal. This is the highest-value line in the entry: chunk B was pointed at a peripheral that provably cannot reach the pin, and finding that out from the bench cost hours.
  • §0.4 — the pin-map/driver row moves 📋 → 🟡. Ring, motor, buzzer and wake button verified on hardware; no Zephyr driver exists for any of them, and the IMU, IR sensor and battery sense remain untouched. The distinction matters, so the row states both halves.
  • §1.4 — the TC2030 warning is now demonstrated rather than predicted, and its failure mode is mechanical. A partial seat presents as a healthy 3.300 V VTref with no SWD response at all, because the power pins touch and SWDIO/SWCLK do not — which reads exactly like a dead board.
  • The status banner is rewritten. "No boards, no firmware, no measurements" was true on 2026-08-11 and is now half wrong in a way that matters: a reader who believes there is no hardware will not go looking for ~/Impulse/.bringup/, which is where the measurements are.

Not changed, deliberately: every constant in §11, all of which still came from reasoning. And §0.4's other rows stay 📋 — a bare-metal image that beeps a buzzer is not an NCS skeleton, and calling chunk A started because a board exists would be the exact kind of drift this table is for. Full reasoning, including two nRF54L SPIM traps that silently half-work, is in docs/agent-notes.md, 2026-08-29.

v0.10a — 2026-08-30 — the port exists as code: skeleton, spine, and a host test suite

Written overnight against NCS v3.4.0 on a machine with no hardware attached. Lands in ImpulseFirmware/nrf_firmware/, which is not versioned. Nothing has run on a board, and the parity rows above say 🟡 rather than ✅ for exactly that reason: the firmware compiles for Board V1 and 89 host checks pass, which proves the APIs line up and the logic is self-consistent, not that any of it is right on silicon.

  • Chunks A, C, F and H now have code. The NCS application builds both role images from one tree. The enforcement spine — §3 data model, the §6.2 blob codec, §5.3 day resolution, the §5.4.3 profile table, and all of §9 — is written and covered by a host suite that runs in about a second, restoring the property §10 asks for.
  • §9's free-edit window had a fail-open, found by writing the tests. §9.3 grants a free edit to an event with no settled baseline, which is the genuine first-setup case. But an event present in the schedule while its integrity record is missing — a storage desync, a failed load — took the same path and would have been loosenable instantly, forever. Such events are now sealed: an entry is created with their current state as the baseline. The worst case becomes an event that cannot be loosened quickly, which is the safe direction. This is a real defect the spec's prose does not cover, and it is worth carrying back into §9.3 if the ESP32 build shares it.
  • The channel-sounding backend is a stub that always abstains, deliberately. With no backend a stayNear commitment fails closed and getAway fails open — §4.5's criterion-dependent fail-safe, working as written. A stub returning a plausible distance would have silently reported compliance, which is the one outcome that must not happen.
  • §11's "check this first" is still unchecked. No CS API was called; the interface was written from §4 and is the part most likely to need reshaping on contact with the SDK.
  • Two hardware unknowns are now blocking, and neither was visible before. The SAADC AIN↔pin mapping for the nRF54LM20A is in no document on this machine — BM20C.pdf gives analog functions only for the BM15 — so battery sense and IR worn detection have no driver at all. And the LED ring's 1.0 µs bit period under Zephyr's ws2812_spi is shorter than the 1.25 µs that was bit-banged successfully; the high times match the measured-good values but the period does not. Both are in nrf_firmware/HARDWARE_TEST_PLAN.md, which is the running list of everything unverifiable without a board.
  • Not changed: every constant in §11, and the §0.4 rows for chunks B, D, E, G, I and J. Drivers exist for the ring, buzzer, motor and button but none has been run under Zephyr, so chunk B stays where v0.10 put it.

v0.9 — 2026-08-11 — three product decisions the user made, none of them a porting question

All three arrived as answers written into Todos.md and are recorded here because each had a section explicitly holding a space for it. None of them changes a wire format; two of them close open items in §11.

  • §1.5 — v1 ships with the WM02C. "It simply sacrifices too much on this product's mission to not have Wifi." §1.5 was written to hold the cost of a no-WiFi build without recording a decision, and it now records one. The consequence worth carrying forward is that the SAR question stops being hypothetical: 2.4 GHz WiFi cannot be excluded at Fanstel's declared power, so shipping the module means a SAR test or an ~8 dB power cut, and the range that cut spends is the range WATCH_REMOVED needs. BLK-15's BLE-only build is now a contingency against the BM20C grant failing, not a candidate.
  • §7 — the anchor reports mains loss to the user, not to the watch. The watch treats a battery-running anchor identically; the app raises the notification; holdover is ≥ 90 min. The earlier draft expected this to be the same design as moved-anchor detection (§4.6) and that expectation is withdrawn — a moved anchor lies about its position, an unplugged one tells the truth until it stops answering, and a silent anchor already fails closed. The residual hole (unplug and wait 90 min) is accepted for MVP with the reasoning in place. The app-side requirement is flagged in MOBILE_APP_SPEC.md §0.
  • §6.3 — watch runtime is 8 hours minimum, replacing the 7–10 h target set on 2026-08-09. A floor rather than a range because the floor is what gets advertised. This half-discharges BLK-04: the 9 pm window is deleted from impulse_overview.md's example day, but that day still enforces a 5:30 pm gym window that an 8 h charge from 6 am does not reach. §6.3 states the three ways to close it and forbids copy that pairs the two figures until one is chosen.

v0.8 — 2026-08-10 — the vibration motor and the servo were on each other's pins

Found while tracing the servo header during the 2026-08-10 anchor failure. Re-exported the netlist from Impulse.kicad_sch and read the balls directly, per the standing rule that the schematic is the authority and this table is a copy.

Function v0.7 said Actually
Vibration motor P1.02 / A8 P3.03 / A7 — Net-(U7-P303) → R5 (1 kΩ) → Q3 BSS138 → M2
Servo P3.09 / K7 P1.02 / A8 — /SERVO → U1 pin 1
P3.09 / K7 the servo unconnected — unconnected-(U7-P309-PadK7), no net at all
P3.03 "no net at all" the motor
  • The consequence is not cosmetic. Firmware built from v0.7's table would drive the servo whenever it meant to buzz the motor. On an anchor that is the strap lock of §4.9 actuating on every vibration event — a physical restraint moving on a signal that was never meant to reach it. It would also have found the motor unresponsive and its pin documented as unconnected, which is a confusing place to start debugging.
  • The BLK-03 claim survives. The ring (P0.02) and the motor (P3.03) are still separate pins, so the v2 §1.1 pin share is still designed out; only the recorded motor pin was wrong. Timeline.md and firmware_spec_v2.md carried the same transposition and are corrected in the same pass.
  • No firmware exists to be wrong, so there is no parity row to flip — but §0.4's chunk-B row now warns to re-export rather than trust the copy.
  • The v0.6 entry below still says P3.09. It is left as written; change-log entries record what was believed at the time, and rewriting them hides exactly this class of error.
  • §7 gains a bullet on the servo rail: U1 pin 2 is /VBAT with no switch, fuse or current limit, so the rail cannot be cut in software and a servo fault is unhandleable in firmware. That is a respin item, not a porting one.

v0.7 — 2026-08-10 — the deletion list was incomplete, and the two missing characteristics were never in any spec

Found while flagging §4.8 as lockstep in MOBILE_APP_SPEC.md. §4.8 named four anchor characteristics and the watch's calibration control. Checking that list against impulse_app/lib/utils/ble_constants.dart — the app is the fourth declaration of this contract — turned up a fifth: the anchor's Calibration Mode …000F, which drives the calibration-v2 phase during a walk-around burst. It is live in the watch firmware and declared in the app, and it is in neither firmware_spec_v2.md nor MOBILE_APP_SPEC.md §6.2.

  • §4.8 gains …000F. Calibration-v2 is deleted by §4.7, and …000F exists for nothing else, so it goes.
  • §4.8 now cites the app-side constant and firmware source line for both calibration characteristics, since neither has a spec section to point at.
  • The wider lesson is in §0.4's chunk-E row. The GATT contract is declared four times (CLAUDE.md), and a characteristic that entered the code without entering any of the documents survived two spec generations. The port is the moment that gets caught, because it is the moment something is being deleted rather than added.
  • No behavioural change, and none to firmware_spec_v2.md — documenting …000F retroactively into the ESP32 contract is a separate job with its own verification, raised in Todos.md rather than done here.

v0.6 — 2026-08-10 — the servo header is never populated; the lead is soldered

Confirmed by the user while tightening §3 of FCC Lab Quote Responses.md. Neither enclosure has room for a 3-pin header, so U1's footprint goes unplaced on every board — watch and anchor — and the SG90's 3-wire lead is soldered directly to the pads. The schematic is not wrong and does not change: U1 is still a connector symbol. This is an assembly decision that diverges from it, which is exactly the kind of divergence that is invisible until someone reads the BOM and "corrects" the spec back.

  • §2's servo row, §7's servo bullet, and §0.1's one-line role summary now say soldered rather than plugged.
  • No parity row, because there is no firmware consequence. P3.09 drives the same PWM into the same motor either way.
  • It matters to FCC, which is why it is recorded here rather than only in a BOM note. A non-detachable 3-conductor unshielded lead is a permanently attached I/O cable: tested as supplied, with no maximum-length or ferrite declaration to make, and no servo-less anchor configuration for a lab to test as the cheaper alternative. FCC Lab Quote Responses.md §1 and §3.

v0.5 — 2026-08-09 — the anchor has a battery, and it is an enforcement component

Corrected by the user while scoping the FCC lab quote (FCC Lab Quote Responses.md). The document contradicted itself: §2 listed the battery charger as populated but unused in the anchor role, while §7 said the charger and cell were unpopulated. Both were wrong in the same direction. The anchor carries a charged cell and runs on it, because otherwise unplugging the anchor is a complete bypass of any criterion that depends on it — Sunrise Lock being the case that matters, since the plug is reachable from bed.

  • §7's power bullet rewritten, and the reason recorded, so this does not get "simplified" back out by someone reading a BOM.
  • §2's populated-but-unused list loses the battery charger.
  • §4.6 gains the unplug attack, which the CS threat-model table did not cover because it is not an attack on the radio.
  • §11 gains a row: anchor cell capacity and holdover time. Framed as a threat-model number — set by how long a user will wait out an unplugged anchor, not by what the cell can deliver.
  • No decision on mains-loss reporting. Surviving the unplug is necessary, not sufficient; an anchor that runs silently until flat only makes the bypass slower. Whether the anchor reports mains loss and the watch treats it as tamper is left open in §7 and §0.4, and belongs with §4.6's moved-anchor chunk.

v0.4 — 2026-08-09 — what dropping WiFi would actually cost, verified against the code

The 2026-08-09 log asks whether the WM02C is needed for a first run, and answers itself with "i dont think thats possible" about working around the out-of-range bypass without it. New §1.5 records what the module carries, because the answer was better than the question assumed and worse than the fallback assumed.

  • WATCH_REMOVED / WATCH_WORN are WiFi-only. send_to_anchors() returns early when WiFi is down (WatchFIrmware/src/main.cpp:1782), so the v2 §5.4.4 mitigation for the Sunrise Lock bypass — leave the watch out of BLE range and go back to bed — has no transport at all in a BLE-only build. The mitigation exists; it is not something still to be invented. It just happens to be the thing WiFi is for.
  • The three WiFi dependencies are now stated in one place, in the order they matter: the removed-watch notification, the anchor's SNTP time and repair path, and only then the getOnWifi / getOffWifi criteria.
  • No decision is recorded. Whether v1 ships with WiFi is open; the trade is in Concepts/NRF Redesign.md §A4 and Todos.md. §1.5 says what it costs, not what to do.

v0.3 — 2026-08-09 — the WiFi module's certified TX power is a firmware constraint; a runtime target exists

Two additions from reading the module datasheets end to end (Concepts/FCC Certification.md), plus one user-set number.

  • §1.1 — the WM02C's FCC/ISED/TELEC grants are conditioned on gain and band-edge backoff settings the host application must reproduce (NCS ≥ 2.6.1, CONFIG_NRF700X_ANT_GAIN_* and per-protocol backoffs, published by Fanstel as a .txt). This was recorded in no document. It is a §12.5-class constraint — wrong values put the product outside the grant with a perfect layout — and chunk I's "done when" now names it.
  • §1.1 — the part is a WM02C, not a WM02E (confirmed 2026-08-09). Irrelevant to firmware, which cannot tell them apart; not irrelevant to the board, where the C has a mandatory antenna keepout the E does not. Stated so the naming stops propagating.
  • §6.3 — watch runtime target of 7–10 h, set by the user, replacing impulse_overview.md's 8–12 h. Backed by one measurement on different silicon (10 h, ESP32-C3, clock disabled), so it is recorded as a target with its provenance, not as a property of this board. It does not discharge BLK-04 — that contradiction is with the example day, which 7–10 h does not fix.
  • §11 gains two rows (watch runtime, declared max WiFi TX power) and the sleep-current row loses a stale WM02E.

v0.2 — 2026-08-09 — §3.2 corrected: the LFXO exists

The BM20C datasheet arrived (datasheets/BM20C.pdf, Draft Ver 0.82) and falsified v0.1's §3.2, which claimed Board V1 had no 32.768 kHz crystal and specified three mitigations for RC drift. Both crystals are embedded in the Fanstel module — "32.0 MHz and 32.768 kHz crystals embedded." The "optional sleep crystal" in the integration guidance is an additional external part, not a missing required one.

  • §3.2 rewritten. The drift failure mode it warned about — an eight-hour sleep waking past the boundary minute and silently skipping the window — is not a live risk.
  • What replaces it is one small firmware task: Zephyr must be told to use LFXO, or it falls back to the internal RC with the crystal sitting unused. Set in devicetree in chunk A, confirmed at runtime in chunk D.
  • CLOCK_GUARD_S drops 30 s → 5 s, and stops being a value that needs measuring.
  • §11 loses two rows; the sleep-current row is reworded, since the datasheet's SoC figure is not the board's.

v0.1 — 2026-08-09 — Document created First draft, written against Board V1's schematic and firmware_spec_v2.md. No hardware existed. Authored by /todo; the decisions taken while writing it are logged in that todo block and, where they outlive it, in docs/agent-notes.md.


1. Platform

1.1 Silicon

Part Role
Fanstel BM20C (U7) nRF54LM20A. Bluetooth 6.0 radio and the host MCU. Runs the firmware.
Fanstel WM02C (U8) nRF7002. WiFi, as a QSPI peripheral with a four-wire BT/WiFi coexistence handshake. VDDIO is gated by a TPS22916 load switch on P3.00, so WiFi can be powered off rather than idled.

The part is a WM02C (confirmed 2026-08-09), though the schematic symbol is named WM02E. Both are nRF7002 on the same 16-pin footprint and firmware cannot tell them apart, so the naming costs nothing here — but it is not cosmetic at the board level: the WM02E's antenna is external on a u.FL cable and carries no mounting restriction, while the WM02C's is a chip antenna on the module with a mandatory 7.4 mm keepout. See datasheets/README.md.

The WM02C's certified TX power is enforced in firmware, and chunk I must honour it. The module's FCC / ISED / TELEC grants were issued against specific gain and band-edge backoff settings; a host that does not reproduce them is outside the grant regardless of layout. Requirements: NCS ≥ 2.6.1, and the CONFIG_NRF700X_ANT_GAIN_* / per-protocol band-edge backoff values published by Fanstel alongside the datasheet — take their .txt verbatim rather than transcribing. This is a §12.5-class constraint, not a tuning knob: launch-plan.md §5 obligation 3 makes deviation a Class II Permissive Change with a full intentional-radiator retest.

Note also that the resulting power is what the SAR analysis is calculated against (Concepts/FCC Certification.md §5). At the datasheet's own figures the 2.4 GHz WiFi is not SAR-excluded on a wrist-worn device, and one of the three ways out is a further firmware power reduction. If that route is taken, the declared maximum becomes a normative constant in this chapter and the WiFi range budget changes with it. Do not treat the backoff values as free to adjust in either direction.

Nordic port numbering is not ESP32 GPIO numbering. A pin number carried across from v2 §1.1 will be wrong. Every pin in this document is Pn.mm.

1.2 SDK and build

nRF Connect SDK (NCS), built with west. Not bare Zephyr: nRF54L-family support, the SoftDevice-free nrfx drivers, and the channel-sounding APIs all arrive through NCS, and the alternative is tracking upstream Zephyr plus Nordic HAL by hand for no benefit.

PlatformIO does not survive. Neither does the espressif32@6.9.0 pin that v2 makes so much of — it was load-bearing for a toolchain that no longer applies. Pin the NCS version instead, in west.yml, for exactly the reason v2 pinned the old one: a floating SDK version is a silent-flash-bloat bug waiting to happen.

The devkit is not the same silicon as the module. The BM20C datasheet notes that "All evaluation boards use an nRF54LM20B module" — the B variant carries an Axon NPU that the nRF54LM20A on Board V1 does not have. Bring-up on an EV board or a Nordic DK is therefore not running the shipping part. Nothing in this spec uses the NPU, so the risk is low, but do not enable NPU-dependent SDK features during chunks A–C and discover in chunk G that the board can't run them. Check the board target in west build -b … matches the variant in front of you.

Symlinked PlatformIO libraries (symlink://../proximity_engine, symlink://../beep_vocab) are gone with them. beep_vocab still needs to be shared between the two roles so the buzzer vocabulary cannot drift; carry it as a Zephyr module or a west project, not a copy.

1.3 Role selection — compile time, not runtime

One board, two roles, two firmware images, selected by Kconfig:

CONFIG_IMPULSE_ROLE_WATCH=y     # or
CONFIG_IMPULSE_ROLE_ANCHOR=y

This follows the convention the deleted engine already used (-DPROXIMITY_ROLE_WATCH), but the reason is no longer stylistic. The watch is the root of trust (v2 §9). A runtime-selectable role means a watch can be told it is an anchor — and an anchor does not enforce. That converts the entire commitment-integrity chapter into one NVS write. A user who can reflash the device has already lost the security argument, but the bar should be reflashing over SWD with the case open, not a settings byte.

Manufacturing cost: two images and an SKU that must be flashed correctly. Accepted deliberately.

1.4 Debug transport — RTT

There is no USB CDC on this board. The USB-C connector carries power and CC only; /DN and /DP are unrouted, deliberately (v2 §1.3). Debug is SEGGER RTT over SWD, through the TC2030-NL footprint (J2).

Consequences worth internalising before the first bring-up session:

  • A TagConnect TC2030-NL cable and an SWD probe are not optional. Without them the board cannot be flashed or observed. Demonstrated 2026-08-29, and the hard part turned out to be mechanical rather than electrical: the TC2030-NL has nothing holding it down, and a partial seat presents as a healthy 3.300 V VTref with no SWD response at any speed — the power pins touch, SWDIO/SWCLK do not. That reads exactly like a dead board and was the entire fault in the first bring-up session. Press the cable in hard and retry before suspecting the target; a retaining clip or the legged TC2030 removes the failure mode. Note also that the DK's own SoC is unreachable while DEBUG OUT has the mux.
  • pio device monitor and every serial workflow in the repo READMEs are dead. Replace with west attach / RTT viewer.
  • The BENCH_NO_SLEEP workaround has no analogue and needs none. It existed because ESP32 light sleep powers down USB CDC, stranding the port until someone physically pressed reset. RTT is a memory-mapped ring buffer read by the debug probe; sleep does not take it away. Do not port BENCH_NO_SLEEP. Its absence is a real improvement in bring-up ergonomics, and it removes the workspace's single most common hardware gotcha.
  • Naming collision, and it will bite: Channel Sounding's headline method is also called RTT — round-trip time. In this document and in code, RTT means SEGGER's debug transport and CS-RTT means the ranging method. Never write bare "RTT" in a proximity context.

1.5 What the WM02C actually carries

The WiFi module is easy to read as an optional extra bought for two enforcement criteria. It is not, and this section exists because the question is live — the 2026-08-09 log weighs dropping it for the first run, and the BLK-15 fallback drops it involuntarily. Either way the cost has to be known before it is paid.

Three things ride on WiFi, and they are inherited unchanged from v2:

  1. The getOnWifi / getOffWifi criteria (v2 §4.10). The obvious one, and the least load-bearing — a phone with GPS can answer "did you get to the gym" and uninstalling the app does not help the user, because a missing report is not a compliant one.
  2. Watch-removed notification to an out-of-range anchor (v2 §5.4.4, §5.5.1). This is the one that is easy to miss. send_to_anchors() returns immediately when WiFi is down — "cmd=0x%02x NOT SENT: no WiFi — anchor beeping is UDP-only" (WatchFIrmware/src/main.cpp:1782) — so WATCH_REMOVED and WATCH_WORN have no transport at all without it.
  3. Anchor WiFi repair (v2 §5.5.3), and the anchor's own SNTP time, without which it will not beep (v2 §4.7).

Why (2) decides more than it looks. The Sunrise Lock bypass is to leave the watch far enough away that BLE cannot reach it and go back to bed: out of range is indistinguishable from away, and away is compliant. v2 already closes this — the watch knows it is off the wrist from the IR sensor (§5.2) and tells the beeping anchor so. But the message has to cross a distance that, by construction, BLE does not reach. A second transport is not an implementation detail of that mitigation; it is the whole of it. Channel sounding does not help — it makes an in-range distance honest, and this attack is about being out of range.

So a BLE-only build does not merely lose two criteria. It reopens a known bypass of the flagship template, in the houses large enough for the bypass to exist.

Decided 2026-08-11 — v1 ships with the WM02C. The user's words: "It simply sacrifices too much on this product's mission to not have Wifi." The trade was weighed in Concepts/NRF Redesign.md §A4 against a ~$5 BOM saving per unit (compounding across a 1–5 anchor attach rate) and a simpler certification, and (2) above is what settled it.

Two consequences follow immediately, and both are now commitments rather than options:

  • The BLK-15 BLE-only fallback is a contingency, not a plan. It is what happens to this design if the BM20C grant fails, and its cost is the whole of §1.5 rather than the two criteria it looks like. Lab quotes still carry the BLE-only delta (AUG-12) because that is what prices the contingency — not because the build is under consideration.
  • The SAR route in Concepts/FCC Certification.md §5 is now on the critical path. 2.4 GHz WiFi cannot be excluded at Fanstel's declared power (ratio 26.2 against a threshold of 3.0), so shipping WiFi means either a SAR test or a declared-power reduction to ≤ +9.8 dBm — roughly 8 dB off the range that item (2) depends on. That question is no longer optional and no longer hypothetical; it is tracked in Todos.md and §11 carries the constant it lands on.

1.6 Radio arbitration — BLE and WiFi share the band in time

Both radios must never be on the air at once. This is a measured constraint on this hardware, not a preference and not a certification nicety:

  • 2026-09-12. With both radios active, both went silent within 5–13 minutes, repeatedly, while the application kept running. A BLE-only build of the same anchor ran 27+ minutes clean. (agent-notes.md, 2026-09-12.)
  • 2026-10-13. The same signature reappeared on v5 board #3, on a board whose WiFi and SPI bus had just been proven good: the anchor held BLE (CS reflector advertising) and WiFi together for 4 m 40 s, then lost three consecutive ICMP echoes with no error, no disconnect and no RPU message in the log and the heartbeat still printing. The debugger was moved before the run could complete, so this is a strong corroboration rather than a second measurement.

The four coex wires are routed, but CONFIG_NRF70_SR_COEX stays off: the owner's decision is time division, not PTA arbitration. Nothing in the silicon arbitrates, so the firmware does.

The rule. WiFi exists for exactly one scenario — the watch has come off the wrist and cannot say so over BLE (v2 §5.5.1). Outside that scenario WiFi is off on both devices.

"Off the air" means not advertising and not connected. Both roles are BLE peripherals and that is the whole of what they transmit, so going quiet is sufficient; bt_disable() is deliberately not used, because it tears down the host and the reflector's RAS registration with it for no gain. On the watch, ranging procedures are stopped too — they are by far the heavier half of its BLE airtime.

Order is normative. Going to WiFi: BLE off the air first, then associate. Coming back: WiFi down first, then BLE. The reverse of either leaves both radios transmitting for the several seconds an association takes, which is where the 2026-09-12 failure reproduced most reliably.

1.6.1 The watch

Rewritten v0.22 (2026-10-25). This section used to say "no policy is needed beyond BLE first". That was wrong, and on hardware it was a one-way latch: escalation tested the ranging link, found none, switched BLE off for WiFi — and the next per-poll re-assertion tested the link again, found none because the previous call had switched BLE off, and re-asserted WiFi. Ranging never resumed until the 10-minute give-up or the window end. A window that opened with the watch unworn hit the same test on the very pass that asked for the ranging link, before it could exist, so it always fell to WiFi. The watch now has a policy of its own (impulse_radio_watch_tick() in src/net/radio_policy.c, driven by src/net/escalation.c, both host-tested).

Specified behaviour — an escalation is pending from the moment WATCH_REMOVED/WATCH_WORN is requested until it is delivered (a BLE write handed to the stack, or a §4.6.1 ACK from every beep anchor). The latest request replaces an undelivered one.

Condition Radio
Not enforcing, or nothing pending BLE
Pending, ranging link ready (connected and the watch-state characteristic discovered) BLE — delivered over the link
Pending, BLE has held the air < ESCALATION_BLE_PATIENCE_S BLE — wait for the link
Pending, BLE has held the air ≥ that with no link WiFi, re-sending every 5 s once an address is held
On WiFi ≥ ESCALATION_WIFI_DWELL_S, or every beep anchor has ACKed BLE, with a fresh patience
Pending and undelivered for ESCALATION_WIFI_GIVEUP_MIN BLE for the rest of the episode

Starting values: patience 20 s, dwell 60 s, give-up 10 min. Three properties are load-bearing:

  1. "No link" is concluded only from a BLE phase that has lasted the full patience. The patience clock starts at the window start and every time WiFi is released — never from a state the watch itself created. This is the latch fix, and it is also what lets a window opening unworn take the BLE path: the CS link is brought up by enforcement, a few seconds after the window opens.
  2. The watch duty-cycles too — the twin of §1.6.2's second property. The dwell is bounded so ranging gets the air back, and released early on an ACK because once the anchor has applied the command the air is worth more to ranging. A 60 s watch dwell against the anchor's 120 s dwell / 30 s re-listen guarantees at least 30 s of overlap per watch dwell.
  3. Blindness the watch inflicts on itself is not evidence (§4.5): while BLE is quiesced for WiFi, and for 15 s after it is handed back, no abstention is charged and no new link loss is detected. Without this, a minute on WiFi resolved getAway as MET with the anchor 40 cm away.

A guard sits under the policy. net_wifi.c will not start an association while anything BLE is still on the air — advertising, the app link, or the CS engine anywhere but parked — connected, connecting, or scanning. The ACL link used to linger up to 5 s after ranging stopped, and a scan in progress ran on for up to its 30 s connect wait and could connect during the WiFi dwell; the engine is now woken to release the link at once and checks for "ranging off" every 250 ms while scanning. It defers, logs association DEFERRED — BLE still holds the air once, and retries every tick; if BLE is found on the air while WiFi is already up it logs RADIO INVARIANT VIOLATED at error level. The guard exists because the overlap does not fail honestly: on 2026-10-24 the nRF7002 reported association failed (status 2) — wrong password — to a correct passphrase while CS ranging was live, and the same credential associated in ~1.5 s with CS out of the build.

Two earlier changes from v0.19's behaviour, both still required by this section:

  • WiFi is no longer held for the duration of a window. It used to come up at window start whenever beepAnchors was non-empty and stay up, so that the association was ready at the instant of a removal edge. That reasoning is correct and the price is wrong: an enforcement window is precisely when BLE matters most, so holding WiFi across it put both radios up for exactly the period that must not happen. The association cost this gives back — ~4 s measured on v5 board #3 (association 1.4 s after the request, DHCP 2.0 s later) — is now paid once per escalation instead of once per window, and §5.5.1's per-poll re-assertion is what makes a first lost datagram harmless.
  • The ranging gate must exclude the WiFi state. The main loop re-evaluates "should CS be running" every pass from the enforcement state alone; without an explicit && !wifi_on_air term it silently re-enables procedures one iteration after escalation turned them off, putting both radios back on the air with nothing in the log.

1.6.2 The anchor

The hard half. The anchor cannot know the watch has been removed — that is the message it is waiting for — so it has to infer when to go and listen on WiFi, and its only evidence is the absence of the watch's BLE link.

It used to hold WiFi for its entire uptime, reasoning that a mains-powered device cannot predict when a watch will escalate. That is what put both radios on the air together for hours.

Specified behaviour (CONFIG_IMPULSE_ANCHOR_WIFI_FALLBACK, policy in src/net/radio_policy.c, host-tested):

Condition Radio
No window naming this anchor is open BLE, unconditionally
Window open, BLE contact within ANCHOR_BLE_SILENCE_S BLE
Window open, BLE silent longer than that WiFi, then duty-cycled

Starting values: silence 90 s, WiFi dwell 120 s, BLE re-listen 30 s.

Three properties are load-bearing, and each is there because of a failure already paid for:

  1. The fallback is armed only inside an open window. An anchor that stops advertising stops being findable by the app, by a watch, and by a schedule push — and that presented for two days as "the anchor keeps dying while powered" (agent-notes.md, 2026-09-11). No argument about silence may override this clause.
  2. It duty-cycles rather than committing. WiFi is where WATCH_REMOVED arrives, but BLE is the only way the watch can come back. An anchor that held WiFi for the whole window could never be told to stop beeping by a watch that had returned and been put on. The fallback has to be two-way or it is a trap.
  3. The WiFi dwell must exceed the slowest enforcement poll, not merely the handshake. §5.5.1 re-asserts WATCH_REMOVED once per poll, 60–180 s apart; a dwell sized to cover association and DHCP (~4 s) would hang up before the message it went there for could arrive. Hence 120 s.

Contact is the live link, refreshed every loop pass, plus a seed at window start. The anchor is a peripheral — it does not scan — so the watch's ranging connection is the only thing it ever hears from a watch. The seed matters: without it, a window opening long after the last contact arms the fallback on its first pass, and the anchor drops BLE before a watch in the next room has had any chance to connect.

One subtlety in what counts as contact. The anchor holds a reference to its first link only — deliberately, because overwriting it leaks the other — so "is the app link up" and "is any peer connected" are different questions, and the ranging watch is usually the second link. The fallback needs the broader one: its entire trigger is the watch's ranging link going quiet. Deriving it from the app-facing status byte instead would leave it stuck at connected whenever a phone was also attached, and the anchor would never go and listen on WiFi at all. The two are separate accessors for this reason, and the app-facing byte's meaning is unchanged.

Proven on hardware 2026-10-22, with one seam left. Board #2 (anchor-cswifi, demo window open, no watch in BLE range) entered the fallback after 90 s of silence, associated and took a DHCP address 3.2 s later, and started its alarm on a WATCH_REMOVED and stopped it on a WATCH_WORN. The datagrams were byte-identical to what v5 board #3's watch firmware had been observed emitting, replayed from a host. The dwell/re-listen cycle measured 120.02 s / 30.01 s and resumed advertising each time, including with a dead RPU. What has not run is a real watch, with a schedule naming this anchor, sending to it; see §0.2 v0.21.


2. Hardware reference — Board V1

Transcribed from the Board V1 schematic via kicad-cli sch export netlist. The schematic is the authority; this is a copy. Re-export before trusting it against a board in hand.

Which board this table describes (v0.19). It is Board V1 — the v4 PCB, archived as ImpulseNRFV4.kicad_pcb, and the board the firmware's impulse_v1 definition targets (the name and the revision are off by one, for this board only). ~/Documents/KiCad/ImpulseBluetooth/Impulse.kicad_sch is now the v5 respin, saved 2026-09-14 and built; two v5 boards exist as of 2026-10-05. Six signals moved, and on each revision the other's pad is unconnected-(U7-…), so a wrong-revision image is inert on those pins, not destructive — a motor that never runs, with nothing reporting why. flash.sh refuses an unregistered die for this reason (boards/dies.tsv).

Function Board V1 (port / pad) v5 (port / pad) v5 net
Vibration motor P3.03 / A7 P1.18 / F9 /MOTOR
Active buzzer P3.07 / C9 P3.09 / K7 /BUZZER
Servo P1.02 / A8 P1.26 / J9 /SERVO
IR worn receiver P1.29 / F7 P1.16 / E9 /IR_REC
Battery voltage sense P1.00 / J7 P2.04 / B5 /BAT_DIV
IMU interrupt INT1 P1.14 / F3 P1.19 / G9 IMU_ESP_INT

Every other row below is unchanged on v5. Also changed and not a pin: VCC merged into VBAT (motor supply), and R36 removed. Not in v5: the servo-rail switch or fuse and the VBUS input protection §7 and Concepts/FCC Respin.md call for. The v5 devicetree (boards/impulse/impulse_v5/) is the authority for v5 until this section is rewritten for it. The IR receiver and battery moves also invalidate two SAADC channel indices that come from Kconfig rather than the devicetree — see the v0.19 change log.

This table supersedes firmware_spec_v2.md §1.3, which now points here.

Function nRF port Pad Net Notes
LED ring data P0.02 A6 /LED_IN Via R15 (510 Ω) to LED1.DIN. 12× SK6805-EC15. Must be driven from SPIM30, not PWM — see the note below; Zephyr's led_strip API, ws2812_spi binding.
Vibration motor P3.03 A7 Net-(U7-P303) Via R5 (1 kΩ) to Q3, a BSS138 low-side switch driving M2. R10 (100 k) pulls the gate down, so the motor is off through reset. Separate pin from the ring — the v2 §1.1 pin share is designed out. Corrected v0.8; this row said P1.02/A8, which is the servo.
Active buzzer P3.07 C9 — Direct to BUZZER1.
Wake button P0.05 D9 /WAKE_BUTTON S2 to GND, on the back of the board. Active low, needs internal pull-up. New. See §6.2.
Reset button RESET G2 /RESET S1 to GND. Whether this pad can be read as an ordinary input on the nRF54LM20A is unverified — see §11.
IMU chip select P0.00 A5 IMU_SELECT LIS3DHTR (U6).
IMU interrupt INT1 P1.14 F3 IMU_ESP_INT Motion wake source. Net name is a leftover; it is not an ESP interrupt.
SPI SCK P1.11 H4 SPI_CLK Shared bus — IMU + WM02E QSPI.
SPI MOSI P1.06 K8 MOSI Shared: IMU SDA/SDI, WM02E QSPI-DATA0.
SPI MISO P1.07 G4 MISO Shared: IMU SDO, WM02E QSPI-DATA1.
WiFi chip select P1.10 J4 /SPI-SS WM02E QSPI-SS.
IR worn emitter P0.07 E4 /IR_EMIT Via R7 (100 Ω) to KEY1 (ITR8307/S17/TR8).
IR worn receiver P1.29 F7 /IR_REC ADC input.
Battery voltage sense P1.00 J7 /BAT_DIV R21/R22 1 M–1 M divider — Vbat/2, as v2.
Charger detect P1.03 C5 /DETECT_CHRG From the TP4057 charger. New signal — v2 had no charge-state input. See §6.3.
Power disable P0.01 D2 /POWER_DISABLE Labelled but unrouted. Do not drive it expecting an effect.
WiFi VDDIO enable P3.00 K5 /VDDIO-EN Gates U2 (TPS22916) → WM02E VDDIO.
WiFi buck enable P2.08 K6 /BUCK_EN
WiFi IRQ P1.15 F2 /WIFI_INT WM02E IRQ.
Coex REQ / GRANT P1.09 / P1.12 H9 / K4 /COEX-REQ, /COEX-GRANT BT/WiFi arbitration.
Coex STATUS0 / STATUS1 P1.31 / P3.10 K9 / K2 /COEX-STATUS0, /COEX_STATUS1
Servo P1.02 A8 /SERVO To U1 pin 1. U1's 3-pin footprint is never populated — neither enclosure has room for a header, so the servo's 3-wire lead is soldered directly to the pads. U1 pin 2 is /VBAT direct — no switch, no fuse, no current limit; see §7. Anchor role only — this is the strap lock of v2 §4.9. Unused in the watch role. Corrected v0.8; this row said P3.09/K7, which is an unconnected pad.
SWDIO / SWCLK J3 / K3 To J2, TagConnect TC2030-NL.
USB D− / D+ G6 / G7 /DN, /DP Unrouted, deliberately. §1.4.

P0 is a separate peripheral domain, and it is why the LED ring driver is not the obvious one. Measured on Board V1, 2026-08-29: PWM20 reported SEQEND and a full DMA AMOUNT on every frame while driving nothing at all on P0.02. Sampling both pads during a single transfer with DECODER.LOAD=Common gave 60/199 highs on P3.05 and 0/199 on P0.02. PWM20/21/22 live at 0x500Dxxxx; P0 sits in the 30 domain at 0x5010xxxx, whose peripherals are SPIM30/SPIS30/TWIM30/TWIS30/UARTE30, COMP/LPCOMP, WDT30/31, GPIOTE30 and CLOCK. They cannot reach across. Bit-banging is not a way out either: a GPIO write to P0 costs ~25–40 cycles even with ICACHE enabled, flooring the high time at ~1.6 µs against a 0.45 µs budget.

SPIM30 does reach P0 and is the driver to use. WS2812-over-SPI at PRESCALER = 4 (4 MHz measured), five SPI bits per LED bit, 12 LEDs packing to exactly 180 bytes. T1H must be 0.75 µs — 11100, not the 11000 (0.5 µs) that the SK6805 datasheet's nominal window implies, which this chain decodes as a zero. Full measurements and two SPIM completion-flag traps are in docs/agent-notes.md, 2026-08-29.

Two diagnostics worth knowing before the Zephyr driver is written, because both invert the obvious reading: a ring that is uniformly dark and stable is receiving valid frames and decoding every bit as zero (T1H too short), not receiving nothing — these parts hold their last state on invalid signalling. And DMA completion proves the engine ran, never that the waveform reached the pad; confirm a pad by sampling IN during the transfer.

Free ports, for anything new — prefer these over loading a shared bus: P0.03, P0.04, P0.08, P0.09, P1.01, P1.17–P1.19, P1.22–P1.28, P1.30, P2.02, P2.06, P2.07, P2.09, P2.10, P3.01, P3.02, P3.04–P3.06, P3.11, P3.12. Module pads with no net at all: P0.06, P1.04, P1.05, P1.08, P1.13, P1.16, P2.00, P2.01, P2.03–P2.05, P3.08, P3.09. Corrected v0.8: P3.03 was listed here and is in fact the vibration motor; P3.09 was listed as the servo and is in fact unconnected (unconnected-(U7-P309-PadK7)).

Populated but unused in the anchor role: LED ring, IMU, IR sensor, vibration motor. This is BOM cost the anchor does not need, paid to keep one PCB — and it is what makes the single-PCB FCC scoping honest, as well as leaving the door open to anchors that report their own motion (a moved anchor is a tampered anchor — see §4.6).

The battery charger and cell are deliberately not on that list. The anchor carries a charged cell and runs on it when mains is pulled; §7 explains why that is an enforcement requirement rather than spare capability.


3. Clocks and time

3.1 The watch owns the clock

Unchanged from v2 §9.7 and it is worth restating because it constrains everything below: the watch is the root of trust for time. Enforcement windows are matched on the exact local minute (v2 §5.3), a window whose minute has already begun is never entered, and the app can propose time changes but the watch decides whether to accept them.

3.2 The 32.768 kHz crystal is inside the module

Corrected 2026-08-09 against the BM20C datasheet. An earlier draft of this section said Board V1 had no LFXO and specified three mitigations for RC-oscillator drift. That was wrong. The BM20C datasheet (Draft Ver 0.82, July 2026, datasheets/BM20C.pdf) states: "32.0 MHz and 32.768 kHz crystals embedded." Both clocks are inside the Fanstel module, so no external crystal is needed and none is missing. The "optional 32.768 kHz sleep crystal" in the integration guidance is an additional external part, not a required one.

LFXO is therefore available, and the whole class of drift problems the earlier draft worried about does not exist. The Sunrise Lock silent-skip failure mode — sleeping eight hours, waking past the boundary minute, and never entering the window — is not a live risk.

One thing still has to be done, and it is a firmware decision, not a hardware one:

Select LFXO explicitly as the LFCLK source. Zephyr picks a low-frequency clock source from devicetree and Kconfig, and the fallback when nothing is specified is the internal RC. A board definition that omits it will silently run on RC with the crystal sitting unused on the module — hardware you paid for, correctness you didn't get, and no error anywhere. Set it in the Board V1 devicetree in chunk A, and confirm it at runtime during chunk D rather than assuming the build got it right.

Keep one small guard band regardless. CLOCK_GUARD_S = 5 — wake five seconds before a boundary and poll into it. Not for drift, which a crystal makes negligible over one sleep, but because window entry is matched on the exact local minute (v2 §5.3) and a boundary missed by any margin at all is missed permanently. Five seconds is cheap insurance against scheduling jitter, wake latency, and whatever the RTC's granularity turns out to be. It is not a number that needs measuring.

Resync time on every app connection, as before.

4. Proximity — Channel Sounding

4.1 What changed, and why the chapter is short

v2's proximity chapter and its companion engine spec run to roughly 3,000 lines between them, because inferring distance from RSSI is genuinely hard and doing it against an adversary is harder. The nRF54LM20A supports Bluetooth Channel Sounding, which measures distance. Almost all of that machinery evaporates.

What remains is the part that was never about RSSI: deciding what a distance means, and what to do when you don't have one.

4.2 Topology

Both devices are the same silicon, so both support CS. Roles map to the product, not the radio:

  • Watch — CS initiator. It already owns the schedule and drives the enforcement poll; it initiates ranging when it needs an answer.
  • Anchor — CS reflector. Passive participant, no decision-making.

CS runs inside an existing ACL connection, which fits the shape of what the watch already does: v2 §5.5.2 has it connecting to the anchor over GATT to submit a vector. It now connects and ranges instead. Connection setup, session reuse, and the connect-failure path in watch_prox_transport.cpp are the parts of that file worth reading before deleting it.

4.3 The measurement

Per enforcement poll, against the anchor named by event.anchorId:

  1. Connect (or reuse a live connection).
  2. Run one CS procedure.
  3. Convert to a distance estimate in centimetres.
  4. Feed it to §4.4.

Method: prefer phase-based ranging (PBR) as the primary estimator with CS-RTT as the sanity check, which is the conventional pairing — PBR gives the finer resolution, CS-RTT is far harder to spoof because it is bounded by the speed of light. Where they disagree beyond a tolerance, treat the measurement as failed (§4.5) rather than picking one. Verify the available methods and the distance-estimation API against the NCS version you build with; this is the part of the document most likely to need adjusting on contact with the SDK.

Cadence follows the existing tiered enforcement poll (v2 §5.4.3) unchanged: 60 s when the criterion is not met, 180 s when it is. The 600 s STILL tier is deleted along with the motion channel that justified it — it existed because a motionless wrist could not produce new fading draws, which is not a thing that constrains a time-of-flight measurement. Losing it costs some battery; §11 flags re-deriving a third tier from measured CS cost as worthwhile once numbers exist.

4.4 From distance to verdict

A single threshold with hysteresis, as the log intended — plus one thing the log did not mention, which must not be dropped.

State machine. Two thresholds and two dwell counters, per anchor:

                 d < NEAR_ENTER_CM for NEAR_DWELL consecutive measurements
        AWAY ─────────────────────────────────────────────────────────────► NEAR
             ◄─────────────────────────────────────────────────────────────
                 d > AWAY_ENTER_CM for AWAY_DWELL consecutive measurements
Constant Starting value Reasoning
NEAR_ENTER_CM 200 "In the room, at the thing." Two metres is a desk, a nightstand, a doorway.
AWAY_ENTER_CM 350 150 cm of hysteresis band, wide enough that a person shifting in a chair does not oscillate.
NEAR_DWELL 2 Two consecutive bursts (v0.17; ~3.6 s each on a BM20C pair). Was counted per poll, which could not form a verdict on a link that drops every ~25 s.
AWAY_DWELL 4 Deliberately double, also in bursts. See below.
CS_QUALITY_MIN SDK-defined Below this, the measurement is an abstention, not a distance (§4.5).

Every one of these is a starting value. §11.

The asymmetry is not tuning — it is a threat-model invariant. Concluding AWAY requires twice the evidence of concluding NEAR, and this survives the move to channel sounding for a reason that has nothing to do with RSSI:

An adversary — which, on a commitment device, is the user in a weak moment — wants the device to believe they are AWAY when the criterion is stayNear, or NEAR when it is getAway. Physical attacks on a radio are overwhelmingly subtractive: shielding, blocking, a hand over the antenna, walking behind a wall. Subtraction can fabricate "far" or "no measurement". It cannot fabricate "near" — you cannot make a device appear closer than it is by attenuating anything. So the two conclusions do not deserve equal trust, and the cheap conclusion must be the one that is harder to reach.

Carried directly from proximity_engine_spec_v2.1.md §13. Do not "simplify" it away in the name of the pivot — it was not a consequence of the old algorithm, it was a constraint the old algorithm had to satisfy, and it is still a constraint.

CS narrows the attack surface but does not close it: an attacker still controls whether a link exists at all.

4.5 Abstention and failure

Below the quality floor, or on a failed procedure, the engine abstains. It does not return a distance, and it does not guess. This is inherited directly from v2's engine and remains an invariant listed in CLAUDE.md.

An abstention means "no new evidence." The state machine holds its current verdict and its dwell counters do not advance.

But abstention cannot be free, or "block the radio" becomes a bypass. Two bounds:

  • CS_ABSTAIN_MAX_CONSECUTIVE = 5. Beyond this, the watch has been unable to measure for ~5–15 minutes, which is not a glitch. It resolves to the criterion-dependent fail-safe already defined in v2 §5.4.1: resolve toward not met where the criterion is stayNear, and toward met where it is getAway. In both directions the sustained inability to measure is treated as the user's problem, not the device's, because the alternative rewards jamming.
  • A connect failure is evidence, not an error. v2 routed this through prox_note_connect_failure() as a log-likelihood; there is no likelihood scale any more, so it is simpler: a failed connect counts as one abstention against CS_ABSTAIN_MAX_CONSECUTIVE. Do not treat it as an AWAY measurement — v2 tried that and it produced a real defect (v0.9 change log, defect 1), where a stationary watch just out of range marched itself to saturation on one observation repeated.

  • A LOST LINK IS AWAY (amended v0.16, owner decision). This reverses the earlier rule that "no signal" must stay an abstention. A connect failure is still one abstention; a link that was carrying procedures this window and falls silent is different. Silence for CS_LINK_LOST_DETECT_MS = 1200 is a lost link; lost for LINK_LOST_AWAY_MS = 10000 concludes AWAY directly — not via AWAY_DWELL, and not via this section's fail-safe. Activity from before the window, or a link that never existed, is never a loss.

  • Lost-link grace (getAway only). On detection the motor and buzzer are silenced so leaving feels immediate; the LED ring is not touched and stays red, because the criterion is still unmet. A measurement completed after the loss with a true distance ≤ AWAY_ENTER_CM ends the grace and restarts the profile at once. A far measurement does not end it. With no noncompliant measurement by 10 s after the last activity, AWAY is concluded — including when the link came back but produced nothing. One grace per LINK_GRACE_COOLDOWN_MS = 120000, timed from the grace's start; inside the cooldown a loss punishes until the 10 s rule concludes AWAY.
  • stayNear gets no grace: a lost link is AWAY, which fails the commitment.

  • BLINDNESS THE WATCH INFLICTS ON ITSELF IS NOT EVIDENCE (added v0.22). While the watch has quiesced its own BLE for a §1.6.1 WiFi escalation, and for RADIO_RESUME_SETTLE_MS = 15000 after handing it back (the CS engine's scan, connect and discovery), no abstention is charged and no new link loss is detected; the last verdict holds. A loss detected before the quiesce keeps running — that one was real. Past the settle window a link that has not come back is charged exactly as before, so the fail-safe above is still reachable by real absence. Neither the fail-safe's direction nor the 2:1 dwell ratio changed — only the input. Found on the bench beside the anchor: abstain_run=6 failsafe=1 cond_met=1, ring green for the whole window, because the watch switched its own radio off with the anchor 40 cm away and then charged the silence as the user's absence — and through lost-link = AWAY as well. Lie still for 300 s before a getAway window (§6.4 then reads unworn, which escalates) and enforcement never started. impulse_enforcement_set_radio_quiesced(), host-tested.

Fail-open still governs everything above it. If there is no valid time, or no stored schedule, or the anchor is unreachable at the schedule level, the anchor fails open (v2 §4.7). Nothing here overrides that.

4.6 Threat model

Superseded from proximity_engine_spec_v2.1.md §13, which was written about RSSI. What actually changes:

Attack Against RSSI (v2) Against CS
Shield the watch / hand over antenna Fabricates AWAY convincingly Since v0.16, fabricates AWAY after 10 s by killing the link (§4.5). ifft itself resists occlusion — pillows and bodies rarely raise it — but a link that no longer exists needs no fooling. Accepted by the owner on 2026-09-13 as the cost of getAway registering a real exit; a motion (IMU) gate on the loss is the natural mitigation and is not built.
Walk behind a wall Indistinguishable from distance Attenuation may fail the procedure; a successful measurement is still a true distance.
Amplify / relay to fake NEAR Hard but conceivable CS-RTT is bounded by the speed of light. A relay adds delay, which adds distance. This is the single biggest security win of the pivot.
Move the anchor Invalidated the fingerprint silently No fingerprint to invalidate. But a moved anchor still moves the meaning of the commitment. See below.
Reflash the device Total bypass Total bypass. Unchanged, and now requires SWD and an open case (§1.4).

One attack the table does not cover, because it is not about the radio: pull the anchor's plug. §7 answers it — the anchor carries a charged cell and keeps serving CS procedures on battery. What it does beyond surviving the unplug is open, and it is the same open question as the row below.

The moved-anchor problem is now the weakest link. With fingerprints gone, nothing detects that the nightstand anchor is in the kitchen. The anchor has an IMU populated (§2) and mains power to spare, so this is cheap to close: an anchor that detects sustained motion flags itself, and the watch declines to treat a recently-moved anchor as authoritative until the user re-confirms it in the app. Not specified further here — it is a design task, not a port task, and it belongs in its own chunk after the port lands. Recorded in Todos.md.

4.7 Calibration

Calibration-v2 is deleted (CALIBRATION_V2_DESIGN.md becomes historical). It existed because a proximity score had no physical meaning, so every anchor needed a demonstrated cutoff learned from a walk-around.

Centimetres need no such thing. What remains is a per-anchor threshold override, because "near the nightstand" and "near the front door" are different distances in a way that is about the room, not the radio:

  • Defaults from §4.4 apply to every anchor.
  • The app may set near_enter_cm / away_enter_cm per anchor, bounded to a sane range.
  • The bounds are enforced on the watch, not the app, and raising them is a loosening change under v2 §9.1 — so it is quarantined for 24 h like any other loosening. A per-anchor threshold the user can widen instantly is a one-tap bypass of every stayNear commitment they have.

That last point is the whole of what calibration-v2's security story becomes, and it costs three lines instead of a document.

4.4.1 The anchor tells watches its own address — adds one characteristic

ANCHOR_LAST_IP, …4a0f001c, read-only, returns [16B anchor uuid][4B ipv4]. Read by a watch, not by the app, and it exists to delete a dependency: without it, §5.5.1's unicast path only works if the app has pushed an anchor IP table (…4a0f0017), and if it has not, the escalation falls back to broadcast — which lost roughly half its datagrams on the bench (2026-10-22). WATCH_REMOVED survives that because it re-asserts every poll; WATCH_WORN is sent once, so "the alarm will not stop" was a coin flip. An anchor that can state its own address removes the provisioning step from the critical path entirely.

It is the LAST KNOWN address, not the live one, and that is forced by §1.6.2. The anchor's WiFi is down whenever BLE has the air, and a GATT read necessarily arrives over BLE — so the live interface reads 0.0.0.0 at exactly the moment anybody can ask. A live-address characteristic here would be correct, useless, and look like a bug. The address is therefore persisted when DHCP grants it (only on change, so a renewed lease does not burn settings storage) and served from storage afterwards.

This is deliberately not folded into ANCHOR_WIFI_STAT (…4a0f000e), which also carries an IPv4 field. That one is the live interface and is lockstep with MOBILE_APP_SPEC.md; redefining its field to mean "last known" would silently change what the app reads.

The stored address can be wrong, and the broadcast fallback is what makes that safe. If the lease moved, the watch's unicast goes nowhere and it still broadcasts (§5.5.1 attempt 3). This characteristic makes the common case cheap; it does not make the fallback removable, and the fallback must not be deleted on the strength of it.

THE WATCH DOES NOT READ THIS YET, and the attempt was withdrawn (2026-10-22). The characteristic exists and is verified on the anchor; the watch-side read is not implemented. It was tried twice on the ranging link — inline in the watch-state discovery callback, then from a workqueue item holding a ref-counted connection — and in three hardware attempts produced no read, no callback and none of its own error logs. It was removed rather than left in place, because the ref-counted version released the connection only on paths that were evidently never reached: that leaks a bt_conn reference on every ranging connection, and a conn object that is never freed is exactly what makes bt_le_adv_start() answer -ENOMEM after a disconnect — the 2026-09-11 "anchor keeps dying while powered" failure.

It is also now redundant, which is why it was not pursued further: §4.6.1's ack carries the anchor's current address, which is strictly better information than the remembered one this would have fetched, and it is proven working. The remaining value of a watch-side read would be aiming the very first escalation as a unicast rather than a broadcast — worth having, not worth a connection-reference leak.

If revisited: issue the read on a connection that is fully set up, not from inside another discovery's callback, and carry the UUID from the reply rather than assuming it — the watch reaches the anchor by iBeacon-prefix match and does not reliably know which anchor it is talking to (the open §4 correctness bug), so an assumed UUID would file the address against the wrong anchor.

App-side consequence: pushing …4a0f0017 is now an optimisation rather than a prerequisite — because §4.6.1's ack teaches the watch the anchor's address within one poll, not because of this characteristic, which no watch reads yet. The four-way GATT declaration (this spec, ble_anchor.c, the app's ble_constants.dart, phone_sim/constants.py) is currently two of four.

⚠️ The UUID is not new — it is a reuse, and it collides (found by /parselogs, 2026-10-23). …4a0f001c is the watch's Calibration Control characteristic, which §4.8 deletes. It is still declared as that in impulse_app/lib/utils/ble_constants.dart:39 (watchCalibCtrlCharUuid), in phone_sim/impulse_phone/constants.py:45 (WATCH_CALIB_CTRL_CHAR), in the ESP32 watch (WatchFIrmware/src/main.cpp:284) and in v2 (the Calibration Control characteristic, §4.10.7) — and ble_uuids.h itself, eight lines below the new define, still says "0x4a0f001c — Calibration Control — is DELETED … Do not re-add it." The two sit in different services (anchor vs watch), which GATT permits, so nothing is broken on the nRF build today. But phone_sim's watch.py:460 addresses the characteristic by UUID alone, the ESP32 watch still serves the old meaning, and one 128-bit UUID now means two things in a contract declared four times. Not changed here, because choosing a UUID is a code change; the cheap fix is a fresh, never-used value (…4a0f001d) before the app learns this one. Raised in Todos.md.


4.6.1 Commands are acknowledged — adds a reply to the §4.6 wire format

The anchor replies to every §4.6 command it parses. 34 bytes:

offset field
[0] 0x80 \| original command — 0x81 acks WATCH_REMOVED, 0x82 acks WATCH_WORN
[1..16] the answering anchor's UUID
[17..32] the event UUID, echoed
[33] result: 0x00 applied, 0x01 heard but not this anchor's business

Why. §5.5.1's transport is unacknowledged UDP, and with no anchor IP known it is a broadcast, which lost roughly half its datagrams on the bench (2026-10-22). WATCH_REMOVED survives that by re-asserting every poll. WATCH_WORN is sent once on the edge, so "the user put the watch back on and the alarm kept sounding" was close to a coin flip. A watch that cannot distinguish delivery from loss cannot retry at all.

The distinction in [33] is the point. "I never heard you" and "I heard you and it was not for me" need different responses — the first is worth retransmitting, the second is the §4.6.5 guard working and must not be. Before the ack a watch could not tell them apart.

It replaces the mDNS rung, and is preferred to it. v2 §5.5.1 specifies a three-rung ladder — direct IP, then mDNS as <UUID>.local, then broadcast — and the ESP32 anchor implements the mDNS rung. The nRF port does not, and should not: both an mDNS query and the command itself require the anchor to be on WiFi at that instant, so mDNS removes no dependency, and the query is multicast and therefore rides the same unACKed low-basic-rate path that made the broadcast lossy. It would add a resolver, a responder, multicast group membership and a resolve timeout, to replace one lossy exchange with three. The ack gets the same benefit for free: the reply arrives from the anchor's current address, so the watch learns where the anchor actually is rather than where it last was. Revisit mDNS only if coexistence is ever solved and the anchor can hold WiFi up continuously — then it holds its lease and can answer reliably.

Watch behaviour. Up to 3 attempts, 250 ms apart, stopping on the first matching ack — 750 ms worst case against a 60 s poll interval. The wait uses zsock_poll() with MSG_DONTWAIT, not SO_RCVTIMEO: the first version set that option, ignored its return value, and called a blocking recvfrom, which parked the main loop until the 30 s watchdog rebooted the watch (reset cause: 0x00000010). Anything that blocks on the loop which feeds the watchdog needs a bound that cannot be silently dropped. Retrying is safe because the command is idempotent; the anchor re-evaluates every guard per packet, verified on hardware (three WATCH_WORNs produce one beep stop). An ack is matched on both the flag byte and the echoed event UUID, because a broadcast can be answered by several anchors and a stale ack from the previous poll can still be buffered. A stored address that does not answer falls through to broadcast rather than failing — that is the stale-lease case, and the ack then teaches the watch the new address.

The anchor stays silent on anything that is not a §4.6 command, so an open port does not answer arbitrary traffic, and no reply is ever larger than the request that provoked it — this cannot be used as an amplifier.

Verified on hardware 2026-10-22, anchor side, all cases: command in window → 0x81/0x82 applied; unknown event → 0x81 NOT_MINE; 5-byte datagram and unknown command 0x7f → no reply at all; and a broadcast request answered by unicast from 192.168.1.65 carrying the anchor's UUID, which is the path by which a watch with no stored address learns one.


4.8 What the app must drop — lockstep

Deleting the engine deletes wire surface. The app, phone_sim, and both firmware images must move together, and MOBILE_APP_SPEC.md §0 needs the matching flag.

Removed from the anchor GATT service:

Characteristic v2 ref
ANCHOR_PROX_VECTOR_CHAR_UUID §4.4, §6.3.1
ANCHOR_PROX_SCORE_CHAR_UUID §4.4
ANCHOR_FINGERPRINT_CTRL_CHAR_UUID §4.4
ANCHOR_FINGERPRINT_DATA_CHAR_UUID §4.4

Also removed from the anchor GATT service — added v0.7, found in the code, documented in no spec: the anchor Calibration Mode characteristic …000F. It carries the calibration-v2 phase the watch drives during a walk-around burst (NONE/INSIDE/EDGE/FINALIZE; WatchFIrmware/src/watch_prox_transport.cpp:16, and declared app-side as anchorCalibModeCharUuid). Deleting calibration-v2 (§4.7) deletes the only thing it exists for, so it goes with the rest. It appears in neither firmware_spec_v2.md nor MOBILE_APP_SPEC.md §6.2 — it shipped without ever being written down, which is why it was nearly missed here.

Removed from the watch GATT service: WATCH_CALIB_CTRL_CHAR_UUID (…001C, ble_constants.dart:39) and its status/progress reporting (v2 §5.6). Also absent from MOBILE_APP_SPEC.md §6.1.

Added: a per-anchor threshold field on the existing anchor Settings characteristic (§4.7). Prefer extending Settings over minting a new characteristic — the surface is already wide.

Unchanged and frozen: every other characteristic, the schedule blob including SCHEDULE_FORMAT_VERSION = 0x02, the UDP command packet, Watch Status, Pending Changes, Emergency Pass, Dock Register/Status, WiFi Status. The app should need no changes outside the proximity and calibration screens. That is a design goal of this port, not an accident: the app is on the critical path (launch-plan.md §12.20) and every byte of contract that moves is app work that competes with the campaign.

Clarification, not a change — the Anchor IP Table is write and notify (2026-09-12). v2 §5.6 heads that characteristic "(Write, With Response)" and then says "Responds with 0x01". Those two clauses describe different mechanisms, and the port implemented the heading: write-only, no CCC. The clients had always implemented the second one — phone_sim subscribes before writing (watch.py's _write_await), so BlueZ refused the entire operation with org.bluez.Error.NotSupported and no byte ever reached the firmware. Every sibling characteristic (…0012, …0014, …0019) was already write+notify; this one was the odd one out. The firmware now matches the clients — NOTIFY, a CCC, and a deferred ack off the Bluetooth callback context — so no client moves and this is not a lockstep change. Read the ambiguity as a warning about v2 §5.6's wording rather than about this characteristic: "responds with" means a notification everywhere else in that chapter.

App-side consequence worth naming: MOBILE_APP_SPEC §8.5's guided walk-around calibration screen loses its purpose. v2 §4.10.7 already records that this screen drives a phone-side animation that trains nothing. It should be replaced by a much smaller "set the distance for this anchor" control, not repurposed.


5. Phone docking — the one surviving RSSI feature

Unchanged from v2 §4.11. The anchor polls the connected phone's link RSSI every 2 s and declares docked below a threshold, with DOCK_UNDOCK_CONFIRM_POLLS = 3 consecutive bad samples required before believing an undock.

It stays RSSI-based for a good reason rather than by omission: the phone is not an Impulse device and cannot do channel sounding. It is also the easiest possible RSSI problem — a presence question, at very short range, over an established connection, where the failure mode is a false undock rather than a false distance.

Port the defect fix with the feature. v2 §0.1 records that ble_gap_conn_rssi() could return rc=0 with a foreign RSSI on the docking handle immediately after another link tore down, producing spurious undocks — false enforcement against a compliant user. That is a NimBLE-specific bug and the Zephyr host may not share it, but the confirm-polls guard is not a workaround for that bug alone; it is the right shape for any RSSI presence check. Keep it.


6. Watch role

6.1 State machine — deltas only

v2 §5.1 is inherited whole: UNPAIRED → DORMANT ↔ DORMANT_SLEEP → ENFORCEMENT, the connectivity flags, boot recovery, mid-day schedule update through the §9.3 gate, midnight re-init. Four deltas:

  1. DORMANT_SLEEP is a Zephyr power state, not esp_light_sleep_start(). Target System ON with RAM retention; wake sources are the RTC, the IMU INT1 on P1.14, BLE, and — new — the wake button on P0.05. Zephyr's idle/PM subsystem should own this rather than an explicit sleep call in the main loop, which is a structural change to how the loop is written and is why chunk D is separate.
  2. Wake-time guard band on every timed sleep — §3.2.
  3. The wake button is a new wake source and a new UI event — §6.2.
  4. No STILL poll tier — §4.3.

6.2 The wake button and the always-on clock

v2 §5.7.4 keeps the 12-LED analog clock lit continuously, redrawn once a minute, relying on the SK6805 latching its last frame through light sleep. That was a battery optimisation — it replaced look-to-wake, whose motion wakeups were the dominant idle drain.

Board V1 adds a user button, and the log's intent is explicit: "this button will be used to wake the watch from sleep, so the LED clock will only be on when the user clicks the button."

Specified behaviour:

  • The ring is dark in DORMANT and DORMANT_SLEEP.
  • A short press on P0.05 wakes the watch and lights the analog clock for CLOCK_SHOW_MS, then clears it. Starting value 5000 ms — long enough to read a watch face without thinking about it, short enough that a pocket press costs almost nothing.
  • Status colours (amended v0.15, at the user's request). During an enforcement window the ring is solid red while the criterion is unmet and flashes green (500 ms on / 500 ms off) while it is met, donning grace included. For the nRF build these replace v2 §5.7.1's ENFORCE_ALARM red blink and ENFORCE_IDLE steady orange. On the anchor, red means its removal alarm is sounding and flashing green means a window involving it is open and quiet. On the watch these two looks are superseded by §6.5's fourteen (v0.23); the anchor keeps them.
  • Status takes priority over the clock. During a window a short press does not replace the status with the time, and a clock already showing when a window begins is cancelled and overwritten.
  • The two states differ by pattern, not only by hue. v2 §5.7.1 chose magenta/green for its proximity pair because red/green "fails both on camera and for the red-green colour blind". Here unmet is steady and met is flashing, so the ring stays readable without relying on hue. Keep that property if the colours ever change.
  • The ring is cleared, not latched, before entering sleep. This inverts v2 §5.7.2's priority-1 rule, which explicitly leaves the clock lit through DORMANT_SLEEP.

Why this is a battery win and not a wash: v2's always-on clock costs 12 LEDs at idle brightness continuously, plus a wake every minute to redraw. This costs 12 LEDs for 5 s per glance, and the wake is the user's press rather than a timer. Twelve glances a day is a minute of ring-on time against 24 hours of it.

The long press is reserved for the hard override (launch-plan.md §11.2). Its mechanism is undecided — the two-button hold in §11.2 is not implementable here, and Concepts/Safety.md is working out what replaces it. Leave a clean seam: route all button events through one handler with short/long/very-long dispatch, and do not scatter press handling. Do not implement the two-button hold. Chunk J.

6.3 Power and charging

/DETECT_CHRG on P1.03 is new — v2 had no charge-state input and inferred charging from voltage. Two things it enables immediately, both of which the existing spec wanted and could not have:

  • The ANCHOR_REPAIR_MIN_BATTERY_MV = 4000 gate for watch-side anchor WiFi repair (v2 §5.5.3) becomes "is it on the charger", which is what it was always a proxy for.
  • Charging is a reliable "the user is not wearing this" signal, which is a cleaner input to worn detection than the IR sensor alone.

Battery sense is unchanged in principle: /BAT_DIV on P1.00, Vbat/2 through a 1 M–1 M divider. The divider values changed (v2 does not name them) — recheck the scaling constant against the schematic rather than porting the number.

Runtime requirement: 8 hours minimum. (Set 2026-08-11 by the user, replacing the 7–10 h target of 2026-08-09 and the 8–12 h figure before it.) This is deliberately a floor rather than a range, and the reasoning is that a floor is the only shape of number that can be advertised: 8 h is what the user judges guaranteeable "with enough optimizations", where 10–12 h is an aim held without a commitment behind it. Copy says 8 hours; the spec's job is to hold the firmware to it.

The only measurement behind any of it is 10 hours on the ESP32-C3 watch with the LED clock completely disabled — a different MCU, a different radio, and no clock. 8 h is therefore a budget this board has to be built to, not a property it has been observed to have, and §11 carries the two unknowns that will decide whether it holds: CS energy per procedure, and DORMANT_SLEEP sleep current. Treat a projected figure under 8 h as a defect against this section, not as a revised estimate.

Two things bear on it in opposite directions. The wake button (§6.2) replaces v2's always-on clock with 5 s per glance, which is a large idle saving. Channel sounding is new radio work that nobody has measured. The worst-case fallback, stated by the user, is that the analog clock does not ship and the ring is used only for commitment-status indication — that is a product decision held in reserve, not a plan.

BLK-04 is half discharged (2026-08-11), and the half that remains is arithmetic. The user resolved it from the copy side, which is the side that was available without a board: the 9 pm phone-distance window is deleted from the example day and the advertised figure is 8 hours. That closes the 9 pm contradiction the blocker was written about.

It does not close the example day. A watch full at 6 am and guaranteed only 8 h is flat by 2 pm, and the example day still enforces a gym-arrival window at 5:30 pm — three and a half hours past the floor. The blocker's own wording ("either the example day or the battery figure has to change") is satisfied by neither: the figure moved down and the day lost its last item but not its latest one. Three ways to close it, none of which is a firmware question: move the gym window earlier, put a top-up in the example day's afternoon, or advertise a figure that reaches it. Until one is chosen, no copy should describe the 5:30 pm window and the 8 h figure in the same breath. Tracked in Todos.md.

6.4 Worn detection on v5 — IMU stillness

v2 §5.2's worn detector is an IR reflectance pair (ITR8307 emitter + phototransistor): sample the receiver with the emitter off and then on, and use the difference, never an absolute level. That is the right design — it measures skin, and it is the only signal here that can actually distinguish a wrist from a still desk — and on the v5 board it does not work, for two independent reasons:

  1. The receiver moved from P1.29 to P1.16, so CONFIG_IMPULSE_ADC_CH_IR (AIN1, swept on the v4 board) names an unrelated input.
  2. Even with that corrected, the enclosure's own plastic reflects the emitter back at 75–90 mV against a 200 mV threshold (2026-09-10). The crosstalk floor is a large fraction of the signal.

A commitment device whose worn detector never asserts cannot run Sunrise Lock at all — §5.5.1's transport can be complete and still never fire, which is exactly what §0.4's chunk F row has said since v0.13. So on v5 the wrist signal is taken from the accelerometer instead. The LIS3DH is on the same SPI bus, works on v5 (2026-10-13), and needs no optics.

The rule is one sentence: if the watch has not moved at all for WORN_IMU_STILL_S, nobody is wearing it.

  • Each second, read the accelerometer and take the L1 distance between this sample and the last, in milli-m/s². L1 rather than Euclidean: the threshold is empirical either way, no axis is privileged, and it avoids a square root in a path that runs once a second forever.
  • Movement above WORN_IMU_MOTION_MMS2 restores worn immediately, with no dwell. Donning the watch is a movement, and any delay would hold a spurious WATCH_REMOVED alive after the user had demonstrably picked the watch up.
  • Stillness must persist unbroken. Any movement restarts the clock from zero rather than resuming it, so a watch that twitches once a minute is never declared off.

Starting values, and the measurement behind each:

Constant Value Why
IMPULSE_WORN_IMU_MOTION_MMS2 400 The sensor's own noise floor is ~150, not ~10. Measured on v5 board #3, 2026-10-13, lying on a desk at the driver's boot default (fs = 2 g, odr = 0x4, scale = 9576 µm/s² per LSB): five consecutive samples gave per-axis jitter of 0–1 LSB and an L1 sum of 114–153. 400 is a bit under 3× that, and far below any real movement — rotating the board puts a whole g (9807) onto one axis.
IMPULSE_WORN_IMU_STILL_S 300 Long enough that sitting still at a desk does not trip it; short enough that a watch put down is caught well inside a Sunrise Lock window.

This detector is strictly weaker than the IR pair, in one direction only, and the asymmetry is the design. It reliably catches the nightstand — which is the bypass Sunrise Lock exists to close. It cannot catch a watch that is unworn but being carried around: a pocket, a bag, a desk being leaned on all read as worn. Prefer §5.2's reflectance method once the optics are fixed; this is the detector that makes v5 usable, not the one that makes the product right.

The failure directions are not equal, and the timeout is biased short because of it. Declaring not-worn while worn costs a donning grace and a spurious WATCH_REMOVED: the alarm sounds on a worn watch, which is annoying and self-correcting, since any movement restores worn within one sample. Declaring worn while unworn tells the system a commitment is being honoured by a watch nobody is wearing — a silent bypass, and the whole thing this detector is here to prevent.

A sleeping wrist is the known weak case. An arm that does not move for five minutes is read as unworn. That is accepted: it fails toward sounding the alarm.

The watch's own alarm is discounted, because the motor shakes the accelerometer. The vibration motor is mounted to the same PCB as the LIS3DH, so while an alarm runs the IMU measures the watch rather than the wearer, and every sample clears the motion threshold with room to spare. Left in, that restarts the still clock once a second for as long as the alarm lasts, and a watch that is alarming can never be declared off the wrist — which is a hole precisely where it matters, since an alarm is what prompts someone to take the watch off and walk away from it.

So while the motor or buzzer is driving — and afterwards until the accelerometer itself reports the board is still again — IMU samples are discounted rather than fed in: the stale baseline is dropped and the still clock is frozen, so the alarm's duration accrues no stillness and resets none either.

The ringdown is ended by measurement, not by a constant, and that is the second attempt. The first used a fixed IMPULSE_SHAKE_TAIL_MS of 2 s and it was far too short: on v5 board #3 (2026-10-22) a motor pulse of roughly 100 ms reset a 13 s still clock, because the ERM coasts and the whole board rings for longer than the constant allowed. A bigger guess would only move the failure, so while settling the sample is still read but compared against a private baseline that never reaches the detector; masking ends on the first sample whose L1 delta is back under CONFIG_IMPULSE_WORN_IMU_MOTION_MMS2. Measured ringdown on the bench is ~3 s.

Masking is capped at SETTLE_MAX_MS (10 s), after which the sensor is believed again and a warning is logged. Masking is the "assume still worn" direction, so it must not be open-ended — a watch on a permanently vibrating surface would otherwise never be declared off the wrist at all, which is the silent-bypass direction this detector exists to prevent.

The freeze is the part worth stating, because the two neighbouring behaviours differ and the difference is deliberate:

situation baseline still clock why
gap in sampling (bench override) dropped keeps running evidence is merely absent, and absent evidence should fail toward "off the wrist"
alarm running, or still ringing dropped frozen evidence is actively corrupted; a running clock would manufacture a removal out of the watch's own buzzing

If the clock kept running during an alarm, any alarm longer than IMPULSE_WORN_IMU_STILL_S would escalate WATCH_REMOVED for a watch demonstrably on a wrist. If it reset instead, the hole above returns. Freezing costs only blindness for the alarm's duration, and Sunrise Lock's main case is untouched: a watch left on a nightstand overnight was declared off the wrist long before the window opened (§5.4.4).

impulse motor <ms> cannot be used to test this. When the watch is not enforcing, the main loop clears the motor and buzzer pins on every pass as a belt-and-braces sweep against a latched motor (a real 2026-09-10 failure), so the bench command's pin write is undone within ~100 ms and the motor never runs for the duration asked. The "while driving" half of the mask is therefore only exercised by a real enforcement profile; what a motor command exercises is the ringdown path.

Two implementation rules that are not obvious and were both got wrong once:

  • Uptime 0 is a legitimate timestamp. Using 0 as a "currently moving" sentinel for the still-clock silently adds one sample period to every timeout, and the first sample after boot — a watch booted on a nightstand, i.e. the case this exists for — is exactly where it bites. The flag is separate from the timestamp.
  • The application's belief is authoritative, and reading the detector must not advance it. The detector carries its own worn because the timeout has to be measured against something, but the application's belief can change by a route it cannot see (the bench override). It adopts the caller's state whenever the two differ, and the shell's reporting command is read-only — "just having a look" must not be a way to clear a removal.

Selected at build time: CONFIG_IMPULSE_WORN_SOURCE_IR / _IMU, defaulting to IMU on impulse_v5 and IR on impulse_v1. Which detector is built changes what a worn reading means, so the firmware prints it in the boot banner — a log that does not say which one is in use cannot be used to judge a worn=0 later in the same log.

6.5 The status ring is a diagnostic readout during enforcement (v0.23)

Every state the watch can be in during an enforcement window has its own colour, either solid or blinking (500 ms on / 500 ms off), and nothing else varies. A colour plus "blinking or not", reported by a human with no debugger attached, identifies the state exactly. Priority runs top to bottom: the first row that applies wins. Each change is logged as ring -> <look> (<meaning>), and impulse status prints the current one. Outside a window the ring stays dark (§6.2). The anchor keeps its two looks: red while its removal alarm sounds, blinking green while a window involving it is open and quiet.

Look Worn? Meaning
white blinking unworn gave up reaching the anchor (§1.6.1 give-up)
blue solid unworn WATCH_REMOVED queued, waiting for a BLE link to the anchor
cyan solid unworn on WiFi, joining or waiting for DHCP
cyan blinking unworn WiFi up, sending to the anchor, no ACK yet
magenta blinking unworn the anchor heard and refused (§4.6.1 result ≠ 0: its clock or schedule disagrees). Only detectable over UDP; a BLE write carries no result
magenta solid unworn the anchor has been told, so it should be beeping
white solid unworn nothing sent: the event has no beep anchors
blue blinking worn back on the wrist, WATCH_WORN not delivered yet (the anchor may still be beeping)
yellow blinking worn donning grace running (§5.4.4)
yellow solid worn anchor criterion, no verdict yet: ranging has not concluded, no fail-safe, no lost-link conclusion
green solid worn criterion met by measurement
green blinking worn criterion met only by fail-safe (getAway failing open, no measurement)
red solid worn criterion unmet by measurement: alarming
red blinking worn unmet by fail-safe or lost-link AWAY: alarming

"By measurement" means have_verdict with neither the §4.5 abstention fail-safe nor the lost-link rule in force. WiFi criteria always count as measured. This replaces v0.15's two looks (solid red unmet, flashing green met), which lumped a measured verdict together with a fail-safe one, and showed a watch that wrongly believed it was worn the same as one honestly alarming. Verified: builds and boots; each look is to be confirmed on the bench (§0.5 v0.23).


7. Anchor role

Same board, same firmware tree, different Kconfig. v2 §4 is inherited nearly whole. Deltas:

  • CS reflector instead of the scoring engine (§4.2). The anchor no longer computes anything about proximity — it participates in a measurement the watch interprets. This deletes anchor_prox_tasks.cpp and the anchor's entire continuous-scan role.
  • No continuous RF scanning. The anchor scanned constantly to maintain its fingerprint. Nothing needs that now. This is a large, free power and complexity saving on the mains side, and it retires the esp_wifi_scan_start() leak workaround (ANCHOR_PROX_WIFI_MAX_SCANS) by deleting its cause.
  • Servo on P1.02 (v2 §4.9 behaviour unchanged) — note the pin moved from GPIO 10, and that v0.7 and earlier said P3.09, which is wrong (§2, corrected v0.8). The lead is soldered to U1's pads, not plugged in (§2). Every anchor ships with the servo attached; there is no servo-less anchor configuration, which is why the FCC scope treats it as a permanently attached I/O cable.
  • The servo's power pin is /VBAT with nothing in the way. U1 pin 2 connects straight to the cell — the ESP32 board's Q2 load switch is not on this design. A shorted or chafed servo lead is therefore a dead short across the pack, held off only by the cell's protection IC; this destroyed an ESP32 anchor on 2026-08-10 (agent-notes.md). Two firmware consequences: the servo rail cannot be cut in software, so there is no way to isolate a faulty actuator, and a servo stall is unbounded by anything the MCU controls. A current-limited load switch or resettable fuse is a respin item, tracked in Todos.md; until then, treat servo faults as unhandleable in firmware rather than writing a recovery path that cannot work.
  • Buzzer on P3.07, not GPIO 12/21. v2's change log records an unresolved pin divergence between spec and anchor code here; it is moot now, and the loop-owns-the-buzzer rule from the 2026-07-12 anchor audit must be preserved — that fixed a real single-core race between the Identify callback and the alarm state machine. Zephyr makes this easier to get right, and easier to get wrong in a new way.
  • Power: mains-fed through the same USB-C input, with the battery populated and held charged as backup. The charger and cell are not unpopulated in this role — earlier drafts of this bullet said they were, and that was wrong. The battery is an enforcement component, not a convenience. An anchor that stops answering when its plug is pulled makes "unplug the anchor" a complete bypass of every criterion that depends on it — Sunrise Lock most obviously, since the defeat is available at exactly the moment the user most wants it. The anchor must continue to serve CS procedures on battery alone. Cell capacity and required holdover time are open (§11).
  • Mains loss is reported to the user, not to the watch. Decided 2026-08-11. Surviving the unplug is necessary and not sufficient — an anchor that runs silently until flat converts the bypass from "unplug it" into "unplug it and wait" — and the earlier draft of this bullet expected the counterpart to be a tamper signal the watch acts on. It is not. The decision, in the user's words: "the watch shouldn't do anything about an anchor that's dying, just keep treating it the same", and "it is not in our threat model to make sure the anchor gets plugged back in — users are responsible for this." Three parts:
  • The watch's behaviour is unchanged. A battery-running anchor is an anchor. No tamper flag, no degraded trust, no §9.1 interaction. This is deliberately not the moved-anchor design in §4.6 — a moved anchor lies about where it is, whereas an unplugged one tells the truth until it stops answering, and an anchor that stops answering already fails closed.
  • The app notifies the user that an anchor is on battery. This is the whole of the mitigation: it makes the bypass visible to the person who set the commitment rather than defeating it. It is an app-side requirement and is flagged in MOBILE_APP_SPEC.md §0; nothing in the firmware acts on it beyond reporting the state.
  • Holdover: 90 minutes minimum, against 1 h observed on a prototype and 2–3 h on more recent measurements (user, 2026-08-11). 90 min is a marketing floor chosen against the commitment lengths that matter — it outlasts a Sunrise Lock morning, which is the case where the unplug is most tempting — not a limit of the cell. §11's holdover row is now set from it, and the anchor cell can be sized up almost freely since there is no wrist to carry it.

Accepted, with the reasoning stated so it can be revisited: a user willing to unplug an anchor and wait 90 minutes defeats it. The user's judgement is that this is an MVP-acceptable hole — "users won't fault me too much for this" — and that it is a worse trade to spend the port's budget closing it. Revisit if the anchor unplug turns up as an actual defeat pattern rather than a theoretical one. - The IMU is populated and unused — the hook for moved-anchor detection (§4.6).


8. Storage

ESP32 NVS → Zephyr NVS via the settings subsystem, on the nRF54LM20A's internal flash.

What must survive the substitution, because behaviour depends on it:

Stored v2 reference Note
Schedule blob §5.3, §4.7 Full blob, both roles. Anchor recomputes locally on receipt/boot/midnight.
Settings §5.6, §4.4
Anchor identity (UUID) §4.2 Generated once on first boot, permanent until factory reset.
Commitment-integrity state §9.2, §9.3 Settle timers, settled baselines, the pending queue. This is security state.
Emergency-pass ledger §9.6 Allowance, remaining, regen countdowns — all on the elapsed-time basis.
WiFi credential slots (anchor) §4.5.1 4-slot table, dedup + LRU.
Per-anchor CS thresholds §4.7 New.
Battery log — dump_battlog.py reads this; see hardware-notes/battlog_usage.md.

Two things not to get wrong:

  • The §9.2 settle state is on a monotonic elapsed basis, not wall-clock — deliberately, so that changing the clock cannot promote a quarantined loosening. Zephyr's uptime counter is the natural fit; do not "fix" it to a timestamp.
  • Factory reset must clear everything above. The self-binding delay means watch clear answers 0x03 by design (CLAUDE.md), and a genuine clean start needs a real wipe. Provide the equivalent of impulse-phone serial wipe — over RTT now, and still bench-builds only.

9. GATT surface

Frozen, minus §4.8's deletions. Same service UUIDs, same characteristic UUIDs, same payload layouts, same SCHEDULE_FORMAT_VERSION = 0x02.

Implementation moves from NimBLE-Arduino to the Zephyr Bluetooth host. That is a rewrite of how characteristics are declared and how callbacks are dispatched, and none of it is a contract change. Two NimBLE-specific hazards to leave behind rather than port:

  • The host-task stack tuning (CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=6144, with its long comment about a 16-byte overflow and 6.2 KB of heap) is NimBLE's problem and does not transfer. Do not carry the number across; size Zephyr's stacks from Zephyr's own measurements.
  • Work must not run on the BLE callback context. The 2026-07-12 anchor audit found schedule storage and anchor_recalculate_day() running on the NimBLE host task, racing the loop task's readers — use-after-free and corruption, presenting as spontaneous field restarts. Zephyr has the same hazard with different names. Defer real work from GATT write callbacks to a workqueue, always.

The BEGIN-transfer length cap (bounded to 64 KB after an untrusted 4-byte length was malloc'd directly) is not NimBLE-specific — it is input validation on an untrusted wire field. Port it.


10. Port plan

Ten chunks. Each is independently buildable and independently testable, and the order is chosen so that nothing is blocked on hardware that has not arrived for as long as possible — A through C need only a DK.

# Chunk Depends on Done when
A NCS skeleton: west.yml pinned, board definition for Board V1, devicetree, builds and boots, RTT prints DK RTT shows a boot banner on a DK
B Drivers: LED ring (led_strip over ws2812_spi/SPIM30 — §2, not PWM), buzzer, motor, IMU over SPI, IR worn sense over ADC, wake button A, board Each exercised from a test shell. The ring, motor and buzzer are already known to work on Board V1 (bare-metal, 2026-08-29) — this chunk is the Zephyr driver, not the hardware question
C Storage: settings/NVS, every record in §8, factory reset A Write, reboot, read back
D Clock: RTC, wake scheduling, confirm LFCLK is actually running on LFXO (§3.2) A Sleeps 8 h and wakes inside the guard, with LFXO confirmed at runtime
E BLE peripheral: full GATT surface minus §4.8, workqueue deferral A phone_sim talks to it unmodified
F Schedule + enforcement: v2 §5.1–§5.4 behaviour, both roles C, D, E Existing schedule fixtures enforce correctly
G Channel sounding: initiator + reflector, §4.4 state machine, §4.5 abstention E, two boards Walk-in and walk-out flip the verdict at the right distances
H Commitment integrity: all of v2 §9 C, E, F The §9 acceptance behaviour reproduces
I WiFi via WM02C: QSPI transport, coex handshake, credential slots, HTTP schedule, Fanstel's certified gain/backoff Kconfigs (§1.1) B, C Anchor serves /schedule; watch matches WiFi criteria, and the build carries the grant-condition CONFIGs
J Hard override B, F, Concepts/Safety.md A mechanism exists, is specified in §11.2, and cannot be disabled

Sequencing notes:

  • G needs two boards. It is the only chunk that does, and it is the one the whole pivot exists for — so order the parts accordingly.
  • H is not optional and not last-in-priority. It is late in the dependency graph, not late in importance; it is the chapter that makes the product a commitment device rather than a reminder app.
  • J is a ship blocker (BLK-01) and is gated on a design decision, not on code. It is listed last because it cannot start until Concepts/Safety.md resolves — which is a reason to resolve that soon, not a reason to defer this.
  • The host test suite is worth rebuilding early. The deleted engine's greatest asset was that it compiled and tested on a laptop in ~1 second (proximity_engine/tests). Keep that property for the §4.4 state machine, the §9 gate, and the schedule recurrence logic — all three are pure logic, all three are where the expensive bugs live, and none of them need an nRF to test.

11. Open measurements

Every number in this document that came from reasoning rather than a bench. Each one is a starting value chosen to be defensible, not a guess dressed as a fact, and each names what would overturn it.

Value Starting Overturned by
CLOCK_GUARD_S 5 s Nothing — it is jitter insurance, not drift compensation, now that the LFXO is confirmed present (§3.2). Left here only so it is not mistaken for a measured value.
NEAR_ENTER_CM / AWAY_ENTER_CM 200 / 350 Provisionally supported, not yet earned. A line-of-sight sweep puts the instrument at ±0.20 m across 0.9–7.0 m, which separates 200 from 350 comfortably. What is still missing is the thing that would overturn them: a walk-in/walk-out corpus in a real room, with furniture and a body between the devices. Expect the band to need widening, not narrowing.
NEAR_DWELL / AWAY_DWELL 2 / 4 The same corpus. The 2:1 ratio is a threat-model floor, not a tunable — raise both together if needed, never invert.
CS_QUALITY_MIN SDK default Bench comparison against known ground truth — and there is now a better instrument than the SDK's own figure. In line of sight phase_slope ≈ 0.92·ifft + 1.97; under occlusion the walk captures gave ≈ 1.45·ifft. Reflections lengthen phase_slope while ifft can still resolve the direct path, so estimator divergence is a physically-motivated blocked-path detector. Currently a fixed 250 cm threshold; wants tuning against labelled occluded/clear data.
CS_ABSTAIN_MAX_CONSECUTIVE 5 How often CS legitimately fails at the edge of usable range. Too low and normal use trips the fail-safe; too high and jamming is free. Sharpened by measurement: the sample rate collapses with distance — ~10/s at contact, 5.7/s at 2 m, 2.5/s at 3.4 m, 0.2/s at 6.4 m — so a burst takes longer the further away the anchor is. In line of sight a dropout correlates strongly with distance; under occlusion the walk captures showed dropouts at close range, so "no signal" means far or blocked. Superseded v0.16: a lost link is now concluded AWAY after 10 s by owner decision (§4.5); this constant still governs links that never carried procedures.
IMPULSE_CS_BURST_MIN_ACCEPTED 10 — measured 2026-08-30 Resampled from the real per-station data, burst-median error sd is 0.19–0.57 m at N = 10 against 0.28–0.82 m at N = 5. The NEAR/AWAY band is 1.5 m wide, so 10 fits inside it and 5 does not. At 2 m that is ~2 s of ranging; at the edge of range, ~6 s.
CS distance calibration offset +0.97 m, provisional ifft fits 0.993 · true + 0.97 m (R² 0.9881) — correctly scaled, needing only a constant. The constant is specific to one board pair. Offsets are antenna-delay dominated and every measurement so far is Board V1 against an nRF54L15-DK, which has a different antenna, ground plane and enclosure. A second Board V1 overturns it.
CLOCK_SHOW_MS 5000 ms Whether it feels long enough to read. Pure ergonomics; decide it on a wrist.
CS energy per procedure unknown Directly determines whether a third poll tier is worth reintroducing (§4.3) and feeds BLK-04. Now the one open item that can fail the 8 h runtime requirement: the engine as written free-runs at ~9 Hz once connected, which is certainly wrong for a wrist. Measure per-procedure cost, then choose the duty cycle.
Sleep current in DORMANT_SLEEP unknown Feeds BLK-04's battery-vs-example-day contradiction. The BM20C datasheet quotes the SoC's System ON IDLE figure; the board's number includes the LED ring, the IMU, and the WM02C's leakage with VDDIO gated off.
Watch runtime 8 h minimum (§6.3) A charged Board V1 worn through a real day. The only supporting number is 10 h on the ESP32-C3 with the clock disabled — different MCU, different radio, no clock. This is now a requirement rather than a target (user, 2026-08-11) because it is the advertised figure: a projection under 8 h is a defect, not a revised estimate. Whether it reaches the example day's 5:30 pm window is a separate and still-open question — see §6.3.
Declared max WiFi TX power Fanstel's certified backoffs (§1.1) Whether the SAR route in Concepts/FCC Certification.md §5 forces a reduction to ≤ +9.8 dBm. If it does, this becomes a normative constant here and the WiFi range budget moves with it.
Whether RESET (S1) can be read as an input unknown The nRF54LM20A datasheet and UICR configuration. Affects §6.2's button seam — though the §11.2 replacement should be designed not to need it.
CS method availability and API shape ✅ answered 2026-08-30 PBR + CS-RTT are both present and both work. NCS v3.4.0 ras_* samples, step mode MODE_2_SUB_MODE_1, single antenna path; the DK controller reports HCI 6.3. This row was flagged "check this first" and was worth flagging — but the answer is the good one, so nothing downstream of it moves.
Anchor holdover ≥ 90 min (§7, set 2026-08-11) The requirement is set; what is unmeasured is whether Board V1 meets it. Observed on the ESP32 anchor: ≥ 1 h, with recent measurements nearer 2–3 h — but on a board whose cell was found flat and in protection lockout after five plugged-in days (agent-notes.md), so the charging path and the holdover figure are the same measurement. Board V1's CS-reflector role draws differently from the ESP32 anchor's continuous scanning, in the cheaper direction.
Anchor cell capacity unknown Sized from the 90 min above once V1's reflector-role current is measured. The anchor has no wrist to sit on, so a larger cell is close to free and the honest default is "bigger than the arithmetic says."