t4as

Case study · nRF54L15 AP, nRF52840 tag · PAwR / PAST · 2026

One Packet, Then Silence: Debugging PAwR on nRF54L15

The access point reported success at every step and the tags still never answered. In May, correlated AP, tag and on-air evidence and a 20-build reproducer traced the fault to the receiving controller and a sharp timing boundary. In October I found the cause, a 32 kHz crystal running 175 ppm fast against a declared 50 ppm, and fixed it with one line of configuration.

  • nRF54L15
  • nRF52840
  • nRF5340
  • PAwR
  • PAST
  • Zephyr / NCS 2.8 + 3.2
  • SoftDevice Controller
  • BLE sniffing
  • Python
periodic events the tag received before giving up (May, run 181151Z)
1 of 26
AP 32 kHz clock error vs three sniffer radios, re-timed from 12 May captures (declared: 50 ppm)
+175 ppm
requests answered after a one-line clock fix (Oct, nRF54L15 as tag)
18 / 18
sync losses in 10.3 h, 21,006 periodic events received (fixed tag, Oct)
0

Result

I was moving the access point (AP) of an ESL-style sensor network from a working nRF5340 / Thingy:91 X gateway to an nRF54L15. Every step the AP could report on succeeded: pairing, GATT configuration, sync transfer, subevent data. The tags still never answered. Three days of testing in May (10–12 May 2026) moved the fault from "the ESL app doesn't respond" to the receiving controller, with a sharp interval boundary. In October I found what sits behind it:

