Skip to content

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

  1. A getAway/stayNear window opens → the watch turns ranging on.
  2. 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.
  3. 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.
  4. 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.
  5. The watch collects 10 good ifft samples, takes the median, subtracts a calibration offset, and gets one true distance. That is one burst.
  6. 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.
  7. If the link goes silent, a lost-link rule takes over: output silenced within ~1.5 s (getAway), AWAY concluded after 10 s.
  8. 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

impulse_cs_set_ranging(g_enf.state == IMPULSE_STATE_ENFORCEMENT);
  • 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/length 0x02 0x15, the 16-byte anchor UUID, major 0x4A0F (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 a TODO(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.

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 complete log prints mode: Invalid. The mode passed in is BT_CONN_LE_CS_MAIN_MODE_2_SUB_MODE_1 and 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 to latest_local_steps unless 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

  1. bt_ras_rreq_rd_subevent_data_parse() walks both halves step by step, extracting phase/IQ per channel (PBR) and timing (RTT) into a cs_de_report_t. Tones are only used when both sides rate their tone quality HIGH.
  2. Tone quality gate: at least 15 channels must have usable IQ data, or the path is marked bad (TONE_QI_OK_TONE_COUNT_THRESHOLD).
  3. cs_de_calc() (Nordic's cs_de library, 512-point FFT, ~1 ms per procedure on thread BT 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
  1. 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…, and impulse_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.

  • 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.

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