How the watch and anchor range each other today¶
What this is: a walkthrough of the Channel Sounding (CS) ranging path as it exists in ImpulseFirmware/nrf_firmware on 2026-09-13 — from "a window opens" to "the motor turns on". It describes the code, not the intent; where the two differ, the code wins and the gap is called out. Numbers marked measured came from Board V1 hardware; everything else is a configured value read from source.
Not a spec. Behaviour is specified in specs/firmware_spec_v3_nrf.md §4; tuning history is in cs_tuning_plan.md and agent-notes.md. If this file and the source disagree, the source is right and this file is stale.
1. The short version¶
- A getAway/stayNear window opens → the watch turns ranging on.
- The watch scans for the anchor's iBeacon, connects as central, secures the link, discovers the anchor's Ranging Service (RAS), and negotiates a CS configuration.
- The two radios then run a CS procedure every ~100 ms: dozens of tone exchanges hopping across the 2.4 GHz channels. Each side records what it measured.
- The anchor streams its half of every procedure back to the watch over the link (RAS real-time ranging data). The watch pairs it with its own half and runs Nordic's distance-estimation library, getting three estimates:
ifft,phase_slope,rtt. - The watch collects 10 good
ifftsamples, takes the median, subtracts a calibration offset, and gets one true distance. That is one burst. - Each burst feeds a NEAR/AWAY state machine (2 near bursts → NEAR, 4 far bursts → AWAY). The verdict decides whether the commitment is met, which drives the motor, buzzer and LED ring.
- If the link goes silent, a lost-link rule takes over: output silenced within ~1.5 s (getAway), AWAY concluded after 10 s.
- When the window closes, the watch disables procedures, disconnects, and stops scanning.
The anchor never computes a distance. It only answers tones and hands over its raw data, because the watch is the root of trust (spec v3 §4).
2. Who does what¶
| Watch | Anchor | |
|---|---|---|
| Hardware | Board V1 (BM20C = nRF54LM20A), one chip antenna | Same board, same antenna |
| BLE role on the ranging link | Central (it connects) | Peripheral (it advertises) |
| CS role | Initiator — CONFIG_BT_CTLR_SDC_CS_ROLE_INITIATOR_ONLY |
Reflector — CONFIG_BT_CTLR_SDC_CS_ROLE_REFLECTOR_ONLY |
| RAS role | Ranging Requestor (rreq) |
Ranging Responder (rrsp) |
| Computes distance? | Yes | Never |
| Source | src/proximity/cs_backend_nrf.c, cs_fuse.c, proximity.c, enforcement/enforcement.c, cs.conf |
src/proximity/cs_reflector_nrf.c, src/ble/ble_anchor.c, cs_reflector.conf |
Both boards also run the phone-app GATT server, so each has CONFIG_BT_MAX_CONN=2: one slot for the app, one for the ranging link. That limit matters — see §9.
Antennas: single path on both sides (BT_RAS_MAX_ANTENNA_PATHS=1, antenna configuration A1_B1), no step mode 3.
3. When ranging runs¶
Ranging is gated on enforcement. Every pass of the main loop (100 ms) calls
- While no window is being enforced, the engine thread is parked: no scan, no link. A connection held 24/7 at a 20 ms interval would cost battery, and a connected anchor stops advertising, which once made a healthy anchor look dead for two days.
- When enforcement starts, the flag flips on and the engine starts scanning. Turning it on also clears the "CS activity seen" flag, so activity from the previous window can't count as a link this window has lost.
- When the window ends, the engine disables procedures, waits for the controller to confirm they're off (to avoid a known SoftDevice Controller assert, DRGN-29669), disconnects, frees its RAS client, and stops scanning. The log line is
CS: no longer needed — releasing the anchor.
The engine is one dedicated thread, impulse_cs (priority 5, 4 KB stack), running cs_engine_entry().
4. Finding and connecting to the anchor¶
What the anchor advertises¶
ble_anchor.c advertises connectably (BT_LE_ADV_CONN_FAST_1):
- Advertising packet: flags + an iBeacon manufacturer payload: company
0xFFFF, type/length0x02 0x15, the 16-byte anchor UUID, major0x4A0F(the Impulse namespace), and a minor. That fills 30 of the 31 bytes. - Scan response: the 16-bit Ranging Service UUID. It can't fit in the advertising packet.
What the watch looks for¶
The watch scans passively with one filter: manufacturer data starting with FF FF 02 15 (the Impulse iBeacon prefix). Matching the RAS UUID instead didn't work, because it lives in the scan response. The iBeacon match also has two real advantages: passive scanning saves battery, and it matches Impulse anchors rather than any CS device in range.
connect_if_match = 1: the scan module stops scanning and connects on the first match, using
| Connection parameter | Value |
|---|---|
| Interval | 0x10 = 20 ms (min = max) |
| Peripheral latency | 0 |
| Supervision timeout | 4 s |
Known gap: the watch connects to any Impulse anchor it hears. It does not check that the anchor UUID matches the one the event names (
event.anchorId). With two anchors in a house it can range the wrong one.impulse_cs_measure()still carries aTODO(hw)for this.
If connecting fails¶
- A synchronous create failure →
scan_connecting_error()→ rescan after 500 ms. - A connection that fails to establish or times out →
connected_cb(err != 0)→ wakes the engine, which restarts and rescans. (Fixed 2026-09-13,d78517c. Before that the engine hung forever with scanning off.) - The engine waits at most 30 s (
IMPULSE_CS_CONNECT_WAIT_S) for a connection before tearing down and rescanning.
5. Setting up the link (watch side, in order)¶
Each step waits on a semaphore (mostly 20 s). If the connection disappears at any point, connection becomes NULL and the engine jumps back to restart:.
| # | Step | API | Log line |
|---|---|---|---|
| 1 | Connected | scan module | CS: connected to F8:F0:… |
| 2 | Encrypt the link (security level 2) | bt_conn_set_security(L2) |
Security changed: … level 2 |
| 3 | MTU exchange (498) | bt_gatt_exchange_mtu |
MTU exchange success (498) |
| 3b | Find the anchor's watch-state characteristic (used for escalation, §8) | ws_discover_start |
anchor watch-state characteristic at handle 38 |
| 4 | Discover the Ranging Service, allocate the RAS client | bt_gatt_dm_start → bt_ras_rreq_alloc_and_assign_handles |
The discovery procedure succeeded |
| 5 | CS default settings: initiator on, reflector off, max TX power, repetitive sync antenna selection | bt_le_cs_set_default_settings |
— |
| 6 | Read the anchor's RAS features | bt_ras_rreq_read_features |
Read RAS feature bits: 0x1 |
| 7 | Subscribe to ranging data. The anchor reports 0x1 = real-time ranging data, so the watch subscribes to the real-time characteristic. The on-demand branch (ready/overwritten/control point) exists but isn't used against our anchor. |
bt_ras_rreq_realtime_rd_subscribe |
— |
| 8 | Exchange CS capabilities | bt_le_cs_read_remote_supported_capabilities |
CS capability exchange completed. |
| 9 | Create the CS configuration, for both sides | bt_le_cs_create_config(LOCAL_AND_REMOTE) |
CS config creation complete. |
| 10 | CS security (the phase/AA randomisation that makes CS hard to spoof) | bt_le_cs_security_enable |
CS security enabled. |
| 11 | Procedure parameters | bt_le_cs_set_procedure_parameters |
— |
| 12 | Enable procedures | apply_ranging_state() → bt_le_cs_procedure_enable |
CS ranging ENABLED, then CS procedures enabled: |
Typical time from "connected" to "procedures enabled" on the bench: ~1.7–5 s (measured in logs).
The CS configuration (step 9, cs_config_get())¶
| Field | Value | Meaning |
|---|---|---|
| Mode | Main mode 2, sub-mode 1 | Mode 2 = phase-based ranging (PBR) tones, giving ifft and phase_slope. Sub-mode 1 = interleaved round-trip-time packets, giving rtt. |
| Main mode steps | min 2, max 5 | Mode-2 steps between sub-mode insertions |
| Main mode repetition | 0 | |
| Mode-0 steps | 3 | Frequency-offset calibration steps at the start of each subevent |
| RTT type | AA only | RTT measured on the access address, no sounding sequence |
| CS sync PHY | 1M | |
| Channel selection | Algorithm #3b, hat shape, jump 2 | How the tone channels are ordered |
| Channel map repetition | 1 | |
| Channel map | bt_le_cs_set_valid_chmap_bits() — every channel CS allows |
The watch's
CS config creation completelog printsmode: Invalid. The mode passed in isBT_CONN_LE_CS_MAIN_MODE_2_SUB_MODE_1and ranging clearly produces mode-2 and RTT results, so this looks like a label-lookup issue in the print, not a wrong mode. Unverified.
Procedure parameters (step 11)¶
The watch requests:
| Field | Value | In time |
|---|---|---|
| Procedure interval (min = max) | 5 ACL connection events (real-time data); 10 if on-demand | 5 × 20 ms = 100 ms, ~10 procedures/s |
| Max procedure length | interval_max × 2 × (5 − 1) = 128 × 0.625 ms |
80 ms |
| Max procedure count | 0 | run until disabled |
| Subevent length (min = max) | 16 000 µs | 16 ms |
| PHY | 2M | |
| TX power delta | 0x80 (no preference) |
|
| Tone antenna config | A1_B1 | |
| SNR control | not used, both sides |
So by design, up to 80 ms of every 100 ms belongs to CS, leaving the ordinary link traffic (including the anchor's ranging-data notifications) squeezed into the rest. Keep that in mind for §9.
6. The anchor side¶
cs_reflector_nrf.c is deliberately small:
- On every connection (app or watch, since both are peripheral links and indistinguishable), it allocates a RAS responder instance (
bt_ras_rrsp_alloc) and enables the reflector role (bt_le_cs_set_default_settings: reflector on, initiator off, max TX power). A phone never starts CS, so this is harmless on the app link. - When the watch creates a CS config, it sets permissive procedure parameters (interval 1–100, subevent 10–75 ms, max procedure length 1000, 2M PHY) so that the watch's request decides the real cadence.
- Ranging data: the RRSP buffers each procedure's reflector-side step data and, because it advertises real-time support, streams it to the watch as notifications while the next procedures run. It holds one ranging-data buffer per connection (
CONFIG_BT_RAS_RRSP_RD_BUFFERS_PER_CONN=1). - On disconnect, the RRSP instance is freed on a private work queue, not in the disconnect callback. NCS's auto-free deadlocked the system work queue on every mid-send disconnect (fixed 2026-09-12/13; see agent notes). A free still stuck after 20 s reboots the anchor, with the reason recorded.
- Escalation: the anchor exposes a watch-state characteristic so the watch can say "I've been taken off" over this same link (§8).
The anchor logs reflector: ranging configured for this link and reflector: procedures ENABLED.
7. From radio to distance (per procedure, on the watch)¶
7.1 Collecting both halves¶
- The watch's own half:
subevent_result_cb()fires for each subevent result from its controller. It stamps CS activity (used by the lost-link rule), then appends the step data tolatest_local_stepsunless the subevent aborted. When the procedure completes it records the procedure's ranging counter. - The anchor's half: arrives as a real-time notification into
latest_peer_steps. When complete,ranging_data_cb()runs. - Pairing: both halves must carry the same ranging counter. If the anchor's data belongs to a different procedure than the watch's latest, the watch drops it (
Ranging data dropped as peer ranging counter doesn't match…). If the watch's own previous procedure is still waiting for its peer data when a new one arrives, the new one is dropped instead (Dropped subevent results. Waiting for ranging data from peer.). - If every one of the watch's subevents in that procedure aborted, there's nothing to compute:
All subevents in ranging counter N were aborted.
7.2 Estimation¶
bt_ras_rreq_rd_subevent_data_parse()walks both halves step by step, extracting phase/IQ per channel (PBR) and timing (RTT) into acs_de_report_t. Tones are only used when both sides rate their tone quality HIGH.- Tone quality gate: at least 15 channels must have usable IQ data, or the path is marked bad (
TONE_QI_OK_TONE_COUNT_THRESHOLD). cs_de_calc()(Nordic'scs_delibrary, 512-point FFT, ~1 ms per procedure on threadBT RX WQ, measured) produces three raw estimates in metres:
| Estimate | What it is | How we use it |
|---|---|---|
ifft |
Inverse FFT of the channel response; picks the earliest arrival, so reflections add peaks without moving it | The distance. Quietest (0.25–0.29 m single-sample noise, measured), most resistant to occlusion |
phase_slope |
Straight-line fit of phase vs frequency; every reflection pulls it longer | Quality cross-check only |
rtt |
Round-trip time of flight; coarse, but bounded by the speed of light | Relay/spoof cross-check only |
- If the library says quality OK, the estimate goes two places: a 9-sample sliding median used only for the log line
Latest distance estimates…, andimpulse_cs_on_estimate(), the product pipeline.
8. From estimates to the alarm¶
8.1 One burst = one measurement (cs_fuse.c)¶
Each estimate is converted to centimetres and offered to the live burst. A sample is rejected if:
| Check | Rule | Counter in cs burst DONE |
|---|---|---|
No ifft |
the library produced no primary estimate | miss_ifft |
| Out of range | ifft < 5 cm or > 3000 cm |
rej_range |
| Multipath / blocked path | phase_slope more than 250 cm from 0.916 × ifft + 197 cm (the line-of-sight relationship) |
rej_qual |
| Relay suspicion | rtt more than 400 cm above 1.037 × ifft + 278 cm (a relay can only add delay) |
relay |
Once 10 samples are accepted (IMPULSE_CS_BURST_MIN_ACCEPTED), the burst closes:
- If relay-suspect samples ≥ accepted ones → the burst fails (abstains). A suspected relay must never produce "near".
- Otherwise: median
ifft→ true distance: - raw ≤ 55 cm (inside the 2.4 GHz near field) → 0 cm
- raw ≤ 72 cm → 0 cm
- else (raw − 72) ÷ 0.993
The log line is cs burst DONE: n=10 rej_range=… rej_qual=… relay=… miss_ifft=… -> result=0 dist=… cm. Seeing dist=0 at arm's length is correct: raw readings of ~0.7 m are inside the offset.
Calibration caveat: slope 0.993 and the old 97 cm offset came from a dev-kit ↔ Board V1 line-of-sight sweep, with ±0.20 m residual (measured). The 72 cm offset is a single point for a BM20C pair (61 cm apart read 1.32–1.36 m, measured). The slope has never been verified for this pair.
8.2 Burst → verdict (proximity.c, per-burst dwell since v0.17)¶
Every completed burst is ingested on the next main-loop pass (impulse_enforcement_burst_update), and the criterion is settled on that same pass:
| Burst distance | Effect |
|---|---|
< 200 cm (NEAR_ENTER_CM) |
near_run++, away_run = 0 → 2 in a row = NEAR |
> 350 cm (AWAY_ENTER_CM) |
away_run++, near_run = 0 → 4 in a row = AWAY |
| 200–350 cm | both runs reset; verdict unchanged |
AWAY deliberately needs twice the evidence: blocking a radio can fake "far" but never "near" (threat model, spec §4.4).
Before any verdict exists, the criterion uses its fail-safe: getAway reads met (flashing green), stayNear reads not met.
8.3 Polls and abstention¶
The enforcement poll (every 10–180 s) no longer takes readings. It only records one abstention if no burst arrived since the last poll. After more than 5 abstentions in a row, the same criterion-dependent fail-safe applies. That covers a watch that can't measure at all.
8.4 The lost-link rule (v0.16)¶
- Lost link = no CS activity (any subevent result, aborted ones included) for 1.2 s. The 4 s supervision timeout is too slow for the 2 s requirement.
- getAway grace: motor and buzzer silenced immediately; LED ring stays red. A burst completed after the loss at ≤ 350 cm restarts the alarm at once. One grace per 120 s.
- 10 s without the link, or no noncompliant burst by then → AWAY is concluded directly. For stayNear that fails the commitment.
- Log lines:
link lost: output silenced…,link grace ended: measured noncompliant…,link lost 10 s: concluded AWAY.
8.5 Escalation over the same link¶
When the watch is unworn during a window, it writes a 33-byte packet — command (0x01 WATCH_REMOVED, 0x02 donned) + watch UUID + event UUID — to the anchor's watch-state characteristic with write-without-response (escalation 0x01 -> anchor over BLE). With no link, it falls back to UDP over WiFi (where built).
9. What the hardware actually does today (measured, BM20C ↔ BM20C)¶
| Quantity | Value | Source |
|---|---|---|
| Procedures attempted | ~8.5 /s (target 10) | 00:10 and 01:01 logs, 2026-09-13 |
| Procedures whose local subevents all aborted | 72–74 % | same |
| Good estimates | 2.2–2.4 /s at < 1 m | same |
| Time per 10-sample burst | median 3.6 s (2.0–6.1 s) | same |
| Link drops while stationary side by side | every ~25–60 s, supervision timeout 0x08, seen from both sides |
00:10, 01:01, 12:08 logs |
| Anchor ranging-data buffer | Failed to allocate buffer for procedure N for most procedures, in floods around each drop |
anchor RTT, 2026-09-12/13 |
ifft accuracy |
±0.20 m after calibration (DK ↔ V1, line of sight) | cs_tuning_plan.md |
| For comparison: DK ↔ BM20C walks | 7.1–8.7 good estimates/s; a burst every ~1.0 s | nrf_cs_test/walk1-3.csv |
How the pieces probably connect — hypotheses, not findings:
- CS owns up to 80 ms of each 100 ms interval, so the anchor has little airtime to stream a procedure's data before the next procedure starts. With a single ranging-data buffer, the next procedure can't be stored, so it's dropped. That would produce both the allocation-failure floods and the watch's counter-mismatch drops.
- If CS scheduling also starves the ordinary 20 ms link events, the link could miss 4 s of events and hit supervision timeout — the regular drops.
- Aborted subevents might come from the same contention, or from something else entirely (RF, timing, channel map).
None of these is proven. The nrf_cs_test limits rig (product vs max-performance profiles) exists to separate what the radio can do from what our parameters allow.
10. Known defects and open questions¶
| Issue | Status |
|---|---|
| Link drops every ~25–60 s beside the anchor | Open. Next target. |
| 72–74 % of procedures abort; ~4× fewer good samples than the DK walks | Open |
| Anchor's single ranging-data buffer overflows | Open; likely related to both of the above |
| Watch ranges any Impulse anchor, not the one the event names | Open (TODO(hw) in impulse_cs_measure) |
ifft slope unverified for a BM20C pair; offset is one point |
Open; needs a multi-station sweep |
Several K_FOREVER waits remain in the engine; one missed callback could hang ranging for the boot |
Open; the connect wait was fixed on 2026-09-13 |
| SoftDevice Controller assert 23/587 during CS teardown | Mitigated (procedures confirmed off before disconnect); link loss can't be sequenced |
| Anchor: which of two peripheral links receives notifications is first-come | Open (TODO in ble_anchor.c) |
| Anchor battery sense reads 66 mV with a cell fitted | Open (hardware or sense path) |
11. Where to look¶
| To understand… | Read |
|---|---|
| The whole watch-side flow | nrf_firmware/src/proximity/cs_backend_nrf.c — cs_engine_entry(), connected_cb, subevent_result_cb, ranging_data_cb, impulse_cs_on_estimate, impulse_cs_link_observe_nrf |
| Burst filtering and calibration | nrf_firmware/src/proximity/cs_fuse.c, cs_fuse.h |
| Verdict state machine | nrf_firmware/src/proximity/proximity.c |
| Per-burst dwell, lost-link rule, grace | nrf_firmware/src/enforcement/enforcement.c |
| Anchor reflector | nrf_firmware/src/proximity/cs_reflector_nrf.c, cs_reflector.conf |
| Anchor advertising | nrf_firmware/src/ble/ble_anchor.c (anchor_ad[], anchor_sd[]) |
| Build configuration | nrf_firmware/cs.conf (watch), cs_reflector.conf (anchor) |
| Behaviour as specified | vault/docs/specs/firmware_spec_v3_nrf.md §4 |
| Calibration history | vault/docs/hardware-notes/cs_tuning_plan.md, nrf_cs_test/RESULTS.md |
| Everything that went wrong | vault/docs/agent-notes.md |