The board's 32.768 kHz crystal runs about 175 ppm fast, and the firmware tells the Bluetooth controller it is accurate to 50 ppm. PAwR receive windows are sized from that declared accuracy, so the receiver catches the first event after sync and misses every one after it. Declaring a conservative accuracy, one line of configuration, fixes it.

  • Below the application. The failure is reported by the tag's controller (recv_cb with buf=NULL). The ESL parser never receives a payload.
  • Not the AP going quiet. The sniffer captured SyncInfo and packets on the periodic access address while the tag was failing.
  • Reproducible without product code. A fork of the stock Zephyr samples, with every PAwR parameter exposed as a build option, reproduces it. The unmodified samples work on the same boards.
  • A parameter boundary. With one subevent, responses stop between interval 302 and 303 (377.5 → 378.75 ms). At long intervals, subevent 1 answers and subevent 0 doesn't.
  • Measured two ways, confirmed a third (October). An on-chip meter (+174.17 ppm relative to the board's 32 MHz crystal), the May sniffer captures re-timed from the air (+167 to +182 ppm on a second unit, relative to three sniffer radios), and the RC oscillator in place of the crystal (157 of 157 events received).
  • Fixed from either end. A tag that declares its own error holds sync (21,006 events, no sync loss in 10.3 h), and so does a tag listening to an AP that declares its error (0 of 10 answers → 10 of 10).

Why this was hard to see

The tags are sensor nodes that sleep on a Periodic Advertising with Responses (PAwR) train. Each periodic event carries one or more subevents. The AP puts addressed commands into a subevent, and the addressed tag answers in a numbered response slot shortly after. Tags don't scan for the train. The AP connects to each tag, writes the ESL configuration over GATT and hands over the sync with PAST (Periodic Advertising Sync Transfer), and then the connection is closed.

AP, tag, sniffer and the three log streams collected by the host PC nRF54L15 AP Makerdiary nRF54L15 Connect Kit NCS v2.8.0, SoftDevice Controller ESL client, PAwR advertiser nRF52840 tag ItsyBitsy, production firmware PAwR receiver, ESL server diagnostics only in separate copies 1 · ACL: GATT config + PAST 2 · PAwR train: subevent data, SID 8 3 · response in the tag's slot (expected) Raccoon sniffer 7 × nRF52832 + Raspberry Pi 3 3 on ch 37–39, 4 on data channels Host PC: one correlated run = AP log + tag RTT + sniffer capture run_correlated_ap_sniffer.py · RF preflight gate before each run · marker counts per run UART console → ap_diag.log SSH → sniffer.jsonl J-Link RTT → tag_rtt.log
Figure 1. The setup, with three independent records per run. The AP log shows what the host stack accepted, the tag RTT shows what the receiving controller reported, and the sniffer shows what was actually on air.

None of the three sources is enough by itself. An API call that returns success on the AP says nothing about timing on air. Packets on air don't prove the tag accepted them, and a quiet tag log doesn't prove the AP stopped sending. Every conclusion below is checked against at least two of the three sources.

First 80 ms of a periodic event for the ESL profile and for the interval-288 two-subevent profile 01020304050607080 ms after the start of the periodic event (to scale) ESL 1408 · 1 sub subevent 0: AP data subevent 0 8 response slots from 10 ms, 0.375 ms apart 8 slots, 0.375 ms apart next event at 1760 ms 288 2 subevents subevent 0: AP data subevent 0 subevent 0 response slots: from 6.25 ms, 10 ms apart 5 slots, 10 ms apart subevent 1 at 48 × 1.25 ms = 60 ms subevent 1 (60 ms)
Figure 2. The first 80 ms of a periodic event for two of the profiles tested. Top: the original ESL profile (interval 1408 = 1.76 s, one subevent, 8 tight response slots). Bottom: the interval-288 profile later used in the product (360 ms, two subevents 60 ms apart, 5 wide slots). Units: interval and subevent spacing are in 1.25 ms steps, slot spacing in 0.125 ms steps.

One packet, then silence

On the AP everything returned success: the GATT writes (err=0x00), PAST (#PAST_TRANSFER:ok) and every call to bt_le_per_adv_set_subevent_data(). The tag got a sync callback with the right parameters and then received either exactly one periodic event or none.

At first the tag log just went quiet. Enabling CONFIG_BT_PER_ADV_SYNC_BUF_SIZE=64 on the tag makes Zephyr call recv_cb() with buf=NULL when the controller fails to receive a periodic event. With that change, the silence became an explicit controller report for every event (rssi=127 means no packet was received). One more trap: the AP registry reported synced=1 for the tag, but that flag is set when PAST is sent, not when a response arrives. It had nothing to do with the tag's real state.

Tag receives PAwR event 90, then reports RX_FAILED for events 91 to 115 while the AP keeps transmitting AP log Tag RTT Sniffer #PAST_TRANSFER:ok · subevent data accepted for every request (op=0x1f / 0x10, addr=0x01, rsp=1) real_pawr_response=False: no response in any slot event 90: received (sub=0) event 91: RX_FAILED, rssi=127 event 92: RX_FAILED, rssi=127 event 93: RX_FAILED, rssi=127 event 94: RX_FAILED, rssi=127 event 95: RX_FAILED, rssi=127 event 96: RX_FAILED, rssi=127 event 97: RX_FAILED, rssi=127 event 98: RX_FAILED, rssi=127 event 99: RX_FAILED, rssi=127 event 100: RX_FAILED, rssi=127 event 101: RX_FAILED, rssi=127 event 102: RX_FAILED, rssi=127 event 103: RX_FAILED, rssi=127 event 104: RX_FAILED, rssi=127 event 105: RX_FAILED, rssi=127 event 106: RX_FAILED, rssi=127 event 107: RX_FAILED, rssi=127 event 108: RX_FAILED, rssi=127 event 109: RX_FAILED, rssi=127 event 110: RX_FAILED, rssi=127 event 111: RX_FAILED, rssi=127 event 112: RX_FAILED, rssi=127 event 113: RX_FAILED, rssi=127 event 114: RX_FAILED, rssi=127 event 115: RX_FAILED, rssi=127 90 91 115 1 event received, then 25 × RX_FAILED (≈ 44 s at 1.76 s per event) → #PAWR_SILENT:45000 ms: sync dropped, tag advertises again during the run: CRC-good SID 8 extended advertising and SyncInfo from the AP after the run: 7 CRC-good packets on the SyncInfo access address 0xD9522E15 (channels 0, 13, 15, 18, 21, 32, 35)
Figure 3. Run 20260511_181151Z, one box per periodic event on the tag. Run 20260511_172925Z has the same shape (event 71 received, 72–96 failed, 6 CRC-good packets on its periodic access address 0x647D6EBE). The direct access-address capture was taken after each run with the same SyncInfo. It proves the periodic train was on air, but not that each event was on its predicted channel and time.
# tag RTT, run 20260511_181151Z (abridged)
#SYNC_CB:idx=0,sid=8,phy=1,int=1408,num=1,sub_int=11,rsd=8,rss=3,svc=0x1857
#PAWR_RX:122855 sub=0 ev=90
#PAWR_RX_FAIL:ev=91,…,rssi=127
…  ev=92 … ev=114, all rssi=127
#PAWR_RX_FAIL:ev=115,…,rssi=127
#PAWR_SILENT:45000 ms - recovering
#SYNC:0
#STATE:UNSYNC

Making the RF evidence trustworthy

The first question was whether the AP transmitted anything after PAST. Answering it turned into debugging the measurement first. These four steps changed what the captures could be trusted for.

  1. CRC-bad frames only look like evidence. The first correlated run (115809Z, 420 s, 73,751 records) seemed to contain SID 8 and SyncInfo. Every one of those frames had failed CRC. Filtering strictly to CRC-good records left no AP evidence at all, so the run was inconclusive rather than proof of a silent AP. From then on, only CRC-good records count.
  2. Validate against a known source. I wrote a small nRF54L15 signal generator (SID 8, 2M secondary, SyncInfo, PAwR data, service UUID 0x1857). It exposed a bug in the probe's parser. It masked the PDU length byte with the legacy 6-bit mask, but extended advertising PDUs use all 8 bits. A 71-byte packet (0x47) was parsed as 7 bytes (0x07), and SyncInfo was cut off with it. After the fix, the probe passed all five checks on the known source: 1,094 CRC-good target records, of which 26 carried SyncInfo.
  3. An AP-only capture found nothing, for a reason. With the tag out of the loop, the sniffer still saw no SID 8. The cause was that the AP stops its extended advertising once PAwR starts. Keeping it running gave 31 CRC-good primary packets but no secondary ones: at the production cadence (14080 × 0.625 ms = 8.8 s), the host-driven AuxPtr follow had too few chances. A diagnostic cadence of 100 ms gave 2,502 CRC-good target records, 57 of them SyncInfo.
  4. Gate every run. From then on, each correlated run started with a 90 s AP-only capture that had to show CRC-good SID 8 before the main run could start. Before each run, AP bonds, AP tag storage and the tag (chip erase) were all cleared. Clearing the bonds on both sides was not enough: the AP's separate tag storage still held the old ESL address and key, and pairing failed with BT_SECURITY_ERR_PIN_OR_KEY_MISSING.
# host_spi/pawr_probe.py: the length fix
def adv_payload_length(pdu):
    encoded = pdu[1]
    masked = encoded & 0x3F          # legacy PDUs: 6 bits
    if encoded != masked and len(pdu) >= 2 + encoded:
        return encoded              # extended: full byte fits
    return masked
# only CRC-good target packets may retune a radio
should_follow = crc_ok and secondary_workers and (
    args.follow_all_aux or bool(reasons))
if should_follow and host_phy is not None:
    worker = secondary_workers[follow_index % len(secondary_workers)]
    follow_index += 1
    worker.follow(aux["channel"], host_phy,
                  args.follow_hold_ms / 1000.0)

The second guard matters as much as the first. A CRC-bad AuxPtr sends a radio to the wrong channel at the wrong time, and the capture that follows looks plausible but is wrong.

Limits of the sniffer as used here: it proves CRC-good packets with channel, RSSI and decoded AuxPtr/SyncInfo fields. Timestamps are per radio and not calibrated across radios, and the direct access-address mode does not predict which channel each event should use. Details are in Sniffer method and limits.


Ruling things out, one variable per run

Each of these runs used the gated setup above, with one change at a time.

HypothesisTestResultRun
AP not transmitting after PASTAP-only preflight, then direct capture on the SyncInfo access addressRuled out140333Z, 172925Z
LE 2M secondary PHYAP forced to LE 1M (BT_LE_ADV_OPT_NO_2M)Ruled out150808Z
Delay from the work-queue hop when pushing subevent dataPush inline in the data-request callbackRuled out38/38 pushes OK, 0 responses
Fast diagnostic advertising disturbing the trainBack to the 8.8 s production cadenceRuled out181151Z
Tag application / ESL parserController RX_FAILED reports (buf=NULL)Ruled out172925Z
Channel selection after the first eventSingle-channel map via bt_le_set_chan_map()Untested184712Z: all 207 SyncInfo still showed ffffffff1f; the override had no effect

One earlier observation pointed the same way. In a previous attempt, with the nRF54L15 on the tag side and the old nRF5340 gateway as AP, archived logs show the same signature: one #PAWR_RX, then repeated receive failures and HCI 0x2084 status 0x3a. One archived run in that role did sustain responses, so this is not a blanket incompatibility.


A reproducer without the product

The unmodified Zephyr periodic_adv_rsp / periodic_sync_rsp samples on the same two boards and the same NCS v2.8.0 worked: 6 PAST handovers and 176 responses in 150 s. So the hardware and controller can do PAwR with PAST. I forked the samples into isolated diagnostics and changed one parameter group per run, moving them towards the ESL profile. The pass/fail signal is AP-side responses, because the tag board had no usable console in this setup.

To keep the runs comparable, the diagnostic advertiser has no runtime configuration. Every PAwR parameter is a compile-time default that a build can override, so each profile is its own build directory (build_pawr_interval_0303_one_subevent, …). Each firmware prints its full profile at boot, so every log records exactly what was tested:

#DIAG_ADV_PROFILE:sid=8,interval=303,num_subevents=1,
  subevent_interval=48,rsp_delay=5,rsp_spacing=80,
  rsp_slots=5,ext_adv_interval=160,phy=1M
# diagnostics/pawr_esl_profile_adv/CMakeLists.txt
foreach(diag_define
    DIAG_NUM_RSP_SLOTS
    DIAG_NUM_SUBEVENTS
    DIAG_PAWR_SID
    DIAG_EXT_ADV_INTERVAL
    DIAG_PAWR_INTERVAL
    DIAG_PAWR_SUBEVENT_INTERVAL
    DIAG_PAWR_RSP_SLOT_DELAY
    DIAG_PAWR_RSP_SLOT_SPACING
    DIAG_PAWR_FORCE_1M
)
  if(DEFINED ${diag_define})
    target_compile_definitions(app PRIVATE
        ${diag_define}=${${diag_define}})
  endif()
endforeach()
Profile (all SID 8, LE 1M unless noted)IntervalSubeventsPASTData setsResponsesResult
ESL timing (slots 8 × 0.375 ms)1408181031Fail
Wide slots (5 × 10 ms)140815850Fail
Wide slots, one PAST, sync kept140811860Fail
Wide slots, LE 2M140815690Fail
Wide slots, stock interval25514376270Pass
Wide slots, stock subevent count140851517012Pass
Two subevents, alternating 0/11408210695Sub 1 only
Two subevents, subevent 0 only1408210680Fail
Two subevents, subevent 1 only140828688Pass

"Data sets" are successful bt_le_per_adv_set_subevent_data() calls; runs differ in length, so compare pass/fail rather than raw counts.

What the matrix rules out

  • The production ESL application: the fork fails with the ESL timing.
  • Long interval alone (1408 with 5 subevents responds) and one subevent alone (255 with 1 subevent responds). The failure needs both.
  • Tight response slots, the PHY, and repeated PAST handovers (one PAST with the link held open fails the same way).

What it points at

  • At interval 1408 with two subevents, subevent 1 answered every time and subevent 0 never did, whether alternated or used alone.
  • Subevent 0 is not broken in general: every passing one-subevent run below uses subevent 0. The index matters only together with the interval.

Interval sweep, one subevent, wide slots

AP-side responses per run against periodic interval, one subevent, wide response slots 0 3 6 9 responses interval 288 (360 ms): 9 responses, 223 subevent-data sets, 5 PAST cycles 9 288 360 ms interval 296 (370 ms): 9 responses, 268 subevent-data sets, 5 PAST cycles 9 296 370 ms interval 300 (375 ms): 4 responses, 263 subevent-data sets, 5 PAST cycles 4 300 375 ms interval 302 (377.5 ms): 5 responses, 262 subevent-data sets, 5 PAST cycles 5 302 377.5 ms interval 303 (378.75 ms): 0 responses, 201 subevent-data sets, 4 PAST cycles 0 303 378.75 ms interval 303 (378.75 ms): 0 responses, 262 subevent-data sets, 2 PAST cycles 0 303 retry interval 304 (380 ms): 0 responses, 261 subevent-data sets, 5 PAST cycles 0 304 380 ms interval 320 (400 ms): 0 responses, 193 subevent-data sets, 5 PAST cycles 0 320 400 ms interval 384 (480 ms): 0 responses, 206 subevent-data sets, 5 PAST cycles 0 384 480 ms interval 512 (640 ms): 0 responses, 155 subevent-data sets, 5 PAST cycles 0 512 640 ms interval 768 (960 ms): 0 responses, 104 subevent-data sets, 5 PAST cycles 0 768 960 ms interval 1408 (1760 ms): 0 responses, 85 subevent-data sets, 5 PAST cycles 0 1408 1760 ms no responses from 303 up (378.75 ms) responds up to 302 periodic interval (units of 1.25 ms) · one bar per run
Figure 4. AP-side responses per run. Intervals 288–302 respond; 303 (twice) and everything above gives zero, although the AP kept setting subevent data (85–262 successful sets per run, hover for details). The step between 377.5 ms and 378.75 ms is abrupt, not a gradual decline. Each bar is one run.

Back in the product at interval 288

The sweep gave a working region. Interval 288 (360 ms) is below the cut-off, with margin from the weaker 300/302 runs. Following the subevent result, I moved the first tag to group/subevent 1. With that profile, the full ESL product got its first real responses through the nRF54L15 AP: #PAWR_RSP, #NUS_STATUS and #NUS_SENSORS from ESL 0x0101 on subevent 1. With one subevent at the same interval, the product got none.

A clean repeat of that run with the tag's RTT attached got no responses at all, and the log shows why. The controller delivers subevent 0 first. The tag rejects it as the wrong group and asks to switch to subevent 1, and the controller refuses:

# tag RTT, clean group-1 repeat (logs/deep_esl_group1_acl/20260512_rtt01)
#SYNC_CB:idx=0,sid=8,phy=1,int=288,num=2,sub_int=48,rsd=5,rss=80,svc=0x1857
#PAWR_SUB:sync_cb=0
#PAWR_RX:378274 sub=0 ev=1431
#PAWR_GROUP_MISMATCH:ev=1431,got=0x00,want=0x01
opcode 0x2084 status 0x3a          LE Set Periodic Advertising Sync Subevent → Controller Busy
#PAWR_SUB:retry1 err=-5 sched=360 force=0
#PAWR_RX_FAIL:sub=1,rssi=127,state=3   … repeated until #PAWR_SILENT:45000 ms

I then tested two changes in isolated copies. In the first, the tag requested subevent 1 directly in sync_cb; the call returned 0, but the controller still delivered subevent 0 first. In the second, the AP re-sent the response request up to 64 times (#PAWR_RETRY:sub=1,left=63…). Neither changed the outcome: the tag never received subevent 1. That points below the application again, at how the controller picks up a subevent after PAST.


October: it was the clock

I came back to it in October with a new bench: an nRF5340 Audio DK as the access point (the proven gateway configuration, with only the host link changed) and a second nRF54L15 Connect Kit as the tag. The nRF54L15 showed the May signature from the receiving side: one good event after sync, then buf=NULL on every event. So the question changed from "which controller is wrong" to "what does this board do differently".

  1. Remove the application and the handover. A passive probe that syncs straight from a scan, with no connection and no PAST, still got exactly one good subevent per sync: 15 syncs, 15 good events, 60 failures in 12 minutes. Getting there had its own trap: the controller only builds in PAwR receive support when both PAST features are enabled. Without them, Kconfig prints a warning, the sync comes up as plain periodic advertising, and every subevent request fails with -EIO.
  2. Same code, other chip. The identical probe source on an nRF52840, listening to the same AP at the same time: 137 of 137.
  3. One variable on the nRF54L15. Low-frequency clock from the RC oscillator instead of the crystal: 157 of 157. Crystal kept, but its accuracy declared as 500 ppm instead of 50: 181 of 182.
  4. Put a number on it. I wrote a small meter that times the kernel clock, which the 32 kHz clock keeps through sleep, against a timer on the 32 MHz crystal, in 30-second windows with the CPU asleep. The 32 kHz crystal reads +174.17 ppm relative to the board's 32 MHz crystal. The reading follows the internal load setting (17 pF: +148.70 ppm, the 18 pF maximum: +126.03 ppm), so it is the 32 kHz crystal that is off; even the maximum setting leaves it far outside 50 ppm. The 32 MHz reference itself isn't calibrated, which is why the next step matters.
  5. Read the answer out of May's data. Periodic packets have no random delay, so they sit on an exact grid, and the sniffer stamps each one with a 1 MHz timer on its own crystal. Fitting time against grid index in the May captures gives the May AP's clock: +167 to +182 ppm across 12 runs and 3 sniffer radios, a second unit with the same fault. That is relative to the sniffers' own crystals, but three independent ones agreeing within about 8 ppm leaves no room for a 170 ppm error on their side. The per-radio timestamps that May's notes listed as a limit turned out to be all this needed.
# diagnostics/lfclk_meter/src/main.c
static void read_pair(uint32_t *grtc, uint32_t *t)
{
	unsigned int key = irq_lock();

	counter_get_value(tmr, t);   /* HFXO, 16 MHz */
	*grtc = k_cycle_get_32();    /* kept by the LF clock */
	irq_unlock(key);
}
…
k_sleep(K_SECONDS(WINDOW_S));
read_pair(&g1, &t1);
double ppm = (((double)(uint32_t)(g1 - g0) /
		sys_clock_hw_cycles_per_sec()) / (dt / f) - 1.0) * 1e6;
# board file load (15.5 pF), then 17 pF
#CLK n=10 win=30s grtc_d=30007057 tmr_d=480029288 err_ppm=174.21
#CLK n=10 win=30s grtc_d=30007056 tmr_d=480041508 err_ppm=148.71
# probe, 12 min, crystal declared 50 ppm
#RAW total=75 data=15 null=60      15 syncs
# same board, accuracy declared 500 ppm
#RAW total=182 data=181 null=1     1 sync

Why it depends on the pair

A receiver widens its window by the declared accuracy of both ends: 50 + 50 ppm allows about 180 µs of drift over a 1.76 s interval. What it actually has to absorb is the real drift between the two crystals. I measured every board on the bench (Audio DK +39 ppm, nRF52840 Pro Micro +60, Connect Kit +174) and tested each pairing. The link breaks between about 110 and 135 ppm of relative drift. An unfixed nRF54L15 AP even works with the Pro Micro, because that tag's crystal runs fast too. A drift that is constant from event to event against a fixed margin also gives a step, not a slope, which fits May's abrupt 302/303 cut-off.

Relative clock drift between AP and receiver for each tested pairing, with the 100 ppm declared budget 050100150200250300 relative drift between AP and receiver, ppm (to scale) declared budget 50 + 50 ppm Audio DK AP (+39) to nRF52840 Pro Micro (+60): about 21 ppm, works DK → nRF5284021 · works Audio DK AP to nRF54L15 on the RC oscillator: about 40 ppm, 157 of 157 DK → nRF54L15, RC clock≈40 · works Audio DK AP to nRF54L15 at 17 pF: about 110 ppm, 1 sync loss in 6 minutes DK → nRF54L15, 17 pF≈110 · marginal nRF54L15 AP (+174) to Pro Micro (+60): about 114 ppm, 17 of 18 answered nRF54L15 → nRF52840≈114 · works Audio DK AP to nRF54L15 with the board file's 15.5 pF: about 135 ppm, one event per sync DK → nRF54L15, 15.5 pF≈135 · fails nRF54L15 AP pushed to +357 ppm (10 pF) to Pro Micro: about 297 ppm, never syncs nRF54L15 (10 pF) → nRF52840fails · 0 of 10 Same pair, AP declares 500 ppm: the tag widens its windows, 10 of 10 answered same, AP declares 500 ppmworks · 10 of 10
Figure 5. Relative drift for each pairing tested in October, from the per-board meter readings (each relative to its own 32 MHz crystal, not calibrated). Both ends declared 50 ppm except in the last row. The break falls between about 110 and 135 ppm. The last two rows are the same boards: only the AP's declared accuracy changed. Hover for details.

With CONFIG_CLOCK_CONTROL_NRF_K32SRC_500PPM=y, a deliberately conservative class (+174 ppm falls in the 151–250 ppm class, but spread over units, temperature and ageing isn't characterised yet), the nRF54L15 runs the production ESL tag service on the same gateway as the nRF52840 tags: 18 of 18 status requests answered in 2.0–3.5 s, and over a 10.3-hour soak one sync, 21,006 events received, 97 failed (0.46 %), no sync loss. The access point build for this board carries the same line.


Where it goes next

The cause is settled. What remains is cost and coverage. Two May observations are not explained by the clock alone: why five subevents rescued interval 1408, and why subevent 1 answered when subevent 0 didn't.

What the fix costs

Wider receive windows cost current. Compare 500 ppm, 250 ppm and the RC oscillator with a power analyser and a long soak, then pick one for the sensor and AP builds.

Check the meter from the air

A 20 ms periodic beacon on each board lets the sniffer time it directly, so the on-chip meter and the air agree or don't on the same unit. A third Connect Kit gets measured too.

Measure every board

Every board on the bench drifted 40–175 ppm against a 50 ppm promise. The next board, an nRF54L05 sensor node, gets a clock acceptance test at bring-up and a crystal chosen for the load the chip can provide.

How the work was run: the known-good gateway was never modified, tag experiments lived in separate copies of the source, and boards were restored from saved images afterwards. Each run has its raw logs, and every number here has a log path in the notes.