Skip to content

Agent notes

An append-only log for things a future agent (or human) would otherwise have to rediscover the hard way. Newest entry at the top.

2026-10-25 (overnight) — WiFi said "wrong password" when it meant "BLE is on the air", and the motor shakes the SWD wire off

Two findings from the session that produced the overnight brief, recorded here because neither is written down anywhere else and the first actively misleads. The fixes are in spec v3 v0.22 (§1.6.1, §4.5).

association failed (status 2) with a CORRECT passphrase means coexistence, not credentials

During an enforcement window the watch's escalation latch (v3 §1.6.1, now fixed) left WiFi retrying association while CS ranging was live — both radios on the air, which §1.6 forbids. The nRF7002 answered every attempt with wifi: association failed (status 2). Status 2 is the supplicant's wrong password. The passphrase was correct: the same credential associated in ~1.5 s once CS was out of the build, and the ! in it had separately been proven to survive the shell and the JSON parser by hardcoding past both. Hours went into the credential before anyone suspected the radio.

So: on this board a status-2 association failure is first evidence that BLE is transmitting, and only second evidence of a bad passphrase. Check that no CS link, app link or advertising is up before touching credentials. net_wifi.c now refuses to associate while any of those is up (a deferral logged as association DEFERRED — BLE still holds the air) and logs RADIO INVARIANT VIOLATED at error level if it ever finds BLE up alongside an association — so if you see status 2 without one of those lines, it really is the credential.

Why the owner heard nothing: the join breaks on the ANCHOR, twice, before any watch bug

Found by reading, then supported on hardware the same day. Neither is a watch firmware bug, which is why fixing the watch's escalation would not by itself have made the last Sunrise Lock audible.

  1. No v5 anchor image that can beep has ever been built in this tree. IMPULSE_BUZZER_DISABLED defaults to y; build-anchor-cswifi-v5/.config has it set; there is no -buzz v5 anchor build directory; the 2026-10-24 entry below records board #3 running anchor-cswifi. The only evidence a compiled-out buzzer leaves is one boot banner on RTT, which nobody reads over BLE.
  2. The nRF anchor has no way to learn the time. No SNTP in wifi.conf, no time characteristic in ble_anchor.c's GATT table (Settings carries max-beep and tz only), and the §4.7 HTTP schedule path cannot work with CONFIG_NET_TCP=n. Its wall clock is whatever an RTT impulse time once set, restored from flash as a lower bound after every reset — and this anchor runs on external power with no cell and has been seen resetting. Both impulse_app_find_active_event() (the WATCH_REMOVED guard) and the §1.6.2 fallback trigger read that clock. On 2026-10-25, with a real schedule accepted by the anchor, it never entered its WiFi fallback (advertising continuously 8 min into BLE silence) and never answered one datagram, which is what an anchor that thinks no window is open does. Proven the same afternoon with SWD on the anchor: impulse status read utc=1792919186 restored_from_flash=1 at 14:09 EDT — 9 h 03 min slow. The restore is "last saved value", so every minute unpowered becomes clock error, and a phone_sim schedule push to the anchor does not set its time.

Anchor half proven, 2026-10-25 afternoon (board #3, anchor-cswifi-buzz, clock set over RTT, a human listening): with no watch on the link, the anchor's own window opened, it entered its §1.6.2 fallback and took a DHCP lease in ~10 s; a 33-byte WATCH_REMOVED sent from the host to UDP 5555 came back as the 34-byte ACK 0x81 | 75b2aeb2… | event | 00, beep start (profile 1) was logged and the owner heard it; WATCH_WORN → ACK 0x82 … 00 and beep stop 28 s later. The 30-second check in Todos.md is therefore done, and NOT_MINE for an inactive event is now a LOG_WRN (it was LOG_DBG, which is why the clock fault was invisible). Gotcha that cost a retry: ./shell.sh "impulse time $(date -u +%s) -240" expands the date before the ~10 s RTT connect, so the anchor ends up ~10 s slow — enough to reject a REMOVED sent in a window's first seconds. Add 10, or set it from the persistent RTT session.

Not yet seen on the watch: a NATURAL IMU unworn. Every worn edge in the overnight run was the wornset bench override. On the same afternoon, board #2 (motor and buzzer compiled out, so the shake mask is inert) left untouched beside the anchor reported worn=1 7 min after its last shake, while board #3 on the same desk went unworn on schedule at 300 s. So the watch never sent REMOVED and the real window ran as "worn, near, ALARMING" — silently, because that watch image cannot sound. Unknown whether board #2's IMU read is failing (the verdict then holds) or its noise exceeds IMPULSE_WORN_IMU_MOTION_MMS2, which was tuned on board #3. Needs the watch's RTT worn_motion= line.

How to tell next time, in one step: force the UDP path (impulse csmock block on the watch) and read the §4.6.1 ACK's result byte. 0 = guards passed, the beep path ran. 1 = the anchor heard and disagrees the window is open: its clock or its schedule. No ACK at all after the anchor's 90 s silence plus a dwell = the anchor is not in its fallback, which also means it does not think the window is open.

The first DHCP exchange after association does not complete

Three of three escalations on board #2: starting DHCP right after connected, nothing, then the restart got an address in ~0.5 s. Presumably the first DISCOVER leaves before the supplicant has authorised the port. Not investigated further; net_wifi.c restarts after 5 s, which turned 20–27 s into 8.5 s edge-to-datagram. If someone makes the first exchange work, the restart becomes harmless rather than wrong.

Why CONFIG_IMPULSE_MOTOR_DISABLED exists

The vibration motor is on the same PCB as the TC2030 SWD footprint, and the TC2030-NL has nothing holding it down (§1.4). On 2026-10-24 motor bursts during enforcement tests shook the SWD connector loose twice in one session, each time ending the session because nobody was awake to reseat it. The option compiles the motor out exactly as IMPULSE_BUZZER_DISABLED compiles the buzzer out — impulse_motor_set() is a bare bx lr in the disassembly, the pin stays a high-impedance input (R10 100 k holds the BSS138 gate low; re-verified from the v5 netlist), and the note_shaking() feed to the IMU worn detector's mask is compiled out with it, because a board with no motor does not shake and the mask would otherwise freeze the still clock for vibration that never happened. Default n, ./build.sh ... --no-motor, build directory suffix -still. Never in a release image. A legged TC2030 or a retaining clip would remove the reason for it.

2026-10-24 (latest) — the anchor wedges with the PC in the vector table, and an attached debugger hides it

Reported as "the anchor has been on for a couple of hours and I can't see it in the app". It was not on the BLE air. Board #3 (FFE2F93F D56E79CE) running anchor-cswifi, found in this state:

  • SWD attached fine, FICR readable, flash verified byte-identical to the image — so not a corrupt image.
  • CPU running, not halted, but PC = 0x00000014 — inside the vector table, not code. IPSR = NoException, ICSR = 0, MSP_S still at its initial value, so no fault was taken and nothing deep was on the stack. The CPU had branched into garbage and was looping there.
  • No RTT output at all, in either direction — no log lines and no shell echo — while the RTT control block was still found in RAM. An earlier capture the same hour showed normal alive heartbeats, so it died during the session.
  • After a reset it would not boot at all; after a reflash (which changed nothing, the verify matched) it booted normally and advertised.

Reset cause on the recovery boot was 0x00000012 — software + watchdog. So the watchdog had fired at some point: this board has been crashing and resetting, not sitting up for hours. Corroborating that, the heartbeats seen before it died showed uptime_ms of only ~20 minutes against a reported couple of hours.

An attached debugger suppresses the watchdog that would have recovered it

impulse_hw_watchdog_start() calls wdt_setup(wdt, WDT_OPT_PAUSE_HALTED_BY_DBG) (src/fatal.c), which pauses the 30 s watchdog while a debugger holds the CPU. That option is deliberate and correct — SWD inspection should not trip it — but it has a consequence nobody had hit yet: while a probe is attached and halting the CPU, a wedged anchor stays wedged instead of being reset. Any session that attaches RTT, halts for registers, or kills JLinkExe mid-connection is therefore changing the failure it is trying to observe.

So soak-test stability with the debugger DETACHED. Reading RTT is not free here; it removes the recovery mechanism.

What this is probably about, and what is not yet established

Leading hypothesis is power: this anchor has no cell fitted (vbat reads 28-42 mV, §7's requirement is already recorded as unmet), it runs on external power, and §7 already notes it resets when that glitches. A supply sagging below the CPU's operating point but above the reset threshold can corrupt execution instead of resetting cleanly, which fits a PC in the vector table with no fault taken. Not established — no measurement of the rail was taken, and the crash record in .noinit was not printed on recovery, which means the fatal handler never ran (consistent with a branch into garbage rather than a trapped fault).

After the reflash it has run clean: anchor advertising (iBeacon major 0x4A0F), reflector armed, UDP listening, 3 minutes of steady heartbeats with no reboot. One mid-run re-advertise at 00:02:17 suggests a central connected and dropped.

The app's side is fine — checked, so nobody chases it

Worth recording because it was the obvious suspect and it is innocent. The anchor's advertising packet carries only flags and the 25-byte iBeacon manufacturer payload — no device name and no service UUID; the 128-bit anchor service UUID lives in the SCAN RESPONSE (anchor_sd), because the adv packet is full at 30 of 31 bytes. So anything scanning passively, or filtering on a service UUID it expects in the adv data, will never see an anchor.

classifyDevice() in impulse_app/lib/services/bluetooth_service.dart checks advertisementData.serviceUuids first but falls back to manufacturer data keyed on company id 0xFFFF, testing data[0] == iBeaconType and data[1] == iBeaconLength. The firmware overwrites the company id to 0xFFFF and leaves 0x02, 0x15 next, and the map value excludes the 2-byte company id, so the fallback matches on 23 bytes. That path is what makes the anchor classifiable at all, and it is correct. Do not "fix" the app's scan filter over this symptom.

2026-10-22 — the Sunrise Lock removal chain runs end to end on hardware, §1.6.2's fallback is verified, and the watch stops believing its own vibration

First time the whole §5.5.1 chain has run on real hardware without a human standing in for any link. v5 board #3 (die FFE2F93F D56E79CE) flashed watch-cswifi-buzz, no anchor present, a silent getAway demo window open, the board left untouched on a desk:

worn -> 0 (imu-stillness, diag 154)      <- 300 s of stillness
BLE off the air (WiFi escalation)
wifi: link REQUESTED
udp: cmd 0x01 deferred — no association yet
wifi: associating with "Kompsci LAN"...
wifi: connected to "Kompsci LAN"
wifi: address 192.168.1.152
udp: cmd 0x01 -> broadcast

and on this host, decoded off the wire: len=33 cmd=0x01 WATCH_REMOVED watch=75b2aeb2… event=d0d1d2d3…, repeating every 60 s on the enforcement poll. The ~3 s of deferred — no association yet between the worn edge and the first datagram is exactly what §5.5.1's per-poll re-assertion exists to cover, and it covered it.

Broadcast UDP loss on this LAN is heavy, and WATCH_WORN is one shot

The thing worth recording. Counted against the firmware's own udp: cmd 0x… -> broadcast log lines, over one toggle burst plus the run above:

command firmware sent host received
0x01 WATCH_REMOVED 9 5
0x02 WATCH_WORN 4 2

Roughly half the broadcasts never arrive. This is the attempt-3 broadcast fallback (255.255.255.255), taken because no app had provisioned an anchor IP — a unicast to a known anchor would very likely do better, and that is untested.

The loss matters asymmetrically, and not in the direction the code comments assume. WATCH_REMOVED re-asserts on every poll, so losing half of them costs at most 60 s. WATCH_WORN is sent once, on the edge only, with the stated reasoning that "stop beeping is not something to repeat — the anchor obeys it at once." On this measurement that single datagram is lost about half the time, so the alarm plausibly keeps sounding after the user has put the watch back on, until the anchor's own max_beep_minutes expires. §6.4 calls a spurious removal "annoying and self-correcting"; it is only self-correcting if the stop message arrives. Cheapest fix is to send the worn edge 3× back-to-back, or to re-assert it for a bounded few polls — it is idempotent either way.

The watch's own vibration masked the stillness detector — fixed

The first attempt at the run above used a stayNear window, which fails closed with no anchor and so ran the motor and buzzer. Five minutes of stillness produced no removal at all (worn=1 throughout) where the silent window produced one in 300 s.

That run is not clean evidence, and the owner said so: during the loud window they were holding the SWD connector by hand the whole time, and during the quiet one a soldering helper arm held it. So a human nudge is not excluded and probably explains that particular run.

The mechanism is certain anyway, which is why it is fixed. The ERM motor is mounted to the same PCB as the LIS3DH. While it runs the IMU measures the watch, not the wearer, and every sample clears the 400 mm/s² threshold easily — so the still clock restarts once a second for as long as the alarm lasts, and a watch that is alarming can never be declared off the wrist. That is a hole exactly where it matters: an alarm is what makes someone take the watch off and walk away.

Fix (v3 §6.4, spec v0.21): while the motor or buzzer drives, and afterwards until the accelerometer says the board is still again, IMU samples are discounted — baseline dropped, still clock frozen, capped at 10 s.

The freeze is the non-obvious half. There was already a resync path for a gap in sampling which drops the baseline but keeps the clock running, on the reasoning that absent evidence should fail toward "off the wrist". An alarm is different: the evidence is not absent but corrupted. A running clock would make any alarm longer than 300 s manufacture a WATCH_REMOVED for a watch demonstrably on a wrist; a reset clock brings the original hole back. Freezing costs only blindness for the alarm's duration.

A fixed ringdown tail was tried first and measured wrong. Two bugs, both found only because the freeze was measured against a calibrated baseline rather than eyeballed:

  1. The tail never covered the driving period. The first version stamped now + TAIL when an output switched on and tested that one deadline, assuming it would cover both driving and ringdown. Nothing refreshes it while the output stays on, so masking expired mid-alarm and there was no ringdown tail at all.
  2. 2 s of tail was far too short anyway. A motor pulse of roughly 100 ms still reset a 13 s still clock. The ERM coasts and the board rings well past 2 s.

So the tail is gone as the mechanism. While settling, the sample is read but differenced against a private baseline that never reaches the detector; masking ends on the first sample whose L1 delta is back under the 400 mm/s² threshold, capped at SETTLE_MAX_MS (10 s) because masking is the "assume still worn" direction and must not be open-ended.

Measured, v5 board #3, two runs, against a baseline growth of 10.006 s over the same command spacing:

still clock
fixed 2 s tail, 10 s motor command reset to 0.989 s
settle-to-noise-floor, same command 78.651 → 90.646 s, and 143.966 → 155.970 s

Mechanical ringdown on the bench comes out at ~3 s.

impulse motor <ms> cannot hold the motor on, and this wasted an hour

Worth knowing before trusting any bench measurement that involves the motor. cmd_motor does impulse_motor_set(true); k_msleep(ms); impulse_motor_set(false), which looks like it drives the motor for ms. It does not. When the watch is not enforcing, the main loop clears the motor and buzzer pins on every pass (main.c, the else branch of the enforcement block) as a belt-and-braces sweep against a motor latched on by a mid-burst schedule swap — a real failure from 2026-09-10. That sweep undoes the shell's pin write within ~100 ms.

Consequence: impulse motor 10000 produces a ~100 ms jolt, not 10 s of drive, and the "while driving" half of the §6.4 mask is only exercisable by a real enforcement profile. A motor command exercises the ringdown path and nothing else. The symptom that exposed this was a freeze of exactly 2.0 s — the tail alone — for both a 3 s and a 10 s command.

§1.6.2's anchor fallback works on hardware, and survives a dead WiFi chip

Verified on board #2 running anchor-cswifi, with a demo window open (the demo names the anchor itself on the anchor role, which is what makes this exercisable at all):

00:04:06  radio: no BLE from a watch for 90s inside an open window — BLE off, listening on WiFi
00:04:06  anchor: BLE off the air (WiFi dwell)
00:06:06  radio: WiFi released — BLE back on the air
00:06:06  anchor advertising (iBeacon major 0x4A0F)
00:06:36  radio: no BLE from a watch for 90s ... — BLE off, listening on WiFi

Dwell 120.02 s, re-listen 30.01 s, both within a sample of nominal. The advertising line is the one that matters — an anchor that comes back from a WiFi dwell without advertising is the 2026-09-11 "anchor keeps dying while powered" failure wearing a different hat, and it does come back.

It ran this cycle with a dead RPU (board #2, below) and did not care: 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 kept printing and BLE kept returning on schedule. Worth knowing because it means the fallback cannot wedge the anchor off the air even if WiFi fails outright — but note the converse: it also cannot shorten a dwell that is achieving nothing.

Board #2: the WM02C's pins were the fault, and more solder fixed it

Narrows a claim in CLAUDE.md, which says the shared spi21 bus was dead on boards #1 and #2. On board #2 (FECC0772 A9478432), measured 2026-10-22:

  • lis2dh: fs=2, odr=0x4 lp_en=0x0 scale=9576 — the IMU initialises and reads normally, and worn_motion tracks the usual 38–152 noise floor.
  • RPU comms test: sig failed: expected 0x42000020, got 0xffffffff, then nrf_wifi_if_start_zep: nrf_wifi_fmac_dev_add_zep failed — the nRF7002 never answers.

The owner then added solder to the SPI pins and both work. Same board, later the same day: lis2dh initialises as before, and wifi scan returns a real list of 17+ APs with no RPU comms test failure at all. Board #2 subsequently associated on WPA2-PSK, took 192.168.1.65 by DHCP and received §4.6 datagrams.

Two faults, one at each end of the bus — and the obvious inference is wrong

Correcting an inference recorded earlier in this very entry. Seeing the IMU work while the RPU answered 0xffffffff, I concluded the bus was sound and the fault was solely at the WM02C's pins. The owner's account is that the v5 WiFi failures had two independent causes:

  1. The BM20C's BGA grid, which is hard to solder and was bad on more than one board. Reworked over the week to 2026-10-22.
  2. The WM02C's SPI pins, fixed separately on 2026-10-22.

Both had to be fixed, and in that order — which is why the "IMU works, so the bus is fine" reading looked right: by the time that measurement was taken, the BM20C end had already been repaired.

So a working IMU does not exonerate the shared bus. The BM20C end can be marginal in ways the IMU tolerates and the nRF7002 does not — different clock rate (8 MHz for the RPU), different CS line, much longer transactions. The narrow claim that survives is conditional: given a known-good BM20C, an RPU returning 0xffffffff points at the WM02C's pins. Without that precondition, suspect both ends, and reflowing only the WiFi side can leave you chasing a fault that is half upstream.

Practical consequence for any future v5: reflow the BM20C BGA first, because a marginal BGA makes every downstream measurement ambiguous.

The chain is joined: a removal RECEIVED because of the fallback

Closed 2026-10-22, after the owner added solder to board #2's SPI pins and the WM02C came alive (below). Anchor on board #2 (anchor-cswifi), demo window open, no watch in BLE range:

00:21:25  radio: no BLE from a watch for 90s inside an open window — BLE off, listening on WiFi
00:21:27  wifi: associating with "Kompsci LAN"...
00:21:28  wifi: connected to "Kompsci LAN"
00:21:29  wifi: address 192.168.1.65          <- 3.2 s from decision to usable IP
00:21:41  beep start (profile 1)              <- WATCH_REMOVED acted on
00:22:42  cmd: WATCH_WORN — stopping alarm
00:22:42  beep stop

The 33 bytes sent were byte-identical to what v5 board #3's watch firmware was observed emitting earlier the same day (cmd=0x01, watch=75b2aeb271b642ae9c2a3a80982e368c, event=d0d1d2d3d4d5d6d7d8d9dadbdcdddedf), replayed from a host. That works without weakening anything because impulse_app_install_demo() uses the same synthetic 0xD0.. event id on both roles and the anchor's demo puts its own UUID in beepAnchors, so every §4.6 guard was evaluated for real.

Association inside a dwell is fast enough. 3.2 s from the fallback decision to a DHCP address, against a 120 s dwell — so the dwell is not at risk of ending before the link it exists for is usable.

Unicast does not share the broadcast path's loss. 3 of 3 0x01 and 3 of 3 0x02 arrived, against roughly half for the 255.255.255.255 fallback measured earlier. That supports treating the loss as a property of broadcast rather than of the LAN, and it is a reason to get anchor IPs provisioned rather than to lean on the broadcast attempt. Re-sends were correctly idempotent: three WATCH_WORN logged three times, one beep stop.

The one seam still not exercised by a real watch is a watch whose schedule names this anchor. Both demos use the same event UUID, but the watch's demo puts the synthetic 0xA0.. UUID in beepAnchors, which a real anchor rightly rejects — the guard working. Closing that 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 now proven on hardware.

Why §4.6 got an ACK instead of the mDNS rung the spec specifies

v2 §5.5.1 defines three rungs — direct IP, mDNS as <UUID>.local, broadcast — and the ESP32 anchor implements mDNS. The owner asked whether the nRF port should too, since a stable name would survive a changing DHCP lease. It should not, and the reasoning is worth keeping because the idea will come back:

  • mDNS removes no dependency. Both an mDNS query and the command itself require the anchor to be on WiFi at that instant, which under §1.6.2 is only during a fallback dwell. The hard constraint is unchanged.
  • The query rides the same lossy path. mDNS is multicast to 224.0.0.251; WiFi multicast is unACKed, sent at a low basic rate, and often buffered by the AP for power-save clients — the properties that made the measured 255.255.255.255 broadcast lose ~half its datagrams. So mDNS replaces one lossy exchange carrying the command with a lossy query, a response, and then a unicast. More legs, same medium, and any failure falls back to broadcast anyway.
  • It costs a resolver, a responder, multicast group membership and a resolve timeout in the escalation path, on a tree whose RAM hit 99.46% once.

Revisit mDNS if coexistence is ever solved and the anchor can hold WiFi up continuously — then it keeps its lease and answers over an established association, and mDNS becomes the right answer.

What was built instead: an acknowledgement (§4.6.1). The anchor already has the watch's source address from recvfrom on the socket it is sitting on, so a reply costs one sendto and no new machinery. It fixes three things at once:

  1. WATCH_WORN can be retried until acknowledged instead of fired once and hoped for. This was the actual user-visible bug — the watch goes back on the wrist and the anchor keeps sounding — and neither addressing scheme fixes it.
  2. The watch learns the anchor's current address from the reply's source, which is the staleness fix mDNS was wanted for, without mDNS.
  3. The watch can finally tell delivery from loss, which it previously could not do at all.

Two design points that are not obvious:

  • The result byte distinguishes "never heard you" from "heard you, not mine". Only the first should be retransmitted; the second is the §4.6.5 guard working. Without that distinction a watch would hammer an anchor that is correctly ignoring it.
  • The anchor stays silent on anything that is not a §4.6 command, and no reply is larger than the request. An open port that answered arbitrary traffic would be an amplifier.
  • A stored address that does not answer falls through to broadcast instead of failing. That is precisely the stale-lease case, and the ack then teaches the watch the new address.

Anchor half verified on hardware across all five cases: in-window WATCH_REMOVED and WATCH_WORN → 0x81/0x82 APPLIED; unknown event → 0x81 NOT_MINE; a 5-byte datagram and an unknown command 0x7f → no reply at all; and a broadcast request answered by unicast from 192.168.1.65 carrying the anchor's UUID — the path by which a watch with no stored address learns one. The watch half builds clean for every role/revision combination but needs a board swap to exercise.

Waiting for a reply on the main loop rebooted the watch — use poll(), not SO_RCVTIMEO

The §4.6.1 ack has to be waited for, and the first version did it the obvious way: setsockopt(SO_RCVTIMEO) then a blocking recvfrom, with the setsockopt return value ignored. First escalation on hardware:

udp: cmd 0x01 -> broadcast
...
reset cause: 0x00000010 (watchdog)

If the option is not honoured the socket stays blocking, so a broadcast nobody answered parked the main loop in recvfrom until the 30 s hardware watchdog rebooted the watch. The intended bound was 3 × 250 ms.

This is the same shape as the SO_BROADCAST lesson already in net_udp.c — ignoring a socket option's return value on this stack, where it answers ENOPROTOOPT and then does something reasonable anyway. There the consequence was a misleading log; here it was a reboot.

Use zsock_poll(), which takes its timeout as an argument so the bound cannot be silently dropped, and read with MSG_DONTWAIT so the read cannot block even if poll misbehaves. Two independent guarantees, which is the right standard for anything running on the loop that feeds the watchdog.

Generalisable: this firmware's main loop both drives enforcement and feeds a 30 s watchdog, so any new blocking call there is a reboot risk, and "it has a timeout" is not good enough if the timeout is set out-of-band.

§4.6.1 measured: retries are doing real work, and the unicast path self-heals

Watch half verified on hardware with a host standing in for an anchor (replying with the UUID impulse_app_install_demo puts in beepAnchors, so the §4.6 lookup matches):

00:09:26  udp: cmd 0x01 -> broadcast
00:09:26  learned anchor a0a1a2a3-... at 192.168.1.237     <- ack on ATTEMPT 2
00:09:26  udp: cmd 0x01 ACKed by 192.168.1.237 (result 0, attempt 2)
00:10:26  udp: cmd 0x01 -> 192.168.1.237                   <- unicast now
00:10:26  udp: cmd 0x01 ACKed by 192.168.1.237 (result 0, attempt 1)

Two things worth keeping:

  • The retries are not theoretical. Across four observed polls the acks came on attempts 2, 3, 3 and 2 while broadcasting — consistent with the ~50% broadcast loss measured earlier. Without retry, most of those escalations would simply have failed, and for WATCH_WORN that is an alarm that does not stop.
  • Addressing self-heals in one poll. Broadcast → ack → learn → unicast thereafter, and the unicast round trip was 73 ms against 282 ms for the broadcast. A moved DHCP lease costs one poll, with no mDNS and no app provisioning.

Also fixed: impulse_app_learn_anchor_ip() logged "learned anchor ... over BLE" unconditionally, which was a flat lie for every address learned from an ack. The message no longer names a transport, because two call paths reach it.

§4.4.1's watch-side read was withdrawn — never ran, and leaked a conn ref

Tried twice on the CS ranging link with the anchor powered beside the watch: inline in the watch-state discovery callback, then from a workqueue item holding a ref-counted connection. Three hardware attempts produced no read, no callback, and not one of its own error logs — even after those were raised from DBG to WRN specifically so a failure could not hide. CONFIG_IMPULSE_NET=y, the code was compiled, the call site was correct, and the preceding discovery logged anchor watch-state characteristic at handle 38 every time.

Cause not established. The first version violated the rule this GATT surface already paid for four times (v3 §0.4 chunk E: do no real work on a Bluetooth callback context) by calling bt_gatt_discover() from inside another discovery's callback. Deferring to the system workqueue did not fix it, and neither did capturing a ref-counted bt_conn instead of reading the engine's connection global, which is not yet assigned during discovery.

It was removed rather than left in, and that is the part worth keeping. The ref-counted version called bt_conn_unref() only on paths that were evidently never reached, so it leaked a connection reference on every ranging connection. A bt_conn that is never freed is precisely what makes bt_le_adv_start() answer -ENOMEM after a disconnect — the 2026-09-11 "anchor keeps dying while powered" failure, which took two days. A silently non-working feature that also leaks a conn ref is strictly worse than no feature.

It is also redundant now: §4.6.1's ack carries the anchor's current address, which beats the remembered one this would have fetched, and it is proven. The only thing lost is aiming the very first escalation as a unicast instead of a broadcast — and the ack makes that self-correct within one poll.

If revisited: issue the read on a connection that is fully set up, not from inside another discovery's callback, and release every reference on every exit path before trusting it.

Worth recording because it was the live regression risk. With the anchor powered next to the watch and a window open, the watch-state discovery runs and ranging is healthy and continuous:

anchor watch-state characteristic at handle 38
cs burst DONE: n=10 rej_range=0 rej_qual=0 relay=0 miss_ifft=0 -> result=0 dist=103 cm

rej_range=0 rej_qual=0 relay=0 miss_ifft=0 across every burst, bursts every 2-3 s, distance stable. The spec's long-standing "untested with a ranging procedure actually in progress" caveat for GATT concurrency is now at least partly discharged for a discovery and a characteristic write.

impulse wipe — because factory reset was dead code

impulse_storage_factory_reset() had been implemented in the storage layer and was reachable from nowhere. Without it, the first schedule push from the app onto any board that has ever held a schedule 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. It is now impulse wipe on the RTT shell.

It clears the schedule, the §9 integrity records, the pending queue, the elapsed base and the timezone, and deliberately keeps the WiFi credentials, the anchor IP table and the anchor's own remembered address: those are expensive to re-provision on a board with no UART and none of them feed the integrity gate. Verified: schedule=0 event(s), and credentials still restored after a reset.

The watch's WiFi escalation is STICKY, unlike the anchor's — §1.6.1 asymmetry

Found while trying to test §4.4.1, and it is a real design gap rather than a bug in a single function.

§1.6.2 gave the anchor a duty cycle for an explicit reason: "going to WiFi and staying there makes the fallback one-way", because the watch could come back into range and have no way to reach an anchor that is no longer listening on BLE. The watch never got the same treatment. Once escalate_to_anchors() reaches for WiFi, watch_radio_set_wifi(true) quiesces BLE and stops ranging, and the only things that put BLE back are a successful BLE write (impossible — BLE is off), the window ending, or IMPULSE_ESCALATION_WIFI_GIVEUP_MIN (default 10 minutes).

Observed: with the anchor powered inches away, the watch escalated over WiFi and stayed there, never seeing the anchor over BLE, for as long as the window was open. Consequences while escalating:

  • No ranging at all, so proximity abstains — for stayNear that fails closed, which is safe but means the commitment is being enforced on no measurement for up to 10 minutes.
  • The watch cannot notice an anchor returning to BLE range.
  • Forcing worn also escalates (a WATCH_WORN), which grabs the air again, so even "the user put the watch back on" does not release BLE promptly.

Not fixed. The obvious remedy is the one §1.6.2 already uses — duty-cycle the watch's WiFi against a short BLE re-listen — and it should be a deliberate decision, not a patch made mid-session.

An anchor can never advertise a LIVE IP over BLE, and that shapes the feature

Asked to make the anchor advertise its IP over BLE so the watch learns it without the app provisioning an IP table. It did not exist. The interesting part is why the obvious version of it cannot exist.

The two radios are mutually exclusive in time (§1.6.2), so the anchor has no address at any moment it can be asked for one. WiFi is torn down whenever BLE has the air, and a GATT read necessarily arrives over BLE, so the live interface reads 0.0.0.0 every single time. This is not a bug to fix — it is the coexistence constraint doing its job.

Worth knowing: ANCHOR_WIFI_STAT (…4a0f000e) already carries an IPv4 field in its §4.4 payload, from impulse_wifi_ipv4(), which reads the live interface. So the anchor has looked like it already had this feature since the GATT surface was written, and it has always returned 0.0.0.0 to anyone reading it over BLE. If the app has ever displayed an anchor IP of 0.0.0.0, that is why.

So the feature has to be last known address, persisted — new characteristic ANCHOR_LAST_IP (…4a0f001c), saved when DHCP grants it and served from settings thereafter (v3 §4.4.1). Consequences worth keeping:

  • It can be stale, so the §5.5.1 broadcast fallback stays. On a home LAN the same MAC almost always gets the same lease, but "almost always" is not a guarantee, and a moved lease means the unicast goes nowhere. Do not delete the broadcast attempt on the strength of this characteristic.
  • The reply carries the anchor's UUID, rather than the watch assuming it knows who it is talking to. The watch reaches the anchor by matching an iBeacon prefix, and the open §4 bug is that this may not be the anchor the event names — so an assumed UUID would file the address under the wrong anchor and send every future escalation to the wrong box.
  • The read is chained after the watch-state discovery, not run beside it: Zephyr allows only one discovery at a time per connection, and two in parallel get -EALREADY.
  • Learn while in range, use when out of range. The watch reads it over the ranging link, which is available precisely when the escalation is not needed, and stores it for when the link is gone — which is the Sunrise Lock sequence.

Anchor half verified on hardware (board #2): saves on acquisition (192.168.1.65), survives a reset, and a reacquisition of the same address correctly does not rewrite storage. The watch half is built and builds clean for both revisions but needs a board swap to exercise.

The LIS3DH's noise floor is ~150 milli-m/s², not ~10

This is the number the whole stillness detector rests on, and it is an order of magnitude larger than the obvious guess. Measured on v5 board #3 lying untouched on a desk, at the driver's boot default (fs=2, odr=0x4, scale=9576): five consecutive samples gave per-axis jitter of 0–1 LSB and an L1 sum of 114–153 milli-m/s². Live in the running firmware the same figure appears as 76–191.

One LSB at ±2 g in the driver's normal mode is ~9.6 mm/s², so single-LSB jitter on each of three axes already puts the L1 sum near 30, and in practice it reaches 150. A motion threshold picked by intuition — "a few tens of milli-g" — would sit below the floor, and the watch would then never be still and never be declared off the wrist. The shipped threshold is 400.

The coexistence failure reproduced on a board with proven-good hardware

Worth recording because it removes the last alternative explanation. On 2026-09-12 both radios dying together could still have been blamed on the hand-rework that was wrong on boards #1 and #2. On 2026-10-13 v5 board #3 — freshly reflowed, IMU working, WiFi scanning 23 APs, DHCP address, ICMP answered — held BLE (CS reflector advertising) and WiFi together for 4 m 40 s and then lost three consecutive pings, with:

  • no error in the log,
  • no NET_EVENT_WIFI_DISCONNECT_RESULT,
  • no RPU message,
  • the application heartbeat still printing on schedule.

The silence is the signature: the radios stop and nothing notices. (The run was cut short because the SWD wire moved to another board, so this corroborates the measurement rather than repeating it.) Do not spend time looking for a solder or a layout cause for this.

Two bugs in the stillness detector that a passing build hides

Both found by host tests and both would have been invisible on hardware.

  1. Uptime 0 is a legitimate timestamp. Using 0 as the "currently moving" sentinel for the still-clock silently adds one sample period to every timeout — and the case it bites is the first sample after boot, i.e. a watch booted on a nightstand, which is the exact scenario the detector exists for. The flag is now separate from the timestamp.
  2. A gap in sampling is not a movement. The §5.2 bench override (impulse wornset 1) stops the application calling the detector at all. Handing control back with wornset auto after 70 s then produced a single delta large enough to clear the 400 threshold on a board nobody had touched — observed live, as a still clock that read 149 s when the board had been undisturbed for 215 s. The detector now discards a baseline older than 5 s without restarting the still clock, because a stale baseline is evidence of nothing and holding the clock fails toward declaring the watch off, which is the safe direction.

The anchor's WiFi fallback has to be two-way, and armed only inside a window

Both of these are the same old failure wearing a new hat, and either is easy to write wrong:

  • 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. That presented for two days as "the anchor keeps dying while powered" (see 2026-09-11 below). Off-window the anchor is on BLE unconditionally, whatever the silence logic would prefer.
  • Duty-cycled, not committed. WiFi is where WATCH_REMOVED arrives, but BLE is the only way the watch can come back. An anchor that took WiFi and held it for the window could never be told to stop beeping by a watch that had returned and been put on. 120 s WiFi / 30 s BLE.

And one that is not obvious at all: the watch's ranging gate re-evaluates every main-loop pass from the enforcement state alone, so turning CS off for a WiFi escalation is undone one iteration later unless the gate carries an explicit && !wifi_on_air term. Both radios go back on the air immediately, with nothing in the log. The two radios are swapped in exactly one function per role for this reason.

2026-10-13 — v5 board #3: the shared SPI bus fault was solder, and reflowing onto a fresh PCB cleared both the IMU and WiFi

FFE2F93F D56E79CE is a third v5 board — a bare v5 PCB with the BM20C and the WM02C re-soldered onto it on 2026-10-13, after hand-rework on boards #1 and

2 had only partly recovered the spi21 bus. Registered in

nrf_firmware/boards/dies.tsv as v5. Note the die moved with the module: DEVICEID identifies the BM20C, not the PCB, so re-soldering a module onto another revision would silently invalidate an existing dies.tsv row.

On the first boot of anchor-wifionly on v5, both previously-dead devices came up with no firmware or devicetree change of any kind:

  • lis2dh: fs=2, odr=0x4 lp_en=0x0 scale=9576, and impulse imu 5 returned five live samples (x≈1.70, y≈0.48, z≈−9.52 m/s², |a| = 9.66–9.70) — repeated transactions with CS toggling, not one boot-time read.
  • No RPU comms test: sig failed. wifi scan returned 23 APs across channels 1/6/11, so the SPI bus, the nRF7002 firmware-patch upload, the RPU boot and the radio receive path all work.

So the entire v5 "WiFi + IMU are broken" episode was solder, on the shared spi21 bus, at the BM20C end — exactly the hypothesis the owner proposed. Nothing in the port, the pin map or the board design was ever at fault. The corollary for the next similar failure: when two independent devices on one bus both fail and the bus is shared, suspect the shared joints before the devices, and prefer reflowing a fresh board over iterating rework on a suspect one.

Still open on this board: no cell is fitted (batt=0%), and the SAADC Kconfig channels are still V1's, so worn detect does not assert (worn=0, though AIN1 does swing −95 mV in the boot sweep). WPA2 association is untested here — credentials are runtime-only by design, never in the tree.

2026-10-12 — the LIS3DH holds MISO high by design, and five single-ended "tests" all mistook it for a fault

Context: WiFi and the IMU both fail on both v5 boards — lis2dh: Invalid chip ID: ff and RPU comms test: sig failed: expected 0x42000020, got 0xffffffff. Firmware was proven good on v4 with the identical spi21 setup, so the hunt was for a board fault.

The single most useful finding is a measurement technique, not a fault.

U6 pin 7 is SDO/SA0 and pulls MISO high on every healthy board

The IMU is a LIS3DHTR, and pin 7 is dual-function. With its chip select high the part is in I2C mode, where pin 7 is the SA0 address-select input carrying an internal pull-up that is connected out of reset (CTRL_REG0, bit SDO_PU_DISC = 0). Measured with the WM02C physically removed from the board: MISO sat at 3.2 V unloaded and 1.7 V against the nRF's ~13 kΩ internal pull-down. 1.7 V is not a logic level — it is a divider between two comparable resistors, and reading it as one is what gave the game away.

Consequences, both directions:

  • MISO reading high is not a fault, and cannot be used as evidence of a bridge on any board with the IMU fitted.
  • It is positive evidence: only a powered IMU with a sound pin 7 joint can source that current. The 3.2 V/1.7 V pair, both taken at the WM02C's pad, proved all three ends of the MISO net continuous at once — the IMU end because something sourced the current, and the BM20C end because the host's internal pull-down could load it.
  • A test must hold IMU-CS high. CS low puts the part in SPI mode where pin 7 becomes a push-pull output and actively fights the test for the net.

The ST datasheet is still missing from datasheets/ (README.md already flags this); the register detail came from ST's application notes plus measurement.

An open net is not observable from one end. Five tests learned this the hard way

Every one of these reported a confident wrong answer before being fixed:

  1. device_is_ready() returned true on a board whose module answers nothing — nRF70 net-device init only registers the net_if; the RPU is brought up later in nrf_wifi_if_start_zep. Fixed to net_if_flag_is_set(NET_IF_UP).
  2. A chip select left asserted during a MISO measurement — a selected slave drives MISO, so a healthy net read as "shorted to VDD".
  3. The nRF7002's own pull-up on CLK read as a bridge (asserting the module rails flipped CLK from OK to HIGH, which is what exposed it).
  4. The LIS3DH's SA0 pull-up read as a bridge, as above.
  5. The new .bringup --wifi short detector flagged a CLK–MISO bridge on a good board on every step (P1_IN = 0x880 with CLK alone driven). Fixed by sampling a baseline with nothing driven and reporting only pins that change, which self-calibrates against any external pull.

The general rule: a floating net follows whatever internal pull you apply, which is exactly what a healthy net does. So "OK" never proves connected and "held" never proves bridged. The only conclusive test is two-ended — drive a known level at one end, look for it at the other, and toggle it so the far end has to follow. That is now .bringup --wifi (chip drives, meter watches) and --wifi-touch (you drive, chip watches). In the Zephyr tree the equivalent is wm02c drive <line> <0|1> plus wm02c held.

A driven pad is only a RAM configuration and does not survive a reset — a reboot hands the pads back to the SPIM and silently drops the hold, leaving the net floating, which meters as ~0 V on a bare pad and looks exactly like an open. This produced one wrong reading on pad 5 before wm02c held existed to print uptime alongside the pad levels.

What is actually established about the v5 boards

Proven good: MOSI, MISO and CLK all continuous to the WM02C footprint (MOSI by driving it and watching the far pad follow both ways). All seven WiFi pins plus both chip selects verified correct in the generated devicetree against the netlist. /SPI-SS and /WIFI_INT are routed (32 segments/6 vias and 18/4), so the failure is not an unrouted net. The IMU is powered with pin 7 sound.

Still open at the time of writing: pads 5 (/SPI-SS) and 15 (/WIFI_INT) measured 0 V while driven, which would be an open — but both nets cross to B.Cu and back, so the fault could be the BM20C joint or a via. Probe a via to split the net: /SPI-SS has one at (93.016, 116.694) mm, ~3.4 mm from U7's origin at (93.3, 120.05); /WIFI_INT has one at (91.756, 119.509). A via fault is a fab defect and would explain both boards failing identically far better than the same two hand-solder joints failing twice. The BM20C was resoldered after this, so --wifi is the re-test.

P2's base address is 0x50050400, not the 0x500D8400 that the P1/P3 stride implies — a different peripheral domain, confirmed against the generated devicetree. BUCK-EN is the only WiFi signal on P2, so getting it wrong reports a dead module rail on a good board.

2026-10-12 (later) — v5 board #1's SPI bus is dead for BOTH devices, and device_is_ready() false-passes on a mute nRF70

First run of the product firmware on a v5 board, and the shared SPI bus does not work at all. Board #1 (E27EB750 E804477E), watch-wifionly:

<err> lis2dh: Invalid chip ID: ff
<err> wifi_nrf_bus: Error: RPU comms test: sig failed: expected 0x42000020, got 0xffffffff
<err> wifi_nrf: zep_shim_bus_qspi_dev_add: RPU enable failed with error -1

Both devices on the bus read all-ones, and that is the whole diagnosis. The WM02C (U8) and the IMU (U6) share SPI-CLK (P1.11/H4), MOSI (P1.06/K8) and MISO (P1.07/G4); only chip select is private to each. Two independent parts failing identically on a bus they share points at the shared net rather than at either part — and 0xffffffff specifically means nothing is driving MISO, as opposed to 0x00000000, which would mean it is held low by a short or by the part being in reset.

So MISO is the prime suspect by Occam's razor: one open or VDD-bridged joint on that net explains both failures at once. The net runs BM20C pad G4 → U6 pin 7 → WM02C pin 3, and the WM02C end was hand-soldered the same day.

What is NOT yet established is whether U6 is even populated on this board. An unfitted IMU also reads ff and would prove nothing, which would move suspicion back onto the WM02C's own pins and rails. This is the one question that splits the diagnosis, and it is a visual check, not a software one.

Note the bus failure is invisible to everything measured on v5 so far: Channel Sounding is internal to the BM20C, so the 2026-10-05 ranging results stand untouched. The first code to touch SPI on v5 was this firmware.

device_is_ready() is NOT a test of the nRF70, and it fails in the dangerous direction

The first version of wm02c probe reported 2 BUS+PWR PASS on this board — on hardware whose module answers nothing at all — and then failed at stage 3 and blamed MISO. The attribution was wrong and the confident PASS was worse than no test.

Cause: the nRF70's net device init only registers the net_if. The actual RPU bring-up — rails, clock, comms test, firmware-patch upload — happens later in nrf_wifi_if_start_zep, at interface start. device_is_ready() reflects only the former, so it is true on a board with no module fitted at all.

Fixed by testing what actually matters: net_if_flag_is_set(iface, NET_IF_UP), retrying net_if_up() to surface the driver's errno in the shell rather than only in the log. It now reports 2 BUS+PWR FAIL — interface will not come up (-5), matching the driver. General shape worth remembering: for any Zephyr driver whose hardware bring-up is deferred to an iface/start hook, device_is_ready() tests the software registration and nothing about the hardware.

The corrected stage 2 also prints the shared-bus cross-check — "is the IMU failing too?" — because that is the question that localises the fault, and it is answerable from the same boot log the operator is already looking at.

2026-10-12 — WiFi needed no pin changes for v5, and the WM02C solder test has to be staged

The WM02C was hand-soldered onto both v5 boards, and wifi.overlay turned out to need nothing. All seven WiFi signals are identical between v4 and v5, checked against the 2026-10-05 netlist export (module U8) rather than assumed:

Signal Port Pad Module pin
IOVDD-EN P3.00 K5 16, via U2 (TPS22916)
BUCK-EN P2.08 K6 7
SPI-CLK P1.11 H4 6
QSPI-SS P1.10 J4 5
QSPI-DATA0 (MOSI) P1.06 K8 4
QSPI-DATA1 (MISO) P1.07 G4 3
IRQ P1.15 F2 15

So the v5 WiFi build is the v4 build against the new board definition. Verified in the generated devicetree, not just the source. All four images build: watch/anchor × --cs --wifi (44.9%/40.9% flash, 54.6%/52.5% RAM) and --wifi-only (31.9%/31.8%, 38.0%/38.4%).

Why the solder probe is staged, and why that is not a design preference

wm02c probe (src/hal/wifi_bringup.c, CONFIG_IMPULSE_WIFI_BRINGUP). The WM02C's pads cannot be tested one at a time, and no amount of cleverness changes that — there is no host-side continuity trick like .bringup's --continuity, because every line only does something in combination with the others and the module is mute until power, clock, chip select and MOSI are all simultaneously good. The probe therefore runs four stages in dependency order, stops at the first failure, and prints the pads that stage implicates:

Stage What it proves Cleared by passing
1 DRIVER nRF70 bound to a net_if nothing — a FAIL here is a build problem
2 BUS+PWR device_is_ready(): rails up, firmware patch uploaded IOVDD-EN, BUCK-EN, CLK, CS, MOSI, module VDD + GND
3 MAC/OTP MAC is a valid global unicast from OTP MISO — the only stage proving reads
4 IRQ a scan returns results /WIFI_INT — the only stage exercising the interrupt

Stages 3 and 4 exist because of how their faults present later, not because they add coverage for its own sake. Stage 2 can pass over a marginal MISO joint, and a zero or locally-administered MAC is that joint's signature (the driver invents an address when it cannot read the part's OTP — the check is lifted from Nordic's own is_mac_addr_set() in samples/wifi/scan). A bad IRQ joint is worse: it does not look like a dead module, it looks like WiFi that associates and then goes quiet, weeks later. A scan that completes with zero APs is reported INCONCLUSIVE, not FAIL — the scan-done event arriving is itself proof the interrupt works, so suspicion moves to the antenna.

The four COEX pads are deliberately untested: CONFIG_NRF70_SR_COEX is off, so a cold joint on one cannot affect anything that runs today.

The probe needs its own image, for the reason the whole coex problem exists

--wifi-only (wifi_only.conf) builds WiFi with CONFIG_IMPULSE_BLE=n, and so no CS either — the flag refuses --cs outright rather than silently producing something that cannot work. This is not tidiness: BLE and WiFi do not coexist on this board (2026-09-12 below — both radios dead inside 5–13 min with both active), so a WiFi probe run from a BLE build can fail for a reason that has nothing to do with the soldering, which is precisely the ambiguity a bring-up test must not have. The probe warns at runtime if it finds itself in a CONFIG_BT image.

That image has no GATT surface and cannot enforce anything. It proves the module; it is not a role.

Not yet run on hardware — compiled, command present in the image (strings confirms the registration), waiting on SWD.

2026-10-05 — Board v5 needs its own board definition, and the CS rig's simple apps were printing into nowhere

Board v5 arrived and six pins moved, so impulse_v1 is now wrong for it. The v5 map was re-exported from ~/Documents/KiCad/ImpulseBluetooth/Impulse.kicad_sch with kicad-cli sch export netlist --format kicadsexpr and read off module U7 (the BM20C) pad by pad — U8 is the WM02C. It agrees exactly with the table .bringup/README.md carries, which is the independent confirmation:

Signal v4 (pad) v5 (pad) v5 net
Motor P3.03 / A7 P1.18 / F9 /MOTOR
Buzzer P3.07 / C9 P3.09 / K7 /BUZZER
Servo P1.02 / A8 P1.26 / J9 /SERVO
IR receiver P1.29 / F7 P1.16 / E9 /IR_REC
Battery divider P1.00 / J7 P2.04 / B5 /BAT_DIV
IMU interrupt P1.14 / F3 P1.19 / G9 IMU_ESP_INT

A v4 image on a v5 board is inert on those signals, not destructive. Every v4 pad above reads unconnected-(U7-…) in the v5 netlist, so the firmware drives module pads that go nowhere. That is worth knowing because it means the failure mode is a silently dead motor or buzzer, never a short — but it also means nothing complains, so the board definition is the only thing standing between a v5 board and a build that does nothing.

A new board definition was added rather than editing the old one, at nrf_firmware/boards/impulse/impulse_v5/ (impulse_v5/nrf54lm20a/cpuapp). The two v4 boards still run the product firmware and still have to be flashable. Note the naming trap this leaves: impulse_v1 is the v4 PCB (ImpulseNRFV4.kicad_pcb), so the board definition and the PCB revision are off by one for v4 and only for v4. impulse_v5 is v5.

The product firmware's default board is unchanged — v5 is selectable, not yet adopted. firmware_spec_v3_nrf.md §2 still carries the v4 pin map and is now wrong for v5; the DTS header is the authority until that is reconciled.

The CS rig's initiator and reflector apps configure no console at all

This cost a flash cycle and looked exactly like a dead board. nrf_cs_test/initiator/prj.conf and reflector/prj.conf set no console options whatsoever — they were written for the nRF54L15-DK and rely on Zephyr's UART default. An Impulse board has no UART and no USB CDC, and its DTS disables uart20/uart30 outright, so the image builds, boots, scans and ranges while printing into nowhere. What you see is RTT: Failed to find RTT control block in RAM from J-Link, which reads as "the firmware is not running" when in fact nothing had ever initialised RTT.

Fixed by having build.sh append a new nrf_cs_test/board_impulse.conf (RTT console) for any non-DK target. The limits apps were never affected — their own prj.conf files force RTT, which is why the limits rig worked on v4 while the simple rig's initiator-v1 target evidently never printed. Worth remembering the general shape: a missing console is not a visible failure, and "no RTT control block" is a symptom of it rather than of a crash.

Flashing guards now come from a file, because boards are now plural

nrf_cs_test/limits/dies.tsv maps FICR DEVICEID → board + role, and flash.sh reads it before writing. Every Impulse board is the same part, so the existing RAM-size check (256 KB = DK, 512 KB = nRF54LM20A) distinguishes the SoCs and can never distinguish the boards. ./flash.sh whoami prints an attached board's DEVICEID plus a ready-to-paste row, and an unregistered die is refused rather than guessed at. v5 board #1 is E27EB750 E804477E.

The field layout bit once: the DEVICEID is a single tab-separated field containing a space (both words, as JLinkExe prints them), so board and role are fields 2 and 3, not 3 and 4.

Measured on v5: the respin fixed the antenna, and the numbers are not marginal

Both v5 boards, product profile, 1.6 ft (49 cm) apart at deliberately unfavourable orientations, 10 minutes (nrf_cs_test/v5-product-run1.rtt), compared with the v4 pair's own product captures measured the same way:

v4 (run1 / run2) v5 (v5-product-run1)
Step RSSI, median −87 dBm −57 dBm
Good procedures 64% / 44% 99.9% (2 DROP, 2 MISMATCH, 5 NOSTEPS in 6034)
Procedure rate degraded 10/s sustained, the full designed rate
Disconnects 9 in 230 s / 25 in 150 s 0 in 604 s, 604/604 seconds linked

The link-drop bug and the 72–74% abort rate were both the antenna, not the radio settings. Working the link budget at 0 dBm: two ideal antennas at 49 cm would give ≈ −34 dBm, so v5's −57 dBm is ≈ 23 dB of excess loss across the pair, ≈ 12 dB per board — unremarkable for a chip antenna on a board this small. The same arithmetic on v4 gives ≈ 30 dB per board, matching the 28–36 dB the 2026-09 link-budget work estimated. So roughly 18 dB per board was recovered by moving the antenna off the keepout and into the corner.

This retires the TX-power A/B. The max profile's +8 dBm was compensating for a broken antenna: v4 needed it to hold a link at all, while v5 holds one at 0 dBm with a 99.9% procedure yield and zero disconnects. Do not raise CONFIG_BT_CTLR_TX_PWR_ANTENNA on the strength of the v4 data. What is still unmeasured on v5 is RANGE — every figure here is from one fixed 49 cm geometry.

Two defects that the v5 pin moves created, neither of them in the pin map

1. The SAADC channels are now wrong, and they are the one thing not taken from the devicetree. CONFIG_IMPULSE_ADC_CH_BATTERY (AIN0) and CONFIG_IMPULSE_ADC_CH_IR (AIN1) are Kconfig indices identified by a boot sweep on the v4 board, where the battery divider was P1.00 and the IR receiver P1.29. Both pins moved on v5 (P2.04 and P1.16), so both channels read unrelated pins. Consequences, deliberately checked rather than assumed:

  • Battery is report-only. It goes out over GATT and into a log; nothing acts on it and there is no low-battery shutdown path, so the cost is a wrong percentage, not a device that dies mid-test.
  • Worn detect silently never asserts. With AIN1 on the wrong pin the delta never crosses CONFIG_IMPULSE_WORN_THRESHOLD_MV, so worn stays false from boot. That does not block enforcement — the §5.4.4 donning grace is skipped (it requires worn), and enforcement proceeds — but there is no worn→not-worn edge, so the §5.5.1 WATCH_REMOVED escalation cannot fire on v5 at all. Anything testing that path on v5 is testing nothing.

The fix is a sweep (impulse adc) on a v5 board and two Kconfig values. Until then this is the only place in the port where a pin is not devicetree-derived, which is exactly why it survived the port.

2. ifft reads long on v5, so the distance calibration does not transfer. IMPULSE_CS_IFFT_OFFSET_CM is 72, derived from a single v4 point (two v4 boards at 61 cm LOS). On v5 at a known 49 cm, ifft ran 1.46 m median over 6025 samples in one capture and 1.76 m in another 60 s capture minutes earlier, which the current offset converts to 0.74 m and 1.03 m — i.e. it reports the boards 0.25–0.55 m further apart than they are.

Solving for the offset the way 72 itself was derived gives 96 and 126 from those two captures. That spread is the finding, not the number. Both captures were at one uncontrolled geometry at deliberately odd orientations, and one point still cannot separate slope from offset — the same objection the comment on 72 already raises about itself. Do not move the constant on this evidence. What it does establish is the direction and rough size of the error, and that the owed multi-station LOS sweep (docs/hardware-notes/cs_tuning_plan.md) is now blocking, because every proximity threshold is in absolute cm.

The bias errs toward UNDER-enforcement, which is the benign direction for a test but the unsafe one for the product: reading long makes a getAway commitment read as satisfied too early. With NEAR_ENTER at 200 cm, NEAR actually requires roughly 145–175 cm on v5. For a bedside Sunrise Lock anchor that still works; it is not a figure to ship.

WatchFIrmware / AnchorFirmware do not take these pins

Asked on 2026-10-05 to "update the WatchFirmware and AnchorFirmware with the new pindefs". They have no relationship to the v5 pin map. Both target esp32-c3-devkitc-02 on the Arduino framework and their pins are ESP32-C3 GPIO numbers on the older ESP32 PCB (ImpulseESP.kicad_pcb) — BUZZER_PIN 21, VIBRO_PIN 10, BATT_ADC_PIN 3, SERVO_PIN 10. Writing nRF pad numbers into them would break the only firmware that has ever run on a wrist. The watch and anchor roles that do need the v5 map are the nRF port's two Kconfig roles, and those get it from the board definition.


2026-09-22 — The BM20C's co-location clause does carry the multi-transmitter exception, and a mirror's summary is what nearly hid it

Two days after the grant was filed into this workspace, the one thing left unread on it has been read: the complete grant note. The 2026-09-20 entry below quotes it as far as "…20 cm from all persons…" and stops, because that was as far as the source went. The full text, read 2026-09-22 from fccid.co/fcc/X8WBM20C:

"Single Modular Approval. Power output listed is conducted. The antenna(s) used for this transmitter must be installed to provide a separation distance of at least 20 cm from all persons and must not be co-located or operating in conjunction with any other transmitter, except in accordance with FCC multi-transmitter product procedures. Grantee must provide integration instructions to host manufacturers. OEM integrators and end-users must be provided with transmitter operating conditions for satisfying RF exposure compliance."

So the BM20C's grant has the same shape as the WM02C's, clause for clause. Two conditions, one unqualified:

Condition Qualified? What it means for the watch
≥ 20 cm from all persons No Violated. Wrist-worn, floored at 5 mm. A permissive change is unconditional — and unlike WiFi, BLE cannot be gated off-body, because transmitting while worn is the enforcement channel
No co-location with another transmitter Yes — "except in accordance with FCC multi-transmitter product procedures" Permitted. BM20C beside WM02C on one board is a thing both grants anticipate and route

This confirms rather than changes the plan of record, and that is the useful part: the correction the user forced on 2026-08-12 — that co-location is permitted and the exposure category is the whole of the risk — now holds for both modules on evidence rather than by analogy. Module Grant Conditions.md §6's struck line stays struck. Nothing here reopens a decision; one assumption became a reading.

The near-miss is the reason this entry exists. The first read of that page asked for the grant details in prose, and the answer came back with the co-location sentence ending at "any other transmitter." — period, no exception. Taken at face value it says the BM20C is stricter than the WM02C, which would reinstate a finding this workspace struck five weeks ago after the user caught it. Only asking for the note verbatim, in a second request, produced the exception clause.

That is now the fourth time this exact clause has been dropped by a reader of it in this project — Module Grant Conditions.md §0 quoted it and argued past it, Module Alternatives.md §4 did the same, FCC Respin.md §3 inherited the error, and now a summarising read of the primary text truncated it. The rule that follows: a grant condition gets quoted verbatim or not at all. A paraphrase of a grant note is not evidence about a grant note, and this clause is specifically the one that goes missing.

On the primary source — the 403 is structural, not transient. Re-tested 2026-09-22 by two independent network paths: an HTTP fetch through this agent's proxy, and curl straight off this machine with a browser user-agent. apps.fcc.gov/oetcf/eas/… returned 403 to both; fccid.io/X8WBM20C returned 403 to both. fccid.co serves it, but only at fccid.co/fcc/<ID> — the bare fccid.co/<ID> path used on 2026-09-20 now 404s, so even the working mirror's URL shape is not stable. The grant is corroborated across three independent reads plus search; it is still not confirmed against the FCC database, and it should be before it goes in front of a lab or a cheque.

The exhibits are listed and they are the documents a lab will actually read. Users Manual · US Agent Letter · Attestation Statements · FCC Modular Approval Request · Confidentiality Letter · Declaration of Authorization · Model Difference Letter · External Photos · ID Label/Location · Internal Photos · MPE Report · Test Reports (BLE and 802.15.4) · Antenna Specifications (5 files) · Test Setup Photos. The MPE report is where "This is a Mobile device, the MPE is required" comes from, and it plus the Model Difference Letter (which says what the ten BM20* variants share) are the two worth pulling before AUG-12's labs quote anything — both go to the question a lab will ask first, which is what was already evaluated and what has to be redone.

And one data point on the half of BLK-15 that is still open. Fanstel's own BM20C page, read the same day, now lists FCC X8WBM20C · ISED 4100A-BM20C · Japan TELEC R201-260622 · Europe, Australia and New Zealand compliant, and states "Availability: Production 09/2026" — this month. Read carefully: that is a vendor availability line, not a confirmation to you, the same page carries older undated prose about arrivals, and Fanstel's 2026-08-04 answer was that they had no confirmed ship date. It does not discharge anything. It does mean the ship-date question has a plausible answer for the first time, and it is worth one line in the Fanstel conversation SEP-13 has to open anyway — asking to confirm a date the vendor has published is a much easier ask than asking for one that does not exist.


2026-09-20 — The BM20C grant issued on 2026-09-11, and it issued mobile

This is the outcome SEP-12 existed to prevent, and it arrived while the email that would have prevented it sat unsent. launch-plan.md §5 wrote the prediction in as many words on 2026-08-11: "If they file it mobile-only the way they filed the WM02C, then the BLE-only fallback is not SAR-excluded by calculation alone either." They did.

What the public record says (read 2026-09-20; fccid.io and the FCC's own apps.fcc.gov search both returned 403 to this machine, so this is from fccid.co/fcc/X8WBM20C, corroborated by a second independent read of the same page and by search snippets — re-confirm against the FCC database before spending money on it):

Field Value
FCC ID X8WBM20C, Fanstel Corporation, Taipei
Date of grant 2026-09-11
Application purpose Original equipment
Equipment class DTS — Digital Transmission System
Frequency range 2402.0 – 2480.0 MHz
Conducted output 6.87 / 6.89 mW (≈ 8.4 dBm)
EIRP ≈ 15.4 dBm
Test report FCC registration 487532, testing 2026-07-15 → 2026-08-11
Model variants BM20M, BM20BM, BM20C, BM20BC, BM20E, BM20BE, BM20F, BM20BF, BM20P, BM20BP

The grant note, quoted: "Single Modular Approval. Power output listed is conducted. The antenna(s) used for this transmitter must be installed to provide a separation distance of at least 20 cm from all persons…" — and the exposure section: "The MPE was calculated at 20 cm to show compliance with the power density limit."

Three consequences, in descending order of cost.

  1. SEP-12's free window is shut. Scoping an open filing for portable body-worn use cost Fanstel a paragraph; the same question now costs a Class II permissive change that only Fanstel may file. drafts/fanstel-exposure-category.md was written 2026-08-12 and has never been sent. The window stayed open for thirty days of that and closed on day thirty-one. The draft is not wasted — it is now the opening of a permissive-change conversation rather than a scoping question, and question 2 needs rewriting before it goes.
  2. The watch now breaks a grant condition on both radios, not one. The WM02C problem (SEP-13) was one module; the BM20C carries the identical 20 cm clause. AUG-13's SAR-exclusion result — BLE excluded at 2.5 against 3.0 — was arithmetic about power, and a 20 cm installation clause is not a power consequence. It is a clause on the grant, so no arithmetic reaches it, which is the same correction Washington Labs made about the WM02C on 2026-08-11 and which is now true twice. Wornness-gating cannot rescue this one: the watch transmits BLE while worn — that is the enforcement channel — so a permissive change on the BLE radio is unconditional.
  3. BLK-15's grant half is discharged, and that is genuinely good news. The BM20C is certified; the nRF54LM20A part is no longer uncertified-from-anyone (Module Alternatives.md recorded on 2026-08-12 that no nRF54LM20A module from any vendor held a grant). The grant number exists to put in front of a lab, which was MAR-05's precondition. What is not discharged is the Nordic silicon ship date, which was always the second half.

The BC15C fallback is worth re-reading against this, not abandoning. X8WBC15 is evaluated portable (§2.1093, exclusion 2.82 vs 3.0 at 5 mm, near-zero tune-up tolerance — Module Alternatives.md §5). So the fallback module is still the only part in this project authorised against a body today, and the gap between it and the primary just widened rather than closed. That does not make it the choice — it halves RAM and drops WiFi — but the "BLE-only is excluded by calculation" shorthand must now say which module, because it is true of the BC15C and false of the BM20C.

One number worth keeping: the grant declares ≈ 8.4 dBm conducted / 15.4 dBm EIRP, which is low — much lower than the WM02C's declared 0.17 W (+22.3 dBm). That is the figure a permissive change argues from, and it is the most favourable input this problem has.

Why nobody saw it for nine days: the grantee-code sweep that ran eleven times between 2026-08-26 and 2026-09-10 checked fccid.io/X8W and fccid.io/X8WBM20C. Both now return 403 to automated fetches, so the instrument that was watching this stopped working at roughly the moment the thing it watched for happened. Use fccid.co as the second source, and treat a 403 as no data, never as no grant.


2026-09-13 — The watch stopped ranging for good after one failed reconnect

Context: Minutes after the conn-leak fix, the same Sunrise Lock window went green again beside the anchor. The anchor had just been reflashed mid-window, dropping the watch's link.

Finding (SWD, 9 minutes in): impulse_cs pended on sem_connected; CS connection NULL; bt_dev.flags SCANNING=0 and EXPLICIT_SCAN=0; one conn slot free. The watch had concluded AWAY under the 10 s lost-link rule and was not scanning, so it could never measure NEAR again that boot.

Cause: with connect_if_match the NCS scan module stops scanning when it initiates. A connection that then fails to establish or times out comes back through connected_cb with err != 0; scan_connecting_error() only fires for a synchronous create failure. connected_cb set connection = NULL and returned without giving sem_connected, and the engine waited K_FOREVER. A peer rebooting during connection setup is exactly what triggers it.

Fix (d78517c): give sem_connected on the error path so the engine restarts and rescans, and bound the wait to 30 s (IMPULSE_CS_CONNECT_WAIT_S) so any other lost wake-up costs one rescan.

Verified the same window, 12:08–12:10: the watch reconnected and ranged at 0–15 cm; on the next link drop (0x08) the grace silenced output, AWAY was concluded at 10 s, and the watch reconnected by itself 18 s after the drop and resumed bursts. Status read cond=ALARMING beside the anchor.

Still open, and now the most visible defect: the link drops beside the anchor every ~25–60 s (supervision timeout). Each drop now produces a silenced grace, an AWAY at 10 s, and a re-alarm after reconnect — a brief green on every drop.

General lesson: every k_sem_take(..., K_FOREVER) in the CS engine is a place one missed callback ends ranging for the boot. Audit the rest before trusting it.


Context: The first Sunrise Lock pushed from the real phone app (11:20–12:20 daily, getAway, strict_both). The watch beeped briefly, went flashing green within ~15 s, and never enforced again with the watch beside the anchor.

Finding: the watch could not connect to the anchor at all. Its log showed Filters matched ... F8:F0:5B:5E:6C:B4 then Connecting failed, retrying scan in 500 ms, forever. Read over SWD against the running target:

what value
acl_conns[0] role peripheral, state DISCONNECTED, ref = 1, dst = the phone
acl_conns[1] state ADV_CONNECTABLE (the watch's own advertising)
connection (CS) NULL; g_ranging_wanted 1, g_procedures_on 0
g_enf.prox have_verdict 0, abstain_run 5 → getAway fail-open, green

CONFIG_BT_MAX_CONN=2, so no conn object was left for the anchor.

Cause: ble_watch.c's connected/disconnected are BT_CONN_CB_DEFINE callbacks, so they fire for every link, including the watch's own central CS link. They took a reference on each connect, overwriting current_conn without releasing it, and released current_conn on each disconnect whichever link had gone. The phone connecting to push a schedule while the CS link came up leaked the phone's reference permanently. Bench tests never hit it because phone_sim pushed well before a window, never overlapping the anchor link.

Fix (dcfeaaa): the watch tracks only peripheral-role links, never overwrites a held reference, releases only the link that went, and retries advertising on any disconnect. ble_anchor.c had the same pattern with two peripheral links (app and watch) and got the same treatment; which of those two receives notifications is still first-come (TODO). Verified on the watch in the same window, 11:55: 143 s after the flash, SWD read acl_conns[1] CONNECTED (central, the anchor), acl_conns[0] ADV_CONNECTABLE, current_conn NULL, and enforcement unmet on a NEAR verdict (near_run 7) from fresh bursts. Anchor built, not flashed.

SWD trap: on a running watch mid-window, savebin of RAM intermittently fails with "Could not read memory" (twice in a row here) and a plain connect can fail too, while the FICR read succeeds. Retry; it is not a dead or locked-up core (DHCSR showed it running, CFSR 0).

Consequence for anyone adding a connection callback: a BT_CONN_CB_DEFINE is global. Filter by role or by the conn you own before touching refcounts, and never assign over a held bt_conn_ref. A leaked ref is invisible in logs; only the conn pool read over SWD shows it.

Tooling trap: $CLAUDE_JOB_DIR does not survive a session resume. A J-Link command file under it silently becomes /tmp/id.jl, the die read returns nothing, and the ID check reports "not the watch" on the right board.


Context: Two Sunrise Lock tests (23:30 and a 00:10 repeat) failed the same way — the alarm went green beside the anchor, and did not stop on leaving. Watch RTT was captured for the repeat.

1. The anchor's RRSP deadlock was never fixed — the 2026-09-12 entry below only survived it. The private advertising queue kept the anchor visible, but the system workqueue still wedged on every CS disconnect, and the sysworkq watchdog rebooted the anchor ~60 s later (three times in one window). The cycle is closed by a fact that entry missed: ATT buffers are returned by bt_conn_tx_notify, which runs on the system workqueue unless CONFIG_BT_CONN_TX_NOTIFY_WQ (experimental) is set. So bt_ras_rrsp_free() draining the RRSP queue from the disconnected callback waits on a sender that waits on a buffer only the waiting thread can release. Fix: CONFIG_BT_RAS_RRSP_AUTO_ALLOC_INSTANCE=n; cs_reflector_nrf.c allocs on connect and frees on a private queue, with a 20 s stuck-free reboot. Flashed; forced-disconnect test still owed. The watch-side signature of a wedged anchor is SMP Timeout then Security failed ... err 9 on reconnect.

2. AWAY was unreachable by design. AWAY_DWELL = 4 is counted per enforcement poll, 60 s apart while unmet. Replaying walk1-3.csv through that logic concluded AWAY zero times; counting per burst reached it only in walk3. In the walks only 2.0 / 5.9 / 16.9 % of samples were beyond 3.5 m true, the longest continuous far run was 10.7 s, and true-distance maxima were 5.06 / 7.46 / 6.91 m (72 cm pair offset). The owner therefore decided a lost link is AWAY after 10 s (spec v3 v0.16), overriding the long-standing "no signal must stay an abstention" rule and the objection that it makes shielding a getAway bypass. Do not quietly revert it — it is recorded as a decision in §4.5/§4.6.

3. BM20C↔BM20C ranging yields ~4× less than the DK walks. At under 1 m: 2.2–2.4 good estimates/s, 72–74 % of procedures aborted, a 10-sample burst every 3.6 s median (2.0–6.1). The DK↔BM20C walks gave 7–9 /s and a burst per ~1.0 s. The same pair also dropped the link (supervision timeout 0x08) twice in four minutes while stationary. Cause unknown; the procedure parameters are still the NCS sample's. No BM20C↔BM20C walk exists, so this pair's real range is unmeasured.

4. The watch took a precise bus fault (reason 25) in memcpy under SEGGER_RTT_WriteSkipNoLock, then locked up inside the fatal handler's LOG_PANIC (reset cause watchdog+cpu-lockup). The HW watchdog recovered it with the clock intact. pc/lr could not say which address was bad; crash record v2 now stores CFSR/BFAR/MMFAR, the frame and the thread name. Unexplained.

VERIFIED ON HARDWARE, 2026-09-13 01:25–01:45. With per-burst dwell (v0.17) and the lost-link rule (v0.16) flashed, a getAway window behaved as specified end to end: every approach to the anchor went red and vibrated, every departure stopped it — confirmed by the user walking in and out repeatedly. The link still drops every ~25 s beside the anchor and the anchor's ~01:02 reboot is still unexplained (reboot-reason record now retained); both remain open.

Traps that cost time tonight: - phone_sim schedule build dated a once event by today even when the lead crossed midnight, so a 00:00 window built at 23:56 was silently never entered. Fixed in phone_sim b5005c8 (local branch, unpushed). - A full RTT buffer at window start swallowed the entire failure; the watch log from the first test was unrecoverable. Keep rtt.sh attached before the window opens. - rtt.sh --reset right after flash.sh printed stale lines with a 36-minute uptime; the second capture showed the real boot. Trust BOOT #n, not the first lines.


What belongs here

Write an entry when you learn something that cost you real effort and isn't recorded anywhere else:

  • A hypothesis you tested and disproved — the most valuable entries, and the ones nothing else in the repo captures. Code records what worked; this records what didn't, so nobody re-runs the dead end.
  • Hardware behaviour that contradicts the documentation or the datasheet.
  • A measurement whose number now lives in a constant, when the reasoning didn't fit in the header comment.
  • A decision you made under ambiguity, with the reasoning, so it can be revisited rather than re-litigated.
  • A workaround whose cause is still unknown.

What does not belong here

  • Anything the spec should say. If it changes device behaviour or a wire format, edit specs/firmware_spec_v2.md and update its §0 parity table and change log. A note here is not a substitute — a spec that lags the code is the failure mode this whole workspace keeps hitting.
  • Anything a code comment should say. If it explains why one line is the way it is, put it next to the line. The engine's proximity.h already does this well.
  • Session narration, task lists, or "I refactored X" — git log covers that.
  • Anything already in a repo README.

Format

## YYYY-MM-DD — one-line summary

**Context:** what you were doing.
**Finding:** what is actually true.
**Evidence:** the measurement, log line, or commit that proves it.
**Consequence:** what someone should now do differently.

Keep entries short. Prune anything that has become false — a stale note here is worse than no note, because it reads as verified.


2026-09-12 — The watch halts on a SoftDevice Controller assert mid-ranging, with the motor latched on

Context: During a Sunrise Lock test the alarm "did not stop when I left the room — only on reset". A second, silent verification window reproduced it with the watch on SWD, undisturbed.

Finding: the watch was not misjudging distance and was not in any application loop. It had crashed and halted:

read value
IPSR 3 (HardFault), CFSR = 0, R0 = 4 (kernel panic, not a memory fault)
PC / LR arch_system_halt+13 / k_sys_fatal_error_handler+29
faulting thread MPSL Work, stacked LR in sdc_assertion_handler
unflushed RTT buffer, read out of RAM SoftDevice Controller ASSERT: 23, 587
motor pad P3 OUT 0x08 — latched on

For ~1.3 s before the assert every CS procedure logged Dropped subevent results. Waiting for ranging data from peer. — local subevent results arriving, the anchor's RAS ranging data never following.

23, 587 is undocumented. nrfxlib/softdevice_controller/include/sdc_asserts.h in NCS v3.4.0 LTS lists exactly one assert (135, 261). The two nearest v3.4.0 changelog fixes — DRGN-28655 (assert when pulling CS Subevent Result events) and DRGN-28736 (CS sub-mode-1 subevent length) — are already in this controller, so this is a different or still-open defect. Build: initiator-only, main mode 2 / sub-mode 1. This needs a Nordic support case; there is no application-side fix to write.

The 17:50 test was very likely this too. "Only reset stops it" is the halted-CPU signature. It was first blamed on the 6-minute abstention timeout (POLL_NOT_MET_S x ABSTAIN_MAX + 1), and that attribution went into a commit message — wrongly. The faster abstention cadence stays because the arithmetic stands, but no hardware run has yet shown that path being slow.

It recurs, and fast. A second assert with the same signature (assert_post_action ← sdc_assertion_handler) fired 163.5 s after boot — about 43 s into a Sunrise Lock window, as ranging was starting. This time the new fatal handler rebooted instead of halting (crash record count = 2). The reboot cost the proximity state mid-window, and left the clock 45 s slow, because it restores from a flash save taken every 60 s — which, while the watch stayed in ENFORCEMENT past the window's real end, looked exactly like a failure to exit the window. Twice in one afternoon, both within minutes of CS starting: this assert is currently the main obstacle to a reliable Sunrise Lock, ahead of RF range and the criteria logic.

The 45 s skew is fixed separately from the assert: the clock is now mirrored into .noinit RAM every loop pass and restored from there after any warm reset. Measured −2 s after a reset that, 52 s after an NVS erase, the flash path alone would have answered with no clock at all.

A plausible upstream match, not yet released. The SoftDevice Controller changelog on nrfxlib main — above v3.4.0, in no release yet — lists "Fixed an issue where the controller could assert when an ACL connection with active CS procedures was disconnected." (DRGN-29669) Both crashes fit that shape: the first followed 1.3 s of the anchor's ranging data not arriving (a link on its way down with procedures running), the second came as a window opened against an anchor at −85 dBm. It is not confirmed to be 23, 587. What it suggests on our side: never let the engine disconnect while procedures are enabled — disable them and wait for the controller to confirm first. A link that is lost rather than closed cannot be sequenced that way, so that case waits for the controller fix.

First session with the engine sequenced: window ended at 15.001, the controller confirmed procedures disabled at 15.227, the engine released the anchor at 19.733 — no assert, no reboot. The disable was already confirmed when the engine woke, so the new wait returned at once; the race it closes is the engine waking inside those ~226 ms. One clean session says nothing about a rare assert.

Disproved on the way: that the watch runs channel sounding on soft-float. It does not — the watch build has CONFIG_FPU=y and hard-float MPSL. Only the anchor is soft-float, and the anchor never runs the distance estimator. Measured, and set aside: cs_de_calc runs on BT RX WQ (cooperative −8), so it does hold MPSL Work off while it runs — but for ~1.0 ms worst case over 34 bursts with the FPU on. That is far too short to be a plausible cause of a controller timing assert.

How to recognise it, because every usual tool lies here: RTT reads return zero bytes and the shell does not answer — which reads like a probe problem, not a crash. The tell is that everything in RAM is byte-identical across samples, including a sleeping thread's _timeout.dticks: a stopped system tick. Halt, read IPSR, then read the RTT up-buffer straight out of RAM (savebin 0x20000000 0x40000) — deferred logs that never reached the host are still there, assert text included. Snapshot of this one: nrf_firmware/build-watch-cs-buzz/crash_ram_20260912.bin (untracked build dir — a pristine build deletes it).

Consequence — both safety nets proven on hardware, not just built: src/fatal.c forces motor and buzzer off, records reason/PC/LR in .noinit RAM, flushes logs and reboots; wdt31 is armed at 30 s (read back from the peripheral: RUNSTATUS=1, CRV=0x000F0000, CONFIG=0x41 — runs in sleep, pauses only under a debugger halt). The bench commands impulse crash (motor on + k_panic) and impulse hang (motor on + interrupts locked forever) exercised both. The crash left a record reason=4, pc=cmd_crash+39, reported and cleared at the next boot. The hang ended in a reboot whose boot report read reset cause: 0x00000010 (watchdog), motor off. CONFIG_IMPULSE_HALT_ON_FATAL keeps the halt for bench builds and is mutually exclusive with the watchdog. The controller assert itself is unfixed and needs a Nordic case; what changed is that it now costs a reboot instead of a latched motor.

Two tooling traps that produced a false "inconclusive" on the hang test: pkill -f JLinkExe matches the calling bash script's own command line and kills it (Exit code 144) — use pkill -x JLinkExe. And timeout 40 ./shell.sh … | head -1 does not return when head exits: against a hung device nc never writes again, never gets SIGPIPE, and runs the full 40 s. A read taken "right after" it was really taken after the watchdog had already reset the board, which made a passing test look contradictory.


2026-09-12 — The anchor's system workqueue deadlocks on every CS disconnect that lands mid-send

Context: The anchor went off the air during a Sunrise Lock test and stayed off for an hour — no advertising, no scan response, no connect — while its uptime counter, heartbeat, main loop and logger all ran normally. Same signature as "the anchor keeps dying while powered", which has now cost several days across two investigations.

Finding: it is a hard deadlock in the NCS RAS reflector's teardown, and nothing about it is visible from outside.

BLE disconnect
 -> BT host deferred_work, on the SYSTEM workqueue
    -> disconnected() -> bt_ras_rrsp_free()
       -> k_work_cancel(&rrsp->send_data_work)   does NOT stop a RUNNING handler
       -> k_work_queue_drain(&rrsp_wq, false)    waits K_FOREVER
 -> rrsp_wq is still BUSY: send_data_work is inside bt_gatt_notify_cb
    -> waits on att_pool for an ATT buffer       K_FOREVER
       the connection is gone, so that buffer never comes

ras_rrsp.c:132-137 uses k_work_cancel(), not k_work_cancel_sync(), and then drains the queue it just failed to stop. If the reflector is mid-send when the link drops, the system workqueue is wedged permanently.

Evidence (read over SWD against the running, wedged target, no reset):

what value
sysworkq.pended_on rrsp_wq + 204 — another queue's drain list
rrsp_wq thread pended_on att_pool + 12
rrsp_wq flags 0x7 = STARTED|BUSY|DRAIN
acl_conns[0] state DISCONNECTED, ref = 3 — leaked
adv_restart_work.flags 0x4 = QUEUED, never ran

Live backtrace above PSP (0x2001DFF8): z_thread_entry → work_queue_main → deferred_work → disconnected → bt_ras_rrsp_free → k_work_queue_drain → z_sched_wait.

Two traps this hid behind. First, "state: pending, timeout: -9223372036854775808" in kernel thread list is also exactly what a HEALTHY IDLE workqueue looks like — it is waiting on its own notifyq. The thread list alone cannot tell idle from deadlocked; only pended_on can. Second, stack words below PSP are dead. Resolving them produced a confident and completely wrong first answer (an NVS/MPSL flash-timeslot deadlock) from a stale frame. Read _thread_base.pended_on at k_thread+8 first; unwind second, and only above PSP.

Consequence: two changes, both verified on hardware.

  • The anchor's advertising restart moved to a private workqueue, so a wedged system queue can no longer make the anchor invisible. Measured after the fix: disconnect at 00:04:30.089, advertising back at 00:04:30.090.
  • CONFIG_IMPULSE_SYSWORKQ_WATCHDOG (default on, 60 s) probes the system workqueue from the main thread and cold-reboots if it stops running work. The leaked conn ref means no in-process recovery exists, so reboot is the only way out — and it is cheap, since the schedule is in NVS and the clock is persisted.

BT_ATT_TX_COUNT is left at 3. More buffers would make the race rarer, but Nordic's own ras_reflector sample runs the same default, and changing a constant without a measurement behind it is the habit this project explicitly avoids.

Not the explanation for the coexistence finding. An earlier draft of this entry suggested the 2026-09-12 "BLE and WiFi cannot both run" result might really be this deadlock, since both present as "alive application, dead radios". The user confirmed both boards were on mains power for that entire morning, and the BLE-only control run (27+ min clean) was the discriminating measurement anyway. The coexistence finding stands on its own evidence.

CORRECTION 2026-09-13: the private advertising queue did NOT fix this — it kept the anchor advertising while the system workqueue stayed wedged and the watchdog rebooted it. The real cycle and fix are in the 2026-09-13 entry above.

Only the anchor is exposed. k_work_queue_drain appears exactly once in all of NCS Bluetooth, in the RRSP reflector. The watch runs the initiator (rreq), which has no such path.


2026-09-12 — The donning grace suppressed the Sunrise Lock alarm twice over

Context: With BLE-only builds on both boards, the alarm finally fired on hardware (window opened, CS bursts clean, motor driven, escalation delivered to the anchor over the CS link). Putting the watch on then exposed two more defects in the same twenty lines of enforcement.c.

Finding 1 — check_condition() had three early returns that skipped the profile transition. The donning grace, the no-active-event case, and the phoneAway fail-open each set condition_met = true and returned. The impulse_profile_stop() that is supposed to kill output on the transition to met lived only at the bottom of the function, after those returns. profile_run.motor_on therefore stayed 1, and since main() drives the pads from profile_run on every pass, donning the watch mid-alarm reported compliance while the motor kept running.

Finding 2 — enter() opened the donning grace whether or not the watch was worn. The worn_now argument was passed in and never tested, contradicting the function's own comment ("or the window start if the watch is already on"). A watch sitting on the nightstand — the entire Sunrise Lock premise — 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 most likely cause of any Sunrise Lock that "just didn't fire" with a non-zero grace configured; the windows that did fire had donning_grace_s = 0.

Evidence: worn=1 cond_met=1 with P3 OUT = 0x08 (motor, P3.03) sampled twelve times over the grace period via J-Link mem32 0x500D8604 against the running target.

Consequence: Both fixed — all exits now route through one settle() helper, and the entry grace is gated on worn_now. More importantly, enforcement.c was not in the host test suite at all, which is why a whole class of "the verdict is right but the pins are wrong" bug was invisible to 158 passing checks. It is in tests/Makefile now with a regression test covering every exit; the suite is 174 checks. Anything that decides output belongs in that Makefile.

Aside — read GPIO on nRF54L at node base + 0x4, not + 0x504. The devicetree reg for gpio3 is <0xd8600 0x300>, already offset past the peripheral's reserved block, so OUT for P3 is 0x500D8604. Reading 0x500D8B04 returns a plausible, permanent 0x00000000 — which reads exactly like "the firmware never drove the pin" and cost a wrong conclusion before the manual impulse motor cross-check.


2026-09-12 — BLE and WiFi cannot both run on Board V1, and four enforcement bugs found chasing it

Context: A Sunrise Lock scheduled for 11:00 did not fire. Diagnosing it consumed a day and turned up five separate defects, four of them in enforcement and one in the radios.

The radio finding, which is the big one. With BLE and WiFi both active, both radios die within 5-13 minutes while the application keeps running normally — heartbeats fine, main loop fine, worn_delta still updating, and on one occasion the clock persisted for 10.5 hours across the failure. Rebuilt the anchor with WiFi removed and nothing else changed:

build outcome
BLE + WiFi radios dead in 5-13 min, repeatedly, all day
BLE only 27 min and counting, uninterrupted

The four coex wires between the BM20C and the WM02C are routed on Board V1 but unused — CONFIG_NRF70_SR_COEX is off, so the two radios share 2.4 GHz with nothing arbitrating. Nothing showed this before WiFi landed the day before.

Do not reach for PTA coexistence. The product owner's position is that WiFi and BLE should be mutually exclusive in time, and expects the FCC grant to require it. PTA exists to permit simultaneous transmission, which is the opposite. The direction is to carry WATCH_REMOVED over the BLE link the watch already holds for channel sounding, leaving WiFi for the out-of-range case only.

Four enforcement bugs found on the way, all confirmed on hardware:

  1. A verdict could never arrive. measure() discarded results after 5 s while the enforcement loop polls every 60-180 s, so it abstained with 342 good estimates in hand at 0.52 m. Self-reinforcing: abstaining made getAway fail open, which selected the slower 180 s poll.
  2. Abstention bought the slow cadence. Fixed separately — a watch with no verdict now polls at the attentive rate.
  3. The strict profiles never drove a pin, ever. tick() returns "did the output change", and for a CONTINUOUS step it returns false forever by design; outputs were gated on that return. So strict_both/strict_silent/strict_buzz set motor_on = 1 and left the pad low — the strictest enforcement level was the only one that did nothing, and the duty-cycled profiles masked it. Outputs are now written from the profile state every pass.
  4. The anchor's advertising never restarted. One attempt, no retry; bt_le_adv_start() answers -ENOMEM right after a disconnect. Fixed with an indefinite retry, and verified working in a log: peer disconnected (0x13) followed immediately by anchor advertising.

Diagnostic lessons worth more than the bugs:

  • A full SEGGER RTT buffer is indistinguishable from a dead board. It is in skip mode, so once it fills every write is dropped silently. This cost three separate misdiagnoses in one day. Keep a reader attached (rtt_watch.sh), and note the boot log still gets truncated mid-line by the BLE init burst even at 4096 bytes.
  • The persisted wall clock is the best "when did it stop" evidence there is. Saved every 60 s, it survives the reset that wipes RTT, and it is what proved the application outlived the radios by hours.
  • ALWAYS re-read FICR DEVICEID before flashing. Watch firmware was flashed onto the anchor because a verbal "the watch is on the wire" was taken at face value right after asking for the anchor; roughly 20 minutes of testing then ran against a board that had been overwritten. build.sh/flash steps now check the die first.

2026-09-12 — the WATCH_REMOVED path is built and the watch half is proven on the wire

Context: Building §5.5.1 anchor escalation on the nRF port, now that both boards have a working WM02C.

What is verified, by decoding real datagrams captured on a laptop on the same LAN:

from 192.168.1.61  len=33  cmd=0x01 (WATCH_REMOVED)
  watch uuid=49b4b0b2-4db0-4cae-9061-d60c1465da00
  event uuid=d0d1d2d3-d4d5-d6d7-d8d9-dadbdcdddedf

Credentials pushed over BLE -> persisted -> associate; anchor IP table pushed over BLE -> persisted -> unicast to the right host; window-start unworn escalation; per-poll re-assertion at the 60 s cadence; and both commands on a worn transition (0x01 on removal, 0x02 on donning). The anchor half is written but untested — that board was mid-rework.

Three defects found building it, all of the same family — a failure that logs nothing:

  1. SO_BROADCAST is refused by Zephyr (ENOPROTOOPT) and the broadcast is sent anyway. Worth knowing in both directions: do not trust the setsockopt result, and do not log its failure as an error. At WRN it fired on every send including unicasts, which is how a log gets ignored.
  2. The first version logged only SUCCESSES. A refused sendto was indistinguishable from no window being open. Every send path now logs its failure, and "deferred, no association yet" is at INF rather than DBG — "nothing happened and nothing was logged" is precisely how the original ESP32 anchor-escalation bug survived in the field.
  3. The Anchor IP Table characteristic was declared write-only. §5.6 says it "responds with 0x01", and the app — and phone_sim — implement that response as a notification, subscribing BEFORE writing. BlueZ therefore refused the whole operation with org.bluez.Error.NotSupported before a byte reached the firmware. Every sibling characteristic (0012, 0014, 0019) was already write+notify; this one was the odd one out. Fixed with NOTIFY + a CCC and a deferred ack.

Consequence:

  • When a GATT write fails with "Operation is not supported" and the payload never arrives, check whether the client subscribes first. A missing CCC presents as a write problem, not a notify problem.
  • The bench override impulse wornset <0|1|auto> exists because the IR window is behind the enclosure, so the sensor cannot cross its threshold and every stage of Sunrise Lock after "the alarm starts" would otherwise be untestable. It is shell-only and deliberately not reachable over BLE — a forced worn state is a way to make the watch believe a commitment is being honoured, which is exactly what the app must never be able to assert.

2026-09-11 — WiFi works on both boards, and "0xffffffff" localises a WM02C solder fault precisely

Context: Bringing the WM02C (nRF7002) up on the watch and then the anchor. The watch worked on the first try once the config was right; the anchor did not, on identical firmware.

Finding: The anchor failed with

wifi_nrf_bus: RPU comms test: sig failed: expected 0x42000020, got 0xffffffff

0xffffffff is MISO floating — the module never drove the bus at all. What makes it diagnostic rather than merely bad news is the other line in the same boot:

lis2dh: fs=2, odr=0x4 lp_en=0x0 scale=9576

The IMU is on the same SCK/MOSI/MISO, with a different chip select. So one line proves the shared bus is electrically good and the other proves the module is not answering — which confines the fault to the WM02C-specific path: its own SPI joints, its chip select (P1.10 → module pin 5), its VBAT/GND, or U2 (the TPS22916) feeding IOVDD. Reworking the module's joints fixed it.

Two things that are NOT the cause and were checked: the firmware reached the comms test, which runs only after BUCK_EN and IOVDD are asserted; and both pins read back as correctly configured outputs (P2 DIR bit 8, P3 DIR bit 0).

Both boards now associate on 2.4 GHz: watch −58 dBm, anchor −65 dBm, WiFi 4, WPA2-PSK. On the watch, 224 CS procedures ran while the WiFi link stayed COMPLETED — BLE channel sounding and WiFi coexist. The anchor advertises its iBeacon and RAS service while associated.

Consequence:

  • When the nRF70 comms test fails, read the IMU line in the same boot before suspecting firmware. It costs nothing and splits "bus" from "module" immediately.
  • 0xffffffff means nothing is driving MISO — an open circuit or an unpowered module, never a protocol or timing problem. A wrong mode or marginal timing gives wrong data, not all-ones.
  • A healthy 3.300 V VTref with no SWD response still means either a partial TC2030 seat or an unpowered board. It cost a round trip again here — the anchor simply was not plugged in, and it presents identically to a dead board.

2026-09-11 — the nRF70 RAM problem is a tuning problem, not a silicon problem

Context: With the WM02C fitted, CS + WiFi together came to 520,432 B of 511 KB (99.46%). If that were irreducible it meant a board respin, so it was worth measuring rather than estimating.

Finding: It reduces to 284,424 B (54.36%) — 236 KB recovered — and the savings are almost entirely heaps sized for a throughput device, not fixed structures. From ram_report on the 99% build:

item bytes what it is
kheap__system_heap 153,692 HEAP_MEM_POOL_SIZE, copied from Nordic's shell sample
kheap_wifi_drv_data_mem_pool 130,000 NRF_WIFI_DATA_HEAP_SIZE, default for streaming
kheap_wifi_drv_ctrl_mem_pool 20,000 NRF_WIFI_CTRL_HEAP_SIZE
our main.c statics 50,209 of which g_integrity is 21,192

The single largest lever is NRF70_RX_NUM_BUFS, which defaults to 48 at up to 1600 B each — roughly 77 KB of the data heap on its own. This watch sends occasional tiny UDP datagrams (WATCH_REMOVED) and never streams, so 6 is ample.

DROPPING 5 GHz SAVES NOTHING. Measured directly: builds with and without CONFIG_NRF_WIFI_2G_BAND=y are byte-identical in both RAM (284,424) and flash (922,596). The band choice is a runtime operating-band setting, not a compile-time feature gate. Keep it anyway — 2.4 GHz propagates through a house far better than 5 GHz, which is what WATCH_REMOVED needs — but do not count it as a RAM saving.

Evidence it still works: flashed and verified on hardware — associates on 2.4 GHz ch1 at −58 dBm (WiFi 4), and ran 224 CS procedures while the WiFi link stayed COMPLETED. Both radios at once, on the cut-down buffers.

Consequence:

  • No respin is needed for RAM. 46% headroom remains with CS, WiFi, the supplicant and the BLE host all resident.
  • Untapped if ever needed: the WPA supplicant still carries WPA3/SAE and enterprise support, and our own statics are 50 KB with g_integrity at 21 KB.
  • The reduced buffers trade throughput for space. Anything later that needs bandwidth — a WiFi firmware update path, say — must revisit NRF70_RX_NUM_BUFS and the data heap rather than assume these numbers.

2026-09-11 — CONFIG_WIFI_NRF70 is a SYSBUILD symbol and the app config cannot set it

Context: CONFIG_WIFI_NRF70=y in the application's conf produced a clean build with no driver and no warning. Every wifi command answered "No default interface found for type: STA".

Finding: The build merges in this order, and the last file wins:

Merged ... wifi.conf                  <- ours, sets CONFIG_WIFI_NRF70=y
Merged ... zephyr/.config.sysbuild    <- contains CONFIG_WIFI_NRF70=n

It is defined in nrf/sysbuild/Kconfig.wifi, so it must be set as -DSB_CONFIG_WIFI_NRF70=y (plus -DSB_CONFIG_WIFI_NRF70_SYSTEM_MODE=y). Setting it at image level is silently discarded — no "assigned but got" warning, because the override is a later merge rather than an unmet dependency.

Consequence: When a Kconfig symbol refuses to take and there is no warning, check .config.sysbuild before checking dependencies. This is the third silent-Kconfig-drop in this port (LOG_BACKEND_RTT, SHELL_BACKEND_RTT, now this) and the only one that produced no diagnostic at all.

2026-09-11 — "the anchor keeps dying" was the watch holding its connection open forever

Context: Across two days the anchor repeatedly "stopped advertising while powered". It was investigated as a crash, a hang, a flat battery, and a bad solder joint. It was none of those.

Finding: The watch was squatting on the anchor's BLE link. The CS engine connected to the first matching anchor it saw and never released it, and a connectable advertiser stops advertising the moment something connects. The anchor was healthy the entire time.

Proved directly rather than by inference: with the anchor on SWD and invisible to a 15 s scan, anchor_conn was non-NULL (0x20010FD0), CFSR/HFSR were zero, IPSR was 0 (thread mode) and the CPU was sitting in Zephyr's idle(). Searching the anchor's RAM for the watch's BLE address (E1:06:CD:6C:4C:41, little-endian) found it inside that connection object at 0x20011041.

Evidence it is fixed: with the link gated on enforcement — dormant: anchor advertising at −73 dBm; enforcement: 172 distance estimates in 60 s; window ended: anchor advertising again at −70 dBm.

Consequence:

  • A permanent central link is not free and not invisible. It cost a 20 ms-interval connection maintained 24/7 on both sides, and it made the anchor unreachable by the phone app, by any second watch, and by a scan — which is how it presented.
  • Gating the CS procedures without gating the connection fixes the power cost and none of the visibility cost. That half-fix was made on 2026-09-10 and the symptom survived it, which is what finally separated the two.
  • A device that has gone quiet is not necessarily broken — check whether something is holding it. The whole misdiagnosis came from treating "not advertising" as "not working". Read the connection pointer first; it is two mem32 reads.
  • The engine still connects to whatever matches the iBeacon prefix rather than the anchor event.anchorId names. With two anchors in a house it will hold the wrong one.

2026-09-11 — a fail-open criterion is not a way to silence a device

Context: To stop the watch alarming before an unattended 8 h run, the demo schedule was switched from stayNear to getAway, which with no ranging available fails OPEN and went silent.

Finding: That silence depended on a fault persisting. getAway is only satisfied while the watch cannot measure; the moment ranging became available again the watch was ~2.5 ft from the anchor, the criterion was violated, and the motor ran at the escalation floor for hours. The battery was flat by the time anyone looked, and the board then would not answer SWD — which reads identically to a dead board or an unseated TC2030.

Consequence: To stop enforcement, remove the cause unconditionally — clear the schedule, or move the clock outside the window (both verified to work). Never rely on a criterion that is satisfied only because a subsystem is broken; when the subsystem recovers, the "safe" setting becomes the most draining state the firmware has.

2026-09-10 — the nRF watch rebooted on every BLE disconnect, and the wall clock does not survive a reboot

Context: First full hardware test pass on the nRF watch image with the CS backend compiled in (build.sh watch + cs.conf), Board V1 on SWD, phone_sim over BlueZ.

Finding: Four defects, all invisible to a clean build and to the 158 host checks.

  1. disconnected_cb() in cs_backend_nrf.c called sys_reboot(SYS_REBOOT_COLD). It came over verbatim from NCS's ras_initiator sample, where rebooting is how a one-shot ranging flow restarts. In the product firmware it meant any BLE disconnect cold-restarted the watch — the app simply closing rebooted the root of trust. The same callbacks are BT_CONN_CB_DEFINE, i.e. global, so the engine also adopted the phone's connection as its ranging peer; the sample never has to ask, because it is only ever a central with one link.
  2. The wall clock is RAM-only. g_utc_base (src/main.c) is a static initialised to 0 with no persistence, so after any reset the watch believes it is 1970 and matches no window and enforces nothing until the app reconnects and pushes the time. Silent — no log, no LED. Combined with (1) this was a complete remote bypass: connect, disconnect, enforcement stops.
  3. A stuck vibration motor. impulse_profile_stop() clears the profile struct and touches no pin; the pins were cleared only on the "window ended" path in main.c. Any other exit left the motor latched on indefinitely.
  4. The bring-up shell had been silently dead. SHELL_BACKEND_RTT "depends on !(SHELL_LOG_BACKEND && LOG_MODE_IMMEDIATE)". Turning on LOG_MODE_IMMEDIATE (added earlier so a workqueue stack overflow was readable) dropped the backend while prj.conf still requested it and the build still succeeded. Every shell.sh command returned nothing.

Evidence: Uptime 211 s -> 20 s across one impulse-phone watch info; after the fix, 50 s -> 120 s across four connect/disconnect cycles. Stuck motor seen directly as P3 OUT = 0x08 for 36 s continuous. Shell failure left only a Kconfig warning in the build log.

Consequence: - Never carry a Nordic sample's connection callbacks into an application that is also a peripheral without filtering on bt_conn_get_info()->role, and treat any sys_reboot() in sample code as a restart mechanism to be replaced, not kept. - (2) is NOT fixed — it is a product decision. Persisting the clock periodically means trusting a stale clock after a power cut, which §9.7's time guard has opinions about. Until it is decided, a flat battery silently ends enforcement. - Put "outputs off" on the state, not the exit path: main.c now clears both pins on every pass where the state is not ENFORCEMENT. - LOG_MODE_IMMEDIATE and the RTT shell are mutually exclusive on this target, because J-Link's telnet server only ever exposes RTT channel 0 (port 19021) — the shell cannot be moved to channel 1 to dodge the BUILD_ASSERT. Pick one; prj.conf documents how to flip.

2026-09-10 — nRF54L GPIO registers are NOT at the nRF52 offsets, and flash.sh could not write an image over 256 KB

Context: Trying to prove electrically that the compiled-out buzzer never drives its pin, by reading P3 over SWD rather than trusting the firmware's own report.

Finding: Two tooling traps, both of which present as a dead or broken board.

  1. The GPIO register layout differs from nRF52. NRF_P3_S_BASE is 0x500D8600 (from nrf54lm20a_global.h), but OUT is at +0x000, IN at +0x00C, DIR at +0x010 and PIN_CNF[n] at +0x080 + 4n — not the familiar 0x504 / 0x510 / 0x514 / 0x700. Reading the nRF52 offsets returns all zeros, which looks exactly like "no pin is configured" and is entirely believable.
  2. flash.sh failed verification at 0x00040000 on any image larger than ~256 KB. Not a flash-size or RRAM limit: a bare halt can fail on a running image ("Failed to halt CPU"), after which the write is corrupt. Adding r before halt fixes it without an erase, so NVS survives. Earlier images were ~246 KB and never crossed the boundary, so this only appeared once CS was compiled in.

Evidence: P3 dump with the correct offsets, during live enforcement: DIR = 0x009 (pins 0 and 3 are outputs, bit 7 never set), OUT = 0x08 in 8 of 40 samples (motor pulsing), PIN_CNF[7] = 0x0 where every unused pin reads 0x2. That is the buzzer-disable proof: the pin is not an output at all.

Consequence: Read GPIO state from the offsets above, not from memory of nRF52. And prefer this method over a firmware self-report when the question is "can this pin ever drive" — it is independent of the code under test.

2026-09-10 — four consecutive nightly /parselogs runs failed in under a second and told nobody

Context: The impulse-parselogs.timer user unit runs /parselogs unattended at 04:00. Between 2026-09-05 and 2026-09-10 the vault received no commit, and the obvious reading — the machine was off — is wrong.

Finding: The timer fired correctly every time it could. The service failed. journalctl --user -u impulse-parselogs.service shows status=1/FAILURE on 2026-09-06, 2026-09-07 and 2026-09-09, each after 680–1020 ms of wall clock — far too fast to have read a log, let alone written one. (2026-09-08 has no record at all; the boot log shows the machine down.) The cause is visible on stderr when the unit is invoked by hand: Failed to authenticate: OAuth session expired and could not be refreshed. The credential expired around 09-05 and every subsequent run died at startup. It works again as of 2026-09-10, which means something re-authenticated interactively in between — not the timer.

Evidence: three Failed to start Impulse — run /parselogs over the daily logs records in the user journal, sub-second Consumed … CPU time lines on each, and a five-day hole in git -C ~/Impulse/vault log between 3643c9b (09-05) and this run.

Consequence: two things, and the second is the general one.

  1. OnFailure= is no longer a hypothetical. The open question in Todos.md — "decide whether a failed run should notify you" — has been answered by events: four runs were lost and the only reason anyone knows is that this run went looking. Four lines of unit file.
  2. An expiring credential is a silent single point of failure for every unattended agent on this machine, not just this one. A run that cannot authenticate produces exactly the same trace as a machine that was switched off — none — which is the same indistinguishability problem already recorded for a missed CAD-REVIEW. Anything scheduled here should assume its credential will expire and be built to say so.

2026-09-05 — /parselogs' filesystem sweep could not see the KiCad project, and reported three days of "nothing changed" on that basis

Context: The nightly unattended /parselogs run detects activity by sweeping ~/Impulse for files modified since the previous run. On 09-03, 09-04 and 09-05 it reported that nothing in the workspace had changed, and by 09-04 was building a narrative out of consecutive silent working days.

Finding: The sweep had a blind spot exactly where the hardware work happens. The KiCad project is deliberately not in ~/Impulse — CLAUDE.md states it lives at ~/Documents/KiCad/ImpulseBluetooth — so no amount of sweeping the workspace can detect PCB work. A home-directory sweep on 09-05 found KiCad had been open on the project since 18:49 on 09-04, which three consecutive workspace sweeps had reported as silence.

Evidence: ~Impulse.kicad_pcb.lck / .kicad_sch.lck / .kicad_pro.lck dated 2026-09-04 18:49–18:50; ps -p 2773 showing kicad started Fri Sep 4 18:49:49, still running 9 h 12 m later. The design files themselves were unmodified (Impulse.kicad_pcb 2026-08-09, Impulse.kicad_sch 2026-08-10), so nothing was saved and no design claim was filed — the finding is about the instrument, not the board.

Consequence: A /parselogs sweep must cover ~/Documents/KiCad/ImpulseBluetooth as well as ~/Impulse, and "nothing changed in the workspace" should never be written as "nothing happened." The two other known out-of-workspace stores are the camera holding AUG-01's only copy of the footage and any email tool holding the subscriber count — neither is detectable from this disk at all, which is why both have to be asked about rather than swept for. Lock files are also the only trace an unsaved KiCad session leaves, and they are cleaned up on exit: an open editor is perishable evidence, in the same way the Board V1 battery endurance test was before it was reflashed away.

2026-08-31 — the §9.3 fail-open does NOT replicate on the ESP32 build; the port raised it, the code answers it

Context: The nRF port's 2026-08-30 entry below found that §9.3's free-edit window is a fail-open when an event exists in the schedule while its integrity record is missing, and said "check whether the ESP32 build shares this — if it does, it is a live bypass on shipped hardware." It was raised for Todos.md and never filed there, so nothing was tracking it. Checked here against the code.

Finding — it does not share it, and the ESP32 build is explicitly conservative in both places the port was worried about. loosening_applies_immediately() in WatchFIrmware/src/main.cpp gates the whole free-edit branch on if (rec && !rec->settled). A missing settle record therefore falls straight through to quarantine rather than into the free branch — the opposite direction from the port's defect — and the comment above it says so in as many words: "A missing record is treated as settled (conservative)." The same choice is made again when the 16-slot table is full (if (!r) return; // table full: event stays untracked (treated as settled — conservative)).

Evidence: src/main.cpp:3934-3948 and :3692, read on 2026-08-31 against sunrise-lock-demo-fixes. No behaviour was changed and no test was run — this is a read of the branch condition, which is where the whole question lives.

Consequence: no ESP32 change is needed and no todo should be opened for one. The two builds reach the safe answer by different mechanisms — the ESP32 treats a missing record as settled, the port seals the event by creating a baseline from its current state — and both make an unrecorded event slow to loosen rather than instantly loosenable. Worth knowing if §9.3's prose is ever tightened: the spec sentence genuinely does not cover this case, and it is only the implementations, independently, that get it right. If a third implementation is ever written from §9.3 alone it will get it wrong.


2026-08-30 — first Zephyr boot on Board V1, and two persistence bugs that meant nothing was ever saved

Context: First hardware session for the NCS port (ImpulseFirmware/nrf_firmware/). The image boots, the enforcement path drives real pins, and four defects turned up that no amount of host testing would have found. Tooling notes are in that tree's README and test plan; this records what cost effort and is written nowhere else.

Finding — settings_save_one() silently could not store either security-critical structure, so NOTHING was ever persisted. struct impulse_schedule is 14,352 bytes and struct impulse_integrity is 21,192 bytes, against an NVS sector of 4096. Both writes returned -EINVAL every time. This is the worst shape a storage bug can take on this product: the device runs perfectly, accepts commitments, quarantines loosenings — and loses the lot on reboot, which is a loosening. Fixed by storing one record per event, per settle entry, and per pending entry, with stale records explicitly deleted so a shortened schedule cannot be resurrected. Any structure holding an array of impulse_event (224 bytes each) will hit this; check the size before adding another.

It was masked by an error-handling habit worth naming. The save helper did err |= impulse_storage_save_integrity(...). Bitwise-OR of negative errnos produces a meaningless number, and the first -EINVAL was invisible behind it. Report the first failure, never the OR.

Finding — the first condition check of a window was gated on UPTIME, not on window entry. The poll deadline compared k_uptime_get() against a last_poll initialised to zero, so a watch that had been up for less than one poll interval would enter a window, sit at its optimistic default of "condition met", and drive nothing for up to 180 seconds. On a stayNear commitment that is three minutes of silent non-enforcement at exactly the moment the window opens. Fixed with a force-poll flag set on entry; the same flag is where the IMU motion interrupt should hook in.

Finding — the buzzer needs NRF_GPIO_DRIVE_H0H1 under Zephyr, and the symptom is bizarre. At standard drive the pad sags so far into the buzzer's ~30 mA load that its own input buffer reads it back LOW while it is being driven HIGH — sampling P3 during a burst gave alternating motor=1 buzzer=0 / buzzer=1. High drive makes it solid. The bare-metal bring-up used extra-high drive and never saw this, which is how it got missed when porting.

Finding — on nRF54L the legacy CLOCK.LFCLK registers read all zero and that is NOT the RC fallback §3.2 warns about. SRC=0 RUN=0 STAT=0 at 0x5010E440 looks exactly like the silent failure the spec predicts, and it is not: the GRTC manages its own low-frequency source through CONFIG_NRF_GRTC_TIMER_CLOCK_MANAGEMENT (nrf_grtc_timer.c calls nrfx_grtc_clock_source_set(NRF_GRTC_CLKSEL_LFCLK)), not through the block older parts used. The authoritative signal is CONFIG_NRF_GRTC_TIMER_SOURCE_LFXO=y. Measured drift settles it empirically: +34 ppm over 140 s against host wall clock — crystal territory, where the internal RC would be hundreds to thousands. Over an 8 h sleep that is under a second, comfortably inside CLOCK_GUARD_S = 5.

Evidence: stayNear with the stubbed CS backend drove motor=1 buzzer=1 for ~2 s then the wait step; getAway, same backend and same abstention, gave 0 samples high over 20 s. That is §4.5's criterion-dependent fail-safe running on real hardware rather than in a test harness.

Consequence — three RTT/tooling traps that each look like a dead board: - JLinkRTTLogger cannot find the RTT control block on this target, even when given its exact address. The J-Link RTT telnet server (port 19021, after exec SetRTTSearchRanges 0x20000000 0x80000) finds it immediately. nrf_firmware/rtt.sh uses the latter. - The RTT down-channel defaults to 16 bytes, which silently TRUNCATES any shell command longer than that — it arrives as a mangled fragment, not an error. Set CONFIG_SEGGER_RTT_BUFFER_SIZE_DOWN=128. - The first thing written after connecting to that port is always lost. Prime with a bare newline before sending anything that matters. - And when piping RTT through grep, use stdbuf -oL: block buffering delivers the whole capture in one burst at the end, which silently destroys any measurement that timestamps lines on arrival. The first two clock-drift measurements were nonsense for exactly this reason.


2026-08-30 — NCS port: four build-system traps, and a fail-open in §9 that only writing tests exposed

Context: Overnight port of the ESP32 firmware to NCS/Zephyr with no hardware attached, into ImpulseFirmware/nrf_firmware/. NCS v3.4.0 installs cleanly on this machine (Python 3.14 was a worry and turned out not to be one). The code and its caveats are in that tree's README.md and HARDWARE_TEST_PLAN.md; only what is recorded nowhere else is here.

Finding — build system, all four cost real time:

  • BOARD_ROOT must be a command-line argument, not a list(APPEND ...) in the application's CMakeLists.txt. Sysbuild resolves the board before the application's CMakeLists is read, so setting it there fails with a bare "No board named 'impulse_v1' found" and an empty did-you-mean list — which reads like a malformed board definition rather than an ordering problem. build.sh exists to stop anyone rediscovering this.
  • A custom nRF54L board fails to build without owned-channels on &grtc. The error is #error GRTC owned-channels DT property is not defined from nrf_grtc_timer.c, several layers from anything you wrote. Copy Nordic's split: channels 7-11 are reserved for zero-latency IRQs and 3-4 for the FLPR core.
  • The SoC Kconfig symbol is SOC_NRF54LM20A_CPUAPP, not ..._ENGA_CPUAPP. Getting it wrong does not produce a missing-symbol error; it produces a cascade of HAS_NRFX ... has direct dependencies with value n warnings that look like driver misconfiguration.
  • CONFIG_WS2812_STRIP does not exist. The transport-specific symbol is CONFIG_WS2812_STRIP_SPI.

Finding — Zephyr's own DK board file states the PWM/GPIO domain rule we measured. zephyr/boards/nordic/nrf54lm20dk/nrf54lm20dk_common.dtsi carries the comment: "PWM signal can be exposed on GPIO pin only within same domain. There is only one domain which contains both PWM and GPIO: PWM20/21/22 and GPIO Port P1/P3." Independent corroboration of the 0/199-highs bench result from 2026-08-29, and a reminder that the board files are worth reading before the datasheet.

Finding — ws2812_spi cannot use the conventional 6.4 MHz on this part. The SPIM prescaler is an integer divisor of 16 MHz, so 6.4 MHz (8 SPI bits per LED bit at 800 kHz) is unreachable; the driver would silently drop to 4 MHz and stretch every bit to 2 µs. The configuration that works out is 8 MHz with 8 bits per LED bit, which reproduces the measured-good high times exactly — 0xC0 = 0.25 µs for a zero, 0xFC = 0.75 µs for a one — at the cost of a 1.0 µs bit period instead of 1.25 µs. The high times are what these parts latch on, so this should be fine, but the shortened period is untested and is the first thing to suspect if the ring misbehaves under Zephyr.

Finding — the nRF54LM20A's SAADC AIN-to-pin mapping is in none of our documents. datasheets/BM20C.pdf lists analog input functions only in its BM15 columns; the BM20 columns show plain GPIO. So battery sense (P1.00) and IR worn detection (P1.29) could not be given a devicetree channel and have no driver at all. Do not guess an AIN number — a wrong channel reads a different pin and returns entirely plausible values, which for a worn sensor means confidently reporting a watch on a nightstand as worn.

Finding — §9.3's free-edit window is a fail-open when integrity state and schedule desync, and the spec's prose does not cover it. §9.3 grants an immediate apply to "any change to an unsettled event whose resulting state is still ≥ its settled_baseline (or that has no baseline yet — the free first-setup window)". Reaching that state legitimately means adding an event, which records an edit and starts its settle window. But an event sitting in the schedule whose integrity record is missing — storage desync, a record that failed to load, a struct-layout change between builds — hits the same branch and is loosenable instantly and permanently. The port seals such events instead: a settle entry is created with the event's current state as its baseline, so the worst case is an event that cannot be loosened quickly, which is the safe direction for a commitment device.

Evidence: found by writing the host test, not by reading the code — the test constructed a schedule directly (as a boot-from-storage would) and a loosening that should have been quarantined applied immediately. nrf_firmware/tests/, 89 checks, ~1 s, no hardware.

Consequence: check whether the ESP32 build shares this. Its §9 implementation reads the same spec sentence, and its NVS layout has the same two independent records. If it does, it is a live bypass on shipped hardware: wipe or corrupt the integrity namespace and every existing commitment becomes instantly editable. Raised in Todos.md rather than fixed here, because it is an ESP32 change and this port is not the place for it.


2026-08-29 — First code execution on Board V1: the BM20C runs, the core is 64 MHz, and the buzzer works

Context: Bringing up the first Impulse Board V1 prototype over SWD from an nRF54L15-DK's DEBUG OUT, with nothing built for this board before. Goal was to prove code execution by beeping the buzzer.

Finding: - The board runs. Cortex-M33 r1p0, security extension implemented, secure debug enabled. RRAM was blank (PC = 0xEFFFFFFE on first halt). J-Link V9.72 knows the part as nRF54LM20A_M33; older J-Link versions do not list it at all. - The core clock out of reset is 64.00 MHz, measured by the target itself (DWT CYCCNT against TIMER20) as 64,004,486 Hz. Do not assume 128 MHz. - TIMER20 runs at 16 MHz out of reset with no clock setup whatsoever — no CLOCK peripheral writes, no HFXO request. MODE=0, BITMODE=3, PRESCALER=4 gives a 1 µs tick immediately, which is the cheapest reliable delay on a cold board. - P3.07 drives cleanly but the buzzer stayed silent on DC. With PIN_CNF = 0xF01 (output, input buffer connected, DRIVE0=E0/DRIVE1=E1 extra-high) the pin reads back 1 then 0 in the IN register as it is set and cleared, so the GPIO works and nothing is loading it down. No sound was heard at either standard or extra-high drive. - The pad mapping is not the problem — it is confirmed from the module's own datasheet. datasheets/BM20C.pdf p.14 (BM20C-specific table): module pad C9 -> nRF ball G5 -> P307, identical across all four BM20C variants. Beware p.11 of the same PDF, which is a cross-module comparison where pad C9 is P101 on BM15 and P000 on BMH20 — reading that table as the BM20C pinout will send you chasing the wrong pin. The KiCad symbol, firmware_spec_v3_nrf.md §2, and the datasheet all agree. - PIN_CNF on nRF54L is not the classic layout. There is no single 3-bit DRIVE field; there are two 2-bit fields, DRIVE0 at bit 8 and DRIVE1 at bit 10, each S/H/D/E (0/1/2/3), where E is an extra-high level the older parts do not have. CTRLSEL at bits 28-30 must stay 0 for plain GPIO. Code ported from an nRF52 pin config will set the wrong bits silently. - Register bases that cost time to find (secure aliases): P3 = 0x500D8600 (OUT +0x000, OUTSET +0x004, OUTCLR +0x008, DIRSET +0x014, PIN_CNF[n] +0x080+4n), TIMER20 = 0x500CA000 (CC[n] at +0x540). These come from nrfx/bsp/stable/mdk/nrf54l/nrf54lm20a/, which is the only place in reach that documents this part.

Evidence: ~/Impulse/.bringup/ — 124-byte bare-metal image, no Zephyr. Flashed to 0x00000000, verified by J-Link. Telemetry at fixed RAM addresses read back over SWD while the core ran free: 0x20000000 = B0FFEE00 (magic), 0x20000004 = 0x0C (12 beeps over ~120 s of runtime), 0x20000008 = 0x03D09186 (measured core Hz). The buzzer pin was confirmed against Impulse.kicad_sch first — Net-(BUZZER1-+) = BUZZER1 pin 1 + U7 pad C9 = P3.07, with R17 (10 k) pulling it to GND so it is silent through reset. This matches firmware_spec_v3_nrf.md §2; the spec table is correct.

RESOLVED — four module pads were never soldered at all. Bare gold, no wetting. Buzzer (C9/P3.07), wake button (D9/P0.05), motor (A7/P3.03) and one other were dead from first power-on. Lifting the BM20C showed those pads still gold while every working pad was tinned. Adding solder to all pads and reflowing fixed them: the buzzer, the motor and the LED ring all work.

The trap: a first reflow with added paste changed nothing. Paste that does not wet is indistinguishable from no paste, and re-running every electrical test afterwards produced identical results. If a pad tests open twice across a rework, lift the part and look at it rather than reflowing again — an unwetted pad is visually obvious and cost this bring-up several hours of indirect measurement.

The objective confirmation, which needs no shorting and no ears. Configure the pad as an input with the internal pull-up and read it:

before after
P3.07 with internal pull-up 1 0

R17 (10k to GND) now loads the pad and beats the ~13k internal pull-up. A change in this reading is far stronger evidence than either value alone, because the internal pull-up's exact strength cancels out — which is what made the single "reads 1" reading inconclusive earlier. This only works where the net has a pull below ~4k; the motor's R10 (100k) cannot register, so that pad needs the actuator test or a VCC short.

Two of my own wrong turns here, both worth not repeating: - I called C9 "proven open" from a VCC-short test that latched nothing. The reading was right but the claim was overconfident: a stuck-low pad and a short that never made contact are indistinguishable unless the short is independently confirmed — the user hearing the buzzer sound is what closes that hole, and I should have required it before concluding. - I proposed a solder bridge between A8 and A9 (GND) to explain the servo pad reading hard-low. The real fault was unsoldered pads, not bridged ones. That reading is still unexplained; re-run --scan before treating it as real.

The LED ring is the counter-example that breaks the whole-edge theory: pad A6 is fine. The ring lights, so A6 (P0.02, ball J3) conducts, R15 conducts, and the SK6805 chain is powered. The defect is therefore not a contiguous unsoldered edge — A6 sits between A7 (motor, dead) and C9/D9 (dead), and it works.

And the ring's "blindingly bright, unchanging white" was my bug, not the board's. That symptom is a WS2812 chain receiving every bit as a 1. It is worth recognising on sight, because it looks exactly like a hardware fault and is not one — a floating data line gives random flicker, whereas a stable full white means the line is connected and being driven with out-of-spec timing.

Do not bit-bang WS2812 on a P0 pin of this part. It cannot work. Measured on this board:

0-bit high frame time
target 20 cyc (0.31 us) ~660 us
bit-bang, no cache 139 cyc (2.17 us) 1421 us
bit-bang, ICACHE on 103 cyc (1.61 us) 791 us
PWM20 + EasyDMA exact, hardware-clocked 714 us

A single GPIO write to P0 costs ~25-40 CPU cycles even with the instruction cache enabled, because P0 is on a slow peripheral bridge — so the shortest achievable high time is ~1.6 us against a 0.45 us budget for a zero. Loop tuning cannot close a 3.5x gap; the waveform must be clocked by hardware. Enabling ICACHE (0xE0082000, ENABLE at +0x404) is still worth doing — it cut frame time nearly in half — but it is not sufficient.

PWM20 CANNOT drive a P0 pin. SPIM30 can. This is the single most important thing on this page. The LED ring is hard-wired to P0.02, and the peripheral you reach for first is the wrong one. Measured by having the firmware sample its own pad during a transfer, with DECODER.LOAD=Common so one waveform went to two pins at once:

Pin Highs / samples
P3.05 (free pin, PWM20's own bridge) 60 / 199
P0.02 (LED ring), same PWM, same instant 0 / 199

PWM20 reported SEQEND and a full AMOUNT every frame while driving nothing at all — DMA completion proves the engine ran, never that the waveform reached the pad. Always confirm a pad by sampling IN during the transfer.

The reason is domains. P0 sits in the 30 domain at 0x5010xxxx, whose peripherals are SPIM30/SPIS30/TWIM30/TWIS30/UARTE30 (all sharing 0x50104000), COMP/LPCOMP, WDT30/31, GPIOTE30 and CLOCK. PWM20/21/22 all live at 0x500Dxxxx and cannot reach across.

So the ring is driven by SPIM30, WS2812-over-SPI: PRESCALER = 4 gives exactly 4 MHz (measured: 1440 bits in 362 us = 3.98 MHz), and each LED bit becomes 5 SPI bits — 10000 = 0.25 us high for a 0, 11000 = 0.5 us for a 1. SK6805 wants 0.3 +/-0.15 and 0.6 +/-0.15, so both sit inside spec, and 12 LEDs pack to exactly 180 bytes. MOSI = PSEL 0x02; SCK has to be assigned somewhere, and P0.06 is a module pad with no net, so it is a safe parking spot.

The ring works. T1H must be 0.75 us — 0.5 us is decoded as a ZERO by this chain. With 5 SPI bits per LED bit at 4 MHz, a 1 is 11100 and a 0 is 10000. 11000 (0.5 us) fails even though it is inside SK6805's nominal T1H window of 0.6 +/-0.15 us. Either the fitted LEDs are not the BOM part or their real threshold sits higher than the datasheet implies; 0.75 us is the top of the SK6805 window and close to WS2812B's 0.8 us typical, so it suits either part.

The diagnostic that identified it is worth remembering, because it inverts the obvious reading: a WS2812 chain that goes uniformly dark and stays dark is receiving VALID frames and decoding them as all-zero — invalid signalling makes these parts HOLD their last state instead. So:

Ring shows Means
solid white, unchanging every bit read as a 1 — high time far too long
uniformly dark, stable valid frames, every bit read as a 0 — T1H too short
random flicker / garbage marginal or unstable signalling
holds previous image no valid reset+frame arriving at all

Two nRF54L SPIM traps, both of which silently half-work: - EVENTS_END at the legacy 0x108 never fires — the DMA completion event moved. Waiting on it burns the full timeout every frame. - DMA.TX.AMOUNT is not a completion flag. It still holds the previous transfer's byte count when a new one starts, so polling it exits instantly and the code disables SPIM mid-transfer — which looks like a working driver in telemetry and produces nothing on the wire. Wait a fixed duration instead, or find the relocated event.

What this does NOT distinguish: an open joint on the module pad vs. an open joint on the part at the other end of the same net (S2 for the wake button, BUZZER1 for the buzzer). Reflow the discrete first — it is much cheaper than rework on a 100-pad LGA.

Counter-evidence worth keeping in view: the die is healthy and some pins work. SWD (J3/K3) is solid enough to flash and run repeatedly, and power (B8/C7/C8) is obviously good. So this is a localised edge defect, not a bad module.

Consequence: §1.4's "a TagConnect cable and an SWD probe are not optional" is now demonstrated, not predicted — and the harder half is mechanical, not electrical. The TC2030-NL has nothing holding it down, and a partial seat presents as VCC and GND making contact while SWDIO/SWCLK do not. That looks exactly like a dead board: VTref reads a healthy 3.300 V (the board's XC6220B331 LDO), yet a raw line reset returns no DPIDR at any speed. The DK's own nRF54L15 is unreachable at the same time, because DEBUG OUT has taken the mux. Before suspecting the target, press the cable in hard and retry — that alone was the entire fault here. A retaining clip or the legged TC2030 would remove the failure mode.

2026-08-26 — The BM20C grant has not issued, checked against the database for the first time — and the free window that SEP-12 depends on is therefore still open

Context: BLK-15 and SEP-12 both rest on one sentence in a Fanstel email dated 2026-08-04: BM20C FCC testing would complete "this month." Five days of that month remain and nothing in this workspace had ever checked the public record. BLK-15's own instruction is "get the grant number in writing before booking a lab slot" — so the grant number is the thing to look for.

Finding: Three facts, and the first one needs its limit stated or it will be over-read.

  1. No FCC grant for a BM20C exists, from any grantee. A site-wide product search returns "No FCC filing matches were found for BM20C", and Fanstel's grantee code X8W carries 36 authorisations of which not one contains BM20 or 20C. This says nothing about whether a filing is in progress. An application is not public until it grants, so a negative here is exactly what an on-schedule, not-yet-finished certification looks like. The only thing it rules out is that the grant has already issued and nobody noticed.
  2. Fanstel's most recent grant of any kind is 2026-03-20 (X8WBT840X, BT 5.0) — five months with no new authorisation. Their 2025 cadence ran roughly one every six to eight weeks: X8WBM15C 2025-01-07, X8WBC15 2025-07-25, X8WBM05 2025-09-03, X8WWM02C 2025-09-18, X8WLR62X1E 2025-09-19, X8WLR68E 2025-12-05. The current gap is an outlier against their own history.
  3. Both fallbacks are already granted: X8WBM15C (BLE 6.0, 2025-01-07) and X8WBC15 (BLE 6.0, 2025-07-25). BLK-15's contingency rests on authorisations that exist today, which is more than the primary part can say.

Evidence: https://fccid.io/X8W and https://fccid.io/search.php?q=BM20C, both read 2026-08-26. The FCC's own search is https://www.fcc.gov/oet/ea/fccid; fccid.io mirrors it and can lag the grant feed by a day or two, so read a negative as "not granted as of about now." The check takes ten seconds and had never been run.

Consequence: The useful direction is the opposite of the one it first looks like. drafts/fanstel-exposure-category.md is built entirely on the claim that its value expires when the grant issues — "their FCC testing is open now; the whole value of this email is that it arrives before the filing closes" — and that premise is now confirmed rather than assumed, on the fifth-to-last day of the month they named. The window is open. Sending the draft costs one click and the question stops being free the moment the grant appears.

Fact 2 is the one that should change a schedule belief rather than an action. "Testing completes this month" is a vendor's estimate from a company that has not put a new authorisation on the record since March, so MAR-05's lab booking and everything in Phase 6 should keep treating the grant as unscheduled — which is what BLK-15 already says, now with a number behind it.

Re-checked 2026-08-28, 2026-08-29 and 2026-08-30, unchanged every time. X8W still carries the same 36 authorisations, none containing BM20; https://fccid.io/X8WBM20C returns 404; and Fanstel's newest grant of anything is still 2026-03-20. Tomorrow is the last day of the month they named, and the free window SEP-12 depends on has stayed open for the full eighteen days the draft has sat unsent.

One distinction this entry did not draw, and three runs blurred it. "Two days left in Fanstel's month" and "two days left in the free window" are not the same statement. SEP-12's due date is the end of the month Fanstel named; the question it asks stops being free on the day the grant issues, which is a separate event, unrelated to any date in this plan, and one that gives no advance notice. It could arrive before Monday or in October. So the end of August is a reason to send the draft and is not a cliff after which the risk falls — reading it as one both overstates the urgency this week and understates it next.

Prune this entry when the grant issues. Its content is a dated negative; re-check https://fccid.io/X8W before citing it.

2026-08-12 — The V1 motor-pin correction reached the specs and missed two copies, one of which was committed a day after the fix

Context: On 2026-08-10 the Board V1 pin map was re-exported from Impulse.kicad_sch and two rows were found crossed: the vibration motor is P3.03/A7, not P1.02/A8, which is the servo. firmware_spec_v3_nrf.md §2, firmware_spec_v2.md §1.3 and Timeline.md were all corrected that day, each with a dated note.

Finding: The pin map is transcribed in more places than the correction pass looked at, and two of them still said P1.02.

  1. ImpulseFirmware/WatchFIrmware/README.md — in the "two things to know before touching hardware" note, which is the paragraph that governs demos and marketing copy. It was committed on 2026-08-11 at 15:42 (bce0955), a day after the correction, so the wrong pin was written into git after being fixed everywhere else.
  2. vault/docs/marketing/launch-plan.md §13, in the BLK-03 checklist line.

Re-verified from the schematic this run, not from any table: /SERVO → U7 pin A8 (P1.02) → U1 pin 1; Net-(U7-P303) → U7 pin A7 (P3.03) → R5/R10 → Q3 → M2; /LED_IN → U7 pin A6 (P0.02) → R15 → LED1.DIN.

Evidence: kicad-cli sch export netlist --format kicadsexpr on ~/Documents/KiCad/ImpulseBluetooth/Impulse.kicad_sch, 2026-08-12. Both copies corrected in this run.

Consequence: CLAUDE.md names the GATT contract as the thing declared four times; the V1 pin map has the same problem and nobody has written down where its copies are. Known transcriptions outside the authoritative table (firmware_spec_v3_nrf.md §2): firmware_spec_v2.md §1.3 and its §0.1 notes, Timeline.md AUG-14 and BLK-03, launch-plan.md §13, WatchFIrmware/README.md, and the quoted constant comment at firmware_spec_v2.md:2385. A pin correction has to sweep all of them. The deeper point is that a repo README is a place a stale hardware fact can survive a spec correction, because the correction pass reads the vault and the README lives outside it.

2026-08-12 — "With SAR" on a datasheet means an exposure exhibit, not a SAR test — and our own exclusion arithmetic was missing antenna gain

Context: datasheets/BC15M+Product+Specifications.pdf p.1 reads "FCC ID: X8WBC15 with SAR." On 2026-08-11 that line was read as Fanstel paid for SAR testing on the fallback module, which would have made a BLE-only BC15 watch authorised against a body with no permissive change and no cooperation from anyone — the single most valuable finding in the module research pass. The user pulled the actual exhibits the same evening.

Finding: Two things, and they point in opposite directions.

  1. The good half holds. files/X8WBC15_rf_exposure_report.pdf states "According to §2.1093 this is a Portable device" — the portable rule, radiator within 20 cm of a body. That is the exposure category a wrist device needs and the one X8WWM02C conspicuously lacks. The comparison in Module Alternatives.md §5 survives.
  2. The reading of "with SAR" does not. There is no SAR measurement in the filing. It is a SAR test exclusion by calculation under KDB 447498 §4.3.1 — the same formula this workspace runs by hand. "With SAR" means this filing carries an RF exposure evaluation, not this module went through a phantom.

The third finding is the one that costs us something. Fanstel's lab computes (8.01 dBm + 1.50 dBi gain = 8.94 mW) / 5 mm × √2.48 = 2.82 against a 3.0 threshold, at a declared tune-up tolerance of zero. Concepts/FCC Certification.md §5 computed 2.5 for the same case from +8 dBm plus 1 dB of tune-up and no antenna gain at all. Include gain and tune-up together and the ratio is 3.46 — not excluded. The BLE exclusion clears only at a near-zero declared tolerance, which is bought with production yield.

Evidence: vault/files/X8WBC15_rf_exposure_report.pdf and X8WBC15_MPE_report.pdf, both International Standards Laboratory report ISL-25LR090FMPE, downloaded by the user 2026-08-11. Worked through in Concepts/Module Alternatives.md §5; FCC Certification.md §5 corrected in place.

Consequence: Three things.

  1. Antenna gain belongs in every exclusion ratio in this workspace. The KDB formula takes source-based conducted power adjusted for tune-up, and every filing in evidence adds the antenna gain. Ours did not. The WiFi figure is so far over the threshold that gain does not change its verdict, but the BLE figure is decided by it.
  2. A "certification row" on a datasheet is a pointer to exhibits, not a claim you can act on. This is the second time in two days that a document was read as authority for something only the filing could settle — the first was the WM02C's grant. Both were free to look up. Look them up.
  3. The two exhibits in this filing disagree with each other, and it is worth knowing before anyone leans on them: the MPE report opens "This is a Mobile device" and evaluates at 20 cm with 6 dBi of gain, against the exposure report's portable evaluation at 1.50 dBi. At 6 dBi nothing is excluded at 5 mm. Which gain governs a host integration is a question for the TCB.

2026-08-11 — The ESP32 proximity engine was right 70–80% of the time, and the failures were visible on camera

Context: Two days of filming Sunrise Lock and Phone Dock demos on the ESP32 hardware, which is the only hardware that exists. The decision to delete the proximity engine in the nRF port (v3 §4, channel sounding replacing inference) was made on 2026-08-07 from bench behaviour and the argument that RSSI cannot be made reliable.

Finding: The user's own field figure, from the 2026-08-11 log: "it worked in about 70-80% of cases, but the 10-20% in which it made a wrong judgement kept showing up on camera, and made the product look really bad." Cherry-picking around the bad takes was possible; shipping it was not — "this is definitely a prototype that i would never ship."

Evidence: vault/Impulse - Daily Logs/2026-08-11.md. A field observation across many takes in one house, not a bench measurement — no capture session backs it, and it is not a number to put in a spec.

Consequence: The pivot to channel sounding now has an empirical justification and not only an architectural one, and it is the kind of evidence that is easy to lose because it arrived while doing something else. Worth holding onto for two reasons beyond the decision itself: it is the honest answer to "why did you move off the ESP32" for the September Hacker News post, which Timeline.md calls the strongest single piece of material in the plan; and it sets the bar the nRF build has to beat, which nobody has written down. A 10–20% wrong-verdict rate is what "not consistent enough" meant. If the CS build cannot be shown to do better, the pivot bought nothing.

2026-08-11 — LE Coded PHY is reachable on the ESP32-C3 today, and the S1b wall is what makes it a separate project

Context: Building WatchRangeTest / AnchorRangeTest, a throwaway pair to measure whether BLE alone can carry WATCH_REMOVED across a house — the question that decides whether the watch needs WiFi at all. "As much range as possible" pointed straight at LE Long Range, which nothing in this workspace had tried.

Finding: The ESP32-C3 on the current pinned toolchain can advertise and scan on the LE Coded PHY. It is one build flag, -DCONFIG_BT_NIMBLE_EXT_ADV=1. Both halves are genuinely compiled in, not stubbed to ENOTSUP: esp_nimble_cfg.h maps the flag to MYNEWT_VAL_BLE_EXT_ADV, and syscfg.h's own #ifndef default of 0 loses because the esp port header is included first. The prebuilt C3 controller already declares CONFIG_BT_BLE_50_FEATURES_SUPPORTED=y.

Worth roughly 10–12 dB of link budget at S=8 for the same +9 dBm, which is more than any other single lever available on this silicon.

The catch, and it is the whole reason this is two new projects rather than a flag: enabling CONFIG_BT_NIMBLE_EXT_ADV compiles NimBLEAdvertising out of the build entirely and replaces it with NimBLEExtAdvertising — a different class with a different API. Every getAdvertising() call site in AnchorFirmware/src/main.cpp and WatchFIrmware/src/main.cpp would have to be rewritten, including prox_platform_set_beacon_slot(), which is the beacon schedule's platform seam. This is the same wall proximity_engine/tests/prox_v2_spikes.md §S1b hit from the other side when it looked at ext-adv as a route to per-slot channel control, and it is recorded there as a cost without the compensating finding that the coded PHY is on the other side of it.

Evidence: Both firmwares build clean and link a real 626-byte ble_gap_ext_disc (riscv32-esp-elf-nm -S), plus ble_gap_ext_adv_configure / ble_gap_ext_adv_start on the anchor — an ENOTSUP stub would be a handful of bytes. platform = espressif32@6.9.0, NimBLE-Arduino ^2.0.0, both as pinned. On-air confirmation has not been made — no board had been flashed when this was written. The rig reports the coded and 1M sets as separate columns precisely so that a coded PHY that does not work in practice shows up as an explicit 0% next to a working baseline rather than as an unexplained silence.

Consequence: Two things follow.

  1. If the range walk shows coded materially beating 1M, that result belongs to the nRF port too, not just to the C3. The nRF54LM20A supports coded PHY as well, and the decision "does the watch carry a WiFi radio" is being made against Board V1 hardware. A positive result here is evidence about link budget in this house, which transfers; it is not evidence about the C3 specifically.
  2. Don't reach for ext-adv in the product firmwares casually. The rewrite is real and the beacon schedule is in its blast radius. If long range ever does move into AnchorFirmware, budget for that port rather than for a build flag.

2026-08-11 — A module grant is a set of conditions, not a power number — and four documents assumed otherwise

Context: Washington Laboratories replied to the first FCC quote request and attached the X8WWM02C Grant of Equipment Authorization. Until then, every FCC document in the vault treated "the WM02C is already certified" as settling the WiFi module's regulatory status, and framed the remaining work as an arithmetic problem — get the KDB 447498 exclusion ratio under 3.0.

Finding: The grant carries a condition that no arithmetic can satisfy: ≥ 20 cm separation from all persons. A wrist-worn host breaks it. Clearing the SAR exclusion threshold is necessary and not sufficient — you can hit a ratio of 0.5 and still be outside the grant, because the 20 cm clause is a term of the authorisation rather than a consequence of the declared power. Correcting it requires a permissive change, which only the grantee may file.

Corrected 2026-08-12. This entry originally said the grant carried two such conditions, the second being no co-location with any other transmitter. That was wrong, and the user caught it by reasoning about the product rather than the paperwork: the WM02C is sold as a WiFi companion module for the nRF54 series, so a grant forbidding it to sit beside a BLE host would make the part incoherent. The grant sentence ends "except in accordance with FCC multi-transmitter procedures." Co-location is permitted; the exposure category is the sole unqualified problem. The lesson in the "Consequence" below survives intact and gains a sharper edge — see point 3.

Two secondary errors fell out of the same assumption. The exposure arithmetic had been run against the datasheet's 18.24 dBm average rather than the grant's declared 0.17 W (+22.3 dBm) — ratio 53, not 26.2. And the "cut TX power in firmware" route had been priced as free, when an integrator has no standing to redeclare a module's certified maximum at all.

Evidence: vault/files/fanstel_doc.pdf, FCC Form 731 granted 2024-09-13 by Kiwa Nederland B.V.; the grant text is quoted in Concepts/Module Grant Conditions.md §0 and §2, which works the whole thing through.

Consequence: Two general lessons, both cheap and both skipped here.

  1. Read the grant, not the datasheet's certification row. A datasheet says certified; only the grant says certified to do what. FCC Certification.md was written from two datasheets read end to end and was excellent on everything they contained — the gap was a document nobody had opened. The grant is free from the FCC's equipment authorization search and always was.
  2. When a vendor's certification is in flight, the exposure category is the thing to ask about, and the window closes. Fanstel is filing the BM20C's certification now. If they file it mobile-only — as they did the WM02C, which is the only evidence of their house style — the BLE-only fallback loses the "SAR-excluded by calculation alone" property that three documents and BLK-15 currently rest on. That question costs a sentence today and a permissive change after the grant issues.
  3. Read the whole sentence, including the exception clause — added 2026-08-12. The grant text was quoted correctly and completely in Module Grant Conditions.md §0, and the analysis in the very next line dropped its final qualifier. Module Alternatives.md quoted u-blox's identical clause with the identical qualifier and walked past it a second time. Two correct quotations, two wrong readings, nine documents downstream in one day. The failure was not access to the primary source — it was reading a legal sentence for its prohibition and stopping before its exception. The tell was available without any FCC knowledge at all: the WM02C is marketed as a WiFi companion module, so any reading that forbids it to sit next to a host radio makes the product incoherent. When a compliance reading implies a vendor is selling something unusable, the reading is the thing to check first.

Note for whoever reads this next: fccid.io and apps.fcc.gov both return 403 to automated fetches, so FCC KDB primary text could not be verified directly. The KDB 178919 question flagged in Module Grant Conditions.md §5.1 — whether mobile → portable is genuinely a Class II permissive change or requires new certification — is still open and is the largest unpriced item in the FCC work.

2026-08-11 — The regulator investigation was closed by the repair, not by an answer

Context: /parselogs. The 2026-08-10 anchor failure block in Todos.md came back ticked Done with the note "all i had to do was replace the fried component, works fine now."

Finding: The repair is real and the anchor works. The diagnosis is not closed, and the distinction matters because the block that carried the open questions has now been dropped. Three questions were never answered:

  1. Which rail the V3 header actually carries — VCC per the user's inspection, switched /VBAT per every ESP32 schematic in ~/Documents/KiCad including the 2026-08-03 one. A meter settles it in fifteen seconds and no file in that tree can.
  2. Which schematic revision the fabricated V3 boards came from. No copy in the KiCad tree matches the boards on the bench.
  3. Whether the other two dead boards also had a servo or LED-ring lead plugged into U1. This is the one that decides whether the shorted cable explains all three failures or only the latest — i.e. whether there is a second failure mode nobody has found. It is answerable from memory and costs nothing.

Evidence: The Todos.md block as ticked, against its own unticked sub-steps. Measurement confirmed on 2026-08-10: 4.9 V at U5 pin 1 on both a broken and a working board, which established the regulator as the failed part and eliminated everything upstream — that much is settled.

Consequence: Do not read "the anchor was fixed" as "the failure was understood." Three of five recent boards died the same way; one has a confirmed trigger. The respin item is correct regardless — a current-limited servo rail is right whichever rail the header carries and whatever killed the other two — so this does not block Concepts/FCC Respin.md §2. What it blocks is any claim that the power tree is now understood, which is a claim the next agent will be tempted to make from the repair alone.

2026-08-11 — On the ESP32 watch the vibration motor is compiled out, so no footage can show vibration

Context: Checking the Sunrise Lock footage shot on 2026-08-10 against launch-plan.md §10's claims fence, which forbids showing vibration and the LED ring on one unit until the new board revision is validated.

Finding: The footage cannot violate that rule, because the hardware cannot. WatchFIrmware/src/main.cpp:46 sets DISABLE_MOTOR 1, which compiles the motor driver out entirely so FastLED owns GPIO 10 — the pin the motor and ring share on this revision. The buzzer is a separate part on GPIO 21 and is unaffected. So "buzzing and flashing red" in the 2026-08-10 log is buzzer plus ring, and no build of this firmware can drive the motor at all.

Evidence: main.cpp:36-46, and the comment at line 39 stating the intent directly. Impulse Launch Video.md line 175 independently reaches the safe position — "if there's any doubt, let the buzzer and the anchor carry the demo."

Consequence: The §10 risk is inverted from what it looks like. Nobody can accidentally film vibration; what they can do is claim it. AUG-14's "shot on a motor-enabled unit" is unmeetable on any current board, so any script line or caption implying wrist vibration is unsupported by every frame that exists. Check copy, not footage.

Context: Verifying the v0.16 enforcement fixes with the watch sitting roughly a metre from its anchor.

Finding: Over one 5-minute getAway window the watch got 4 successful anchor scores and 2 outright connect failures — a third of its polls. Advertisement RSSI clustered at −88 to −98 dBm with the boards a metre apart, which is 30–40 dB worse than that geometry should give. Earlier in the same session the same pair sat at −79 to −82 dBm, so it drifts.

This is not a firmware fault and nothing in v0.16 addresses it, but it shapes how the engine behaves and is worth knowing before anyone tunes against these boards:

  • Connect failures route through prox_note_connect_failure, which is AWAY-pointing evidence. On getAway that is the direction that silences the alarm, so a flaky link biases the engine toward compliance — the exact thing §13.0 says to distrust.
  • PDR spent most of the window at ABSTAIN(cov=0..1), so the whole PDR channel contributed nothing; the observation window is not seeing enough of the anchor's beacon slots to form a rate.
  • It is very likely why the HMM sat at lam ≈ −82 and never reached NEAR despite scores of 181–217 against a 170 cutoff. The §13.0 NEAR floor is what makes enforcement correct anyway.

Evidence: run1.log from impulse-phone test enforcement --criteria get_away --beep-anchor, 2026-08-10 23:20–23:26. grep -c connect-failed = 2, grep -c 'PROX] SUCCESS' = 4, RSSI histogram peaking at −90/−91.

Consequence: Do not treat these two boards as a reference RF environment, and do not tune engine constants against them without checking the link first. Given the 2026-08-10 note below about regulator failures across this prototype batch, a marginal antenna or supply on one of these two is plausible and worth measuring before the nRF boards arrive and the comparison gets made.

2026-08-10 — The watch and anchor swap /dev/ttyACM numbers; address them by serial, never by number

Context: Reflashing the watch after its flash was erased.

Finding: Both boards enumerate as 303a:1001 USB JTAG serial debug unit, and the ACM numbers are not stable across a reset. They swapped — watch ACM2 → ACM1, anchor ACM1 → ACM2 — at the moment the watch was erased and re-enumerated. Two traps follow:

  1. An already-open file descriptor keeps reading the OLD device after the numbers are reassigned, so a running serial tap keeps logging the right board while /dev/ttyACM2 now points at the other one. The log looks authoritative and the port name lies.
  2. Consequently flash.sh … /dev/ttyACM2 wrote watch firmware onto the anchor. Symptom was confusing rather than obvious: the watch stayed in a invalid header: 0xffffffff boot loop (it had never been flashed), while the anchor silently dropped off WiFi and its HTTP endpoint (it was running watch firmware). Twenty minutes went into suspecting the flash offsets in flash.sh, which were correct all along.

Evidence: /dev/serial/by-id/ timestamps both regenerated at 22:54, the instant of the erase. esptool … flash_id reports the MAC, which is the only trustworthy identity check: watch 38:44:be:8b:ac:cc, anchor 38:44:be:8a:e5:24 (its BLE MAC is …e5:26, i.e. base + 2).

Consequence: Always address a board as /dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_<MAC>-if00, and confirm the MAC: line esptool prints before writing. A tap should re-resolve that symlink on every reconnect rather than caching a device node. No harm done in the end — flashing an app does not touch NVS, so the anchor came back with its schedule (CRC 0x12c19853, unchanged) and WiFi credentials intact once anchor firmware was restored.

2026-08-10 — PlatformIO's bundled esptool cannot flash these C3 boards; the venv's 4.11 can

Context: pio run -t upload --upload-port /dev/ttyACM2 — the command CLAUDE.md gives — failed on the watch.

Finding: esptool 4.5.1, which PlatformIO bundles at ~/.platformio/packages/tool-esptoolpy@1.40501.0, dies in USBJTAGSerialReset with OSError: [Errno 71] Protocol error from self._setRTS(False). esptool 4.11, already installed in phone_sim/.venv, resets and flashes the same board over the same cable with no trouble. Both boards enumerate correctly as 303a:1001 USB JTAG serial debug unit, so this is not a detection problem — 4.5.1 picks the right reset strategy and then fails to drive it.

Evidence: ~/.platformio/penv/bin/python -m esptool --chip esp32c3 --port /dev/ttyACM2 chip_id → esptool.py v4.11.0 … MAC: 38:44:be:8b:ac:cc, clean. The same operation through pio run -t upload → traceback ending at serialposix.py:708 fcntl.ioctl(self.fd, TIOCMBIC, TIOCM_RTS_str).

Consequence: Build with pio run, flash with esptool 4.11 directly. Offsets for these boards: 0x0 bootloader, 0x8000 partitions, 0xe000 boot_app0.bin, 0x10000 firmware. The failed attempt is harmless — it errors before resetting anything, and the board keeps running. This does not contradict the "never toggle DTR/RTS" rule: that rule is about taps and monitors, and both esptools toggle those lines on purpose because flashing requires it.

2026-08-10 — Anchor beeping has a single point of failure nobody had noticed: the watch's WiFi

Context: Chasing "the anchor does not sound when the watch is taken off", on the bench watch + anchor.

Finding: The bench watch had no saved WiFi credentials at all (nvs_get_str len fail: wifi_creds NOT_FOUND), and anchor escalation is UDP-only, so every WATCH_REMOVED was refused inside send_to_anchors before a byte left the watch. Two things made this cost far more time than it should have:

  1. try_connect_saved_wifi_async() was silent on every path, including the g_wifi_cred_count == 0 early return. A watch that could never reach an anchor was indistinguishable from one choosing not to.
  2. No WiFi also means no SNTP, and the watch's clock read 1952-02-07. The watch owns the schedule and the clock, window entry matches on the exact local minute, and the anchor separately window-checks each datagram against its own SNTP-correct clock. So the missing credential silently corrupts window timing as well as escalation — two symptoms, one cause, and they look unrelated.

Evidence: Anchor present in the host's ARP table at 192.168.1.15 (38:44:be:8a:e5:24), watch absent. A host-injected 33-byte datagram to both 192.168.1.15:5555 and the broadcast address produced [UDP] cmd=0x01 … WATCH_REMOVED ignored: event not in this anchor's schedule — proving the anchor's whole receive path, schedule load and guard chain were healthy the entire time. The fault was never on the anchor.

Consequence: Fixed per §5.5.1 v0.16 (per-poll re-assert, WiFi held for windows with beepAnchors, retry path logs). The architectural point is not fixed and should be considered: the watch already central-connects to the anchor on every proximity poll, over BLE, with no WiFi involved. Carrying the escalation over that link instead of UDP would delete this failure mode entirely. It was not done here because it is a new anchor characteristic and therefore a four-way lockstep wire change (§6, both firmwares, app, phone_sim) — far beyond the session's scope, and the ESP32 tree is being retired for the nRF port anyway. If anchor beeping survives into v3, build it on the link that already exists.

2026-08-10 — The v2 HMM can strand its posterior on the wrong side for a motionless wrist

Context: "The watch does not beep when I am standing next to the anchor" — the getAway half of Sunrise Lock.

Finding: hmm_fold()'s still-window ceiling is not merely slow to converge; for a stationary watch it can be stuck. Successive polls one metre from the anchor, all reading well above the anchor's calibrated cutoff, produced an unchanged lam:

v0.8=NEAR v2=AWAY   p_near=16  lam=-731  score=209/170 emit=+152
v0.8=NEAR v2=AMBIG  p_near=120 lam=-34   score=213/170 emit=+184
v0.8=NEAR v2=AMBIG  p_near=120 lam=-34   score=205/170 emit=+120

This is the mechanism working as written — no fresh fading draw means the window's evidence is capped at one observation's worth, and g_hmm_still_credited_q8 refuses to re-credit the same reading. But the ceiling is symmetric, while the threat model (§13.0) is not, and the two combine badly: the getAway cold start biases lam to AWAY, and a still user then cannot accumulate their way out of it. The alarm fired only when §13.4-R6's liveness timeout revoked the gate — the right outcome for the wrong reason, up to five minutes late, and it would have gone quiet again the moment the user walked enough to arm the gate while still standing next to the anchor.

Evidence: Above, captured on /dev/ttyACM2 2026-08-10 20:41–20:46. Also worth keeping: on a later run with a healthier link (PDR 88 %, llr=+768, one FIDGET tick) the filter reached lam=+1791, p_near=255 on its own. So the failure needs a poor PDR channel plus stillness — it is intermittent, which is exactly why it read as flaky hardware.

Consequence: Fixed at the prox_decide() seam, not in the engine — see §5.4.1 / the v0.16 change log. The dead end worth recording: the tempting fix is to relax the still-window ceiling for NEAR-pointing evidence inside hmm_fold(). Do not, without field validation. It is symmetric on purpose, and on stayNear a NEAR verdict silences the alarm, so making NEAR cheap inside the filter hands an attacker the frozen-fade exploit the ceiling exists to close. The seam fix confines the asymmetry to the one place the threat model already argues for it, and leaves AWAY, AMBIGUOUS, PDR, connect-failure and the locomotion gate untouched.

2026-08-10 — Board V1 inherits the whole power tree that is killing ESP32 prototypes, tantalums included

Context: The 2026-08-10 log reports the anchor prototype dead — 3 of the 5 most recent ESP32 boards have failed the same way, with the 3.3 V regulator outputting 2.8 V, 3.0 V and 2.5 V respectively against inputs the user measured as identical to a healthy board. The anchor had been sitting plugged in and unused for five days. Its cell read 0.7 V while connected and 4.1 V once disconnected — the pack's protection FET latched out. The user's stated next step was to hot-air a replacement regulator on.

Finding: The failure is not diagnosed, but two things about it are now established from the schematics rather than from memory.

  1. The power tree is identical on Board V1 and on the ESP32 boards. Both carry U5 XC6220B331MR (Torex 1 A LDO, SOT-23-5), U4 TP4057 charger, an AO3401A P-FET battery mux, and DSK24 Schottkys diode-OR-ing USB and battery into the LDO input. Board V1 adds exactly two parts in that path — L1 BLM15BB121SN1D ferrite and R18 2.2 Ω — and the log is right that both were added for EMC. Neither addresses any plausible cause of this failure, and R18 puts 2.2 Ω in series with the entire board load, including twelve SK6805 LEDs, so it costs headroom rather than buying margin. Whatever is killing these boards is un-fixed on the boards now in fabrication.
  2. Every tantalum on both designs is under-derated, and the worst one sits across the Li-ion cell. C5 is a 100 µF 6.3 V tantalum directly on /VBAT, a rail that reaches 4.2 V — 1.5× derating against the 2× minimum tantalums conventionally need, and tantalums fail short, progressively, under sustained DC bias. C18 (100 µF 6.3 V) sits across the vibration motor's flyback node, which is the other place a tantalum least wants to be. C9/C13/C32/C37 are more comfortable.

Evidence: Netlists exported with kicad-cli sch export netlist on 2026-08-10 from ~/Documents/KiCad/ImpulseBluetooth/Impulse.kicad_sch (Board V1) and .history/Impulse.kicad_sch (the ESP32-C3-WROOM-02 design, June 2026). V1: /usb-filter → L1 → R18 → VBUS → U5 VIN+CE, U5 VOUT → VCC. ESP32: D4/D5 → Net-(U5-CE) → U5 VIN+CE. Tantalum net attachments read from the same netlists.

Consequence: See the 2026-08-10 addendum below — the measurement was taken the same day and it settles which part failed, though not yet why. The recurrence rate matters more than the repair either way: AUG-14/BLK-03 require an assembled unit to demonstrate vibration and the LED ring together, and a 60% infant-mortality rate on the power tree is a validation gate that will not close.

Addendum, 2026-08-10 — measured: the regulator really is the failed part, and reverse current is the likely reason

Measurement (user, 2026-08-10): GND to U5 pin 1 reads 4.9 V on both a broken board and a working one.

What that settles. Two of the three hypotheses die on this one reading.

  • Fault upstream of the LDO — eliminated directly. The part is being fed 4.9 V and putting out 2.8 V.
  • LDO in current limit against a downstream short — eliminated indirectly, and this is the part worth keeping. The LDO input is fed through D5, a DSK24 Schottky, whose forward drop is strongly current-dependent (~0.2 V at milliamps, ~0.55 V at an amp). A broken board sinking amps into a short would read visibly lower at pin 1 than a healthy one. It reads identically. So both boards draw comparable current, the load is not shorted, and the pass element or its feedback is damaged. The user's original diagnosis was right.

Why it keeps happening — the strongest remaining candidate is reverse current from Vout into Vin. Output bulk capacitance vastly exceeds input capacitance on both designs:

Input node (at the LDO pin) Output (VCC) Ratio
ESP32 board 32 µF (C13 10 µF + C14 22 µF) ~571 µF (incl. C11/C19, 220 µF each) 18 : 1
Board V1 0 µF — see below ~582 µF —

When VIN collapses — USB unplugged, mux switching, a brownout — VOUT is held up by half a millifarad and the pass element is reverse-biased. Repeated cycles degrade an LDO progressively rather than all at once, which matches boards that worked for weeks and then didn't, and matches three survivors reading three different wrong voltages. The conventional mitigation is a Schottky from VOUT (anode) to VIN (cathode), plus input capacitance not dwarfed by output capacitance.

Board V1 makes this worse, not better. Its C13/C14 did not move with the regulator: they sit on /usb-filter, which is upstream of both L1 and R18, so VBUS — the net that actually lands on U5 pin 1 and pin 3 — carries no capacitance at all. The regulator's input pin is fed through a ferrite bead and 2.2 Ω from the nearest bulk capacitor. That is a stability and transient problem in its own right before it is a reverse-current one; LDOs require a local input capacitor.

Not yet verified: whether the XC6220 has internal reverse-current protection, and what input capacitance Torex actually require. The datasheet is not in datasheets/ — filing it is a step in Todos.md, and it should be read before the respin commits to a fix.

Consequence: Reworking the regulator is now the right call, and the boards that come back will fail again unless the respin adds a VOUT→VIN Schottky and puts a capacitor on VBUS next to the pin. Keep one dead board un-reworked anyway — a diode-test from U5 pin 5 to pin 1 on a dead part versus a healthy one is the cheapest confirmation of the reverse-current theory, and it is unavailable once every board is repaired.

Addendum 2, 2026-08-10 — the trigger was a shorted servo lead, and the servo rail is worse on Board V1

Finding (user, 2026-08-10): the tape insulating the servo cable's power and ground conductors had fallen off, and they had almost certainly been shorting. That is the external event, and it explains more of the log than any of the electrical hypotheses above did:

Observation Explained by a shorted servo lead
Cell 0.7 V connected, 4.1 V disconnected Directly. U1 pin 2 is the VBAT rail, so power-to-ground on that cable is a dead short across the pack. The protection FET latched, and released once the load came off.
"every slight movement caused it to beep for half a second, then it stopped entirely" Directly, and nothing else did. An intermittent short making and breaking as the board is handled browns the rail out in pulses; then the cell latched and it went quiet for good.
Worked five days ago, sat plugged in, dead today Tape works loose, the short develops, and the pack drains despite mains — the short is downstream of the cell, so the charger cannot win against it.
Regulator degraded with a healthy 4.9 V input Via reverse current, below.

This does not compete with the reverse-current mechanism; it supplies its trigger. The servo rail is VBAT, not VCC, so the short cannot have overloaded the regulator's output — which leaves input collapse as essentially the only path by which it could have damaged U5 at all. Running on battery, a dead short on VBAT drags the LDO input to near zero in microseconds while ~570 µF of output bulk holds VOUT at 3.3 V; the pass element's body diode then dumps that charge backwards into the short. An intermittent lead does it repeatedly. One caveat that should be tested rather than assumed: with USB attached, D4 ought to block a shorted VBAT from pulling the LDO input down, so the damaging events were presumably on battery or during plug/unplug transitions.

The design consequence is larger than the repair, and it points at Board V1. On the ESP32 board the servo/ring connector's power pin is fed from Q2, an AO3401A load switch. On Board V1 Q2 is gone and U1 pin 2 is wired straight to /VBAT — the raw cell, permanently live, with no switch, no fuse and no current limit. The fault that just destroyed an anchor is therefore easier to cause on V1 and impossible to isolate in firmware. A chafed or pinched servo lead is an ordinary field event on a shipping product, not a bench accident, and on a Li-ion product it should not be able to flatten the pack or destroy the regulator.

Open: whether the other two dead boards also had a servo or LED-ring lead attached. If they did, this is the cause of all three and the electrical hypotheses reduce to "why the board didn't survive it." If they didn't, there is a second failure mode still unaccounted for.

Addendum 3, 2026-08-10 — which rail the 3-pin header carries: the board and the schematics disagree

The user's account (2026-08-10): the V3 ESP32 boards — the ones being tested now — carry GND, VCC, LEDDATA on U1. The change to GND, VBAT, LEDDATA was made deliberately for the nRF version, because VBAT sits above VCC and a servo wants the higher rail. That is sound: 3.3 V is marginal for a hobby servo.

Every ESP32 schematic in ~/Documents/KiCad says otherwise, including the newest. In ImpulseBluetoothBackup/Impulse.kicad_sch (mtime 2026-08-03, four days before the respin): U1.1 = /LED_IN, U1.3 = GND, and U1.2 = Net-(Q2-D), the drain of Q2 (AO3401A) whose source is on /VBAT. The June .history revision is identical. The February revisions have a different pinout again (U1.2 on GND). No file in that tree shows VCC on U1.

Trust the board over the file — but note what the mismatch implies: the fabricated V3 came from a revision that is not in ~/Documents/KiCad. That is worth knowing independently of this failure, because it means the ESP32 boards on the bench cannot be reasoned about from these schematics. Settle it with a meter rather than a file: on a working board with USB attached, the header's power pin reads ~3.25 V if it is VCC and ~4.1 V if it is VBAT.

Why it changes the diagnosis rather than decorating it.

  • If VCC: the short was a dead short across the regulator's output, so U5 was driven into current limit and thermal shutdown repeatedly, by an intermittent contact. That is direct overstress of the pass element and explains three survivors sitting at three different wrong voltages without needing any other mechanism. The flat cell follows — the LDO sources its limit into the short, drawing from VBAT through Q1/D4 until the pack's protection latches. The reverse-current hypothesis becomes unnecessary, and Board V1's move to VBAT is then a genuine improvement, since it takes the regulator out of the short path altogether.
  • If switched VBAT: the reverse-current account in Addendum 2 stands, and V1's change was not the rail but the removal of Q2.

What holds either way, and is the thing to act on: on Board V1 Q2 is gone and U1 pin 2 lands directly on /VBAT — no switch, no fuse, no current limit, with only the pack's protection IC between an external cable and a dead short. A servo is the worst load to leave unfused on a raw cell: stall currents of amps, inrush on every movement, and a lead that can chafe in normal use. The respin needs a current-limited load switch or a fuse on that header. The VOUT→VIN Schottky and the missing input capacitor on VBUS remain real, but they drop to "cheap to fix while you are in there" rather than being the explanation for anything.

2026-08-10 — Cadence misses get scored, not narrated as the plan failing

Context: The 2026-08-09 Progress.md scored CAD-FILM 0 for the week and added that "two consecutive misses is the signal Timeline.md says to treat as the plan failing." The 2026-08-10 log pushed back: "you're right, i should probably do things more in order, but im not sure i agree that that constitutes as a full miss. give me time to get in my rhythm."

Finding: He is right on the reporting, and the file was wrong to reach for the failure language on data point one of a rate. Timeline.md says two consecutive misses is the signal; quoting that clause while scoring the first one imports the conclusion into the evidence.

Consequence: Score the rate, name what was filmed instead, and let the second data point carry the judgement. The escalation clause gets quoted when it fires, not before. This is a reporting rule, not a softening of the commitment — CAD-FILM is still the mechanic Timeline.md says keeps the plan alive past week five.

2026-08-10 — Which end of a Fanstel module is the antenna, decided from the PCB file rather than assumed

Context: Answering the 2026-08-09 log's question about two-module antenna placement. Every measurement in that answer depends on knowing which end of each module radiates, and the KiCad footprints (BThing:WM02C, BThing:BM20C) carry no marking that says so.

Finding: The castellations sit in the RFI shield and never in the antenna area, so the pad-free end is the antenna end — and it can be proved rather than assumed, because the opposite orientation puts pads outside the shield, which cannot physically happen. On Board V1 both modules resolve to antenna facing west: U8 WM02C pads span x 93.49–101.19 within a body of 83.60–102.70, and U7 BM20C pads span 90.70–98.15 within 83.60–98.40. Both antenna areas therefore stop 0.6 mm short of the west board edge at x = 83.00, over the full four-layer stackup, and sit 1.15 mm from each other.

Evidence: ~/Documents/KiCad/ImpulseBluetooth/Impulse.kicad_pcb, read 2026-08-10. Shield and antenna lengths from datasheets/BM20C.pdf p.17 (10.3 + 5.5) and WM02C+Product+Specifications.pdf p.12 (11.7 + 7.4).

Consequence: The technique generalises to any castellated module with an integrated antenna, and it is worth reaching for before asking a vendor — pad extents in the PCB file are unambiguous where a courtyard rectangle is not. The findings themselves live in Concepts/Antenna Placement.md; the one to carry forward here is that Board V1's BM20C has the same keepout defect as its WM02C, which had been an open question since 2026-08-09 and is now closed the unlucky way. One further trap recorded there: the drawn BM20C silkscreen is 14.8 mm against a datasheet-implied 15.8 mm, so its antenna-area coordinates are good to about a millimetre and no better.

2026-08-10 — FCCReg/ in the KiCad project is a parts library, not regulatory material

Context: Progress.md has carried "the FCCReg folder in the KiCad project, touched 2026-08-05, is still undescribed anywhere" as an open unknown across three runs.

Finding: It is an ordinary KiCad library directory — sixteen JLCPCB part libraries by C-number (C17477, C21189, C2939726, …), each with a .kicad_sym, a .pretty footprint folder and a .3dshapes model folder. Nothing in it relates to FCC registration despite the name.

Evidence: Directory listing, 2026-08-10.

Consequence: Stop treating it as a lead. If anyone renames it, PartsLib would describe it honestly.

2026-08-10 — The website repo is public, and it has a live Stripe 2FA recovery code in it

Context: Todos.md carried a flag raised 2026-08-09 that website/src/stripe_backup_code.txt is tracked by git, with the first step "check whether github.com/ironon/ImpulseWebsite is public or private." Checking it took one request.

Finding: The repo is public. api.github.com/repos/ironon/ImpulseWebsite returns "private": false, and the file is fetchable unauthenticated at raw.githubusercontent.com/ironon/ImpulseWebsite/master/src/stripe_backup_code.txt (HTTP 200). So the 2FA recovery code for the Stripe account is disclosed, not merely at risk of being disclosed, and has been since the commit that added it. Deleting the file does not undo that — only rotating the code does.

Evidence: GitHub API, 2026-08-10. The remote is originn → https://github.com/ironon/ImpulseWebsite.git; origin points at https://impulsestore.net/ and is not a git host. src/files.zip, the other unexplained committed blob, is harmless — two files, shipping_policy.html and return_policy.html.

Consequence: Two standing rules follow from the repo being public, and they outlive this incident. Nothing secret may be committed under website/ — not keys, not recovery codes, not exports — and anything already committed there should be assumed read by someone. Recorded in CLAUDE.md so it is visible before the next commit rather than after. The rotation itself is Todos.md; this entry exists so the public fact is not re-discovered.

2026-08-09 — The Sunrise Lock out-of-range bypass is already closed, and WiFi is what closes it

Context: The 2026-08-09 log weighs dropping the WM02C from the first run and describes a bypass it believes is unsolvable without WiFi — leave the watch two floors down, out of BLE range, go back to bed; out of range reads as away, away is compliant, the alarm stops. The log's words: "the watch has no way to tell the anchor, because its out of BLE range, that it has been taken off" and "i dont think thats possible".

Finding: The mitigation exists and ships today. The watch detects removal from the IR sensor (firmware_spec_v2.md §5.2) and sends WATCH_REMOVED to the beeping anchors (§5.4.4, §5.5.1) — over UDP, so only when WiFi is up. send_to_anchors() returns before doing anything if it isn't, and logs "NOT SENT: no WiFi — anchor beeping is UDP-only".

Evidence: ImpulseFirmware/WatchFIrmware/src/main.cpp:1781–1786, the !g_wifi_connected early return. Reached via send_watch_removed_to_anchors() at :1878 and the window-start unworn check at :3084.

Consequence: Don't cost the WM02C as two enforcement criteria. Dropping it — deliberately, or involuntarily via the BLK-15 BC15C fallback — reopens a known bypass of the flagship template, and takes the anchor's SNTP time (§4.7) and the §5.5.3 repair path with it. Channel sounding does not help: it makes an in-range distance honest, and this attack is about not being in range. The general form is worth holding onto — an enforcement system needs a transport that outlives the range of the thing it is enforcing on, and BLE plus one radio is the cheapest way to have one. Recorded in firmware_spec_v3_nrf.md §1.5; the open decision is Concepts/NRF Redesign.md §A4.

2026-08-09 — The watch and the anchor are the same PCB, and that answers three open questions at once

Context: Writing firmware_spec_v3_nrf.md and needing to know what the anchor's radio would be, because Bluetooth Channel Sounding requires both ends of the link to support it — and no anchor PCB project exists in KiCad.

Finding: There isn't one because there doesn't need to be. ~/Documents/KiCad/ImpulseBluetooth is the board for both roles. The anchor is the same board in a different case with a servo on P3.09; the watch is the same board on a strap. (Amended 2026-08-10: the servo lead is soldered — U1's 3-pin footprint is never populated, as neither enclosure has room for a header. See firmware_spec_v3_nrf.md §2.)

Evidence: The author, 2026-08-09. Corroborated by the schematic, which carries a servo header, a battery charger, an LED ring, an IMU and an IR worn sensor all on one board — a combination that makes no sense for either role alone.

Consequence: Three things stop being open questions.

  1. Channel sounding works watch↔anchor, so the proximity engine is deleted outright rather than kept as a fallback. RSSI survives only for phone docking, where the peer is a phone and cannot do CS.
  2. The /SERVO net is explained. firmware_spec_v2.md v0.11 called it "unexplained on a watch" and suggested treating it as an expansion header. That was wrong and is corrected.
  3. launch-plan.md §5's FCC scoping — "one PCB, two host configurations, two exposure categories" — is now literally true, which is the cheapest possible outcome for certification and was previously only an assumption.

The cost is BOM: every anchor carries a charger, cell, LED ring, IMU and IR sensor it does not use. Worth revisiting under BLK-11 (anchor BOM reduction), against the certification saving and against the moved-anchor detection that the unused IMU makes almost free.

2026-08-09 — What to keep when deleting the proximity engine

Context: The nRF port deletes proximity_engine/ entirely — ~4,650 lines, two spec documents, and a year of tuning — because channel sounding measures what the engine inferred.

Finding: Two things in it are not about RSSI and would be silently lost with the code.

  1. The evidence asymmetry (proximity_engine_spec_v2.1.md §13): concluding AWAY requires more evidence than concluding NEAR. The reason is that physical attacks on a radio are overwhelmingly subtractive — shielding, blocking, a body in the way — and subtraction can fabricate "far" or "no reading" but never "near". That is a property of adversaries and antennas, not of RSSI, so it survives the pivot intact. It is carried into v3 §4.4 as a 2:1 dwell ratio with a note that the ratio is a floor, not a tunable.
  2. The test corpus encodes situations — walk-in, teleport rejection, frozen evidence, starved vector — not algorithms. Those situations still occur under CS.

Evidence: v2 §0.1 records what happened when the asymmetry was got wrong in the other direction: a stationary watch just out of range re-failed its connect every poll and marched its posterior −610 → −1378 → −2146 → −3473 to saturation on one observation repeated. v3 §4.5 keeps the corresponding rule — a connect failure is an abstention, not an AWAY measurement.

Consequence: When chunk G is built, port the asymmetry and the situational tests before deleting the engine, not after. The engine's other great asset — a host-compiled test suite that runs in about a second with no hardware — is worth rebuilding for the v3 §4.4 state machine, the §9 gate, and the schedule recurrence logic, all of which are pure logic and none of which need an nRF.

2026-08-08 — Removing USB data from the watch closes a no-tools bypass; it was a space decision that turned out to be a security one

Context: Transcribing the newly-ordered nRF board's pin map into spec §1.3 from a kicad-cli netlist export, and finding the USB data lines unrouted.

Finding: /DN and /DP are labelled on the BM20C's G6/G7 pads but connect to nothing; the USB-C connector's own data pads (J3.A6/B6, J3.A7/B7) export as unconnected. VBUS, CC1/CC2 and shield are correct, so charging works and USB data does not exist on Board V1. /POWER_DISABLE on P0.01 dangles the same way. Confirmed deliberate by the author: board space was tight, and RTT over SWD covers serial debugging.

Evidence: kicad-cli sch export netlist on ~/Documents/KiCad/ImpulseBluetooth/Impulse.kicad_sch (title block "Impulse Board V1", ordered 2026-08-07); nets /DN and /DP have exactly one node each. Author's confirmation, vault/Todos.md, 2026-08-08.

Consequence: Two, and the second is the one worth remembering.

  1. Budget RTT over SWD as the debug transport for the Zephyr port. There is no USB CDC console. Do not carry over the BENCH_NO_SLEEP workaround rationale (spec §8 / CLAUDE.md) — it exists because ESP32 light sleep powers down USB CDC, which cannot happen on a board with no USB CDC.
  2. USB CDC was an escape hatch, and this closes it. On a device whose purpose is to hold someone to a commitment made earlier, a serial port that lets anyone with PlatformIO erase the flash is a bypass needing no tools and leaving no trace — the author notes having used exactly that route on the ESP32 boards. SWD remains a bypass but costs a probe, a TagConnect cable, and the back off the watch. So a future revision that re-adds USB data "for convenience" is spending a threat-model property to buy a debug transport that RTT already provides. Make that trade knowingly. It is not marketing material — launch-plan.md §10 forbids "impossible to cheat" framing, and this doesn't approach it — and it is not a substitute for §11.2's deliberate hard override, which exists so that stopping is easy. The two pull in opposite directions on purpose; see vault/Concepts/Safety.md.

2026-08-08 — The RF-fingerprint proximity engine is being replaced by channel sounding, not fixed

Context: Three ESP32 prototypes and a two-phase proximity engine (proximity_engine, spec v2.1) built to infer watch↔anchor distance from RSSI and RF fingerprints, with an elaborate motion-conditioned HMM to stop a stationary receiver averaging one fading sample into confident nonsense.

Finding: The user's judgement, recorded in the 2026-08-08 log: "the proximity engine currently is not consistent enough, and the only way forward was this." The board was re-spun onto the nRF54LM20A (Fanstel BM20C) specifically because it supports channel sounding, which measures distance directly. The stated consequence is that the engine stops being an inference engine — "just check if the number channel sounding gave us is above or below a certain threshold, with a state machine for hysteresis."

Evidence: 2026-08-08 daily log; PCB ordered 2026-08-07 with the ESP32-C3 replaced by BM20C + WM02E.

Consequence: This is a decision made under ambiguity and it should stay revisitable, so: the v2.1 engine was never shown to be wrong — it was judged not consistent enough in the field. Its threat-model invariants are the part worth carrying forward rather than the physics. Specifically, spec §13's asymmetry — concluding AWAY requires more evidence than NEAR, because passive attenuation can fabricate AWAY but never NEAR — is a security property of an adversarial commitment device, not an artefact of RSSI being noisy. A channel-sounding threshold with hysteresis and no asymmetry is a simpler engine that is also easier to defeat by shielding. Carry the asymmetry, and the abstain-rather-than-guess floor, into whatever replaces it. The host-compiled test corpus in proximity_engine/tests is likewise worth keeping: it encodes the failure modes, not the algorithm.

2026-08-08 — This log was created; the workspace was reorganised

Context: Docs were scattered across four git repos plus loose copies in website/, and the firmware spec shipped as a base document plus a 17-part amendment that had to be applied in the reader's head.

Finding: The amendment had silently drifted from the code in at least eight places — PROX_V2_AUTHORITATIVE (0 → 1), BEACON_TX_LO_DBM (−21 → −6), the IMU thresholds (TBD → measured), and most consequentially the proximity vector wire format, which the amendment describes as v2-with-trailer while prox_serialize_vector() still emits the v0.8 [count][N×8] layout. An agent applying the amendment literally would have written a parser for a format no device sends.

Evidence: proximity_engine/src/proximity.h constants vs. amendment Part 14; prox_serialize_vector() in proximity.cpp; AnchorFirmware/src/main.cpp:2528 showing the score characteristic's third byte is near_threshold, not the amendment's neff.

Consequence: The amendment is merged into specs/firmware_spec_v2.md (v0.10) with every block marked ✅ shipped / 🟡 partly / 📋 planned, and archived. Trust the markers over the prose. When you implement a 📋 block, flip its marker and update §0.1 in the same commit.