Firmware Batch 1 (F6-F9): phase bias, slot telemetry, first-class RX arming, epoch-drift lock-out - #87
Conversation
Both halves of the one-sided phase bias, implemented per the verified map in FIRMWARE_BATCH1_MAPS.md. TX HALF. The header's slot_offset_ms was sampled at ADMISSION (sx1276_tx.c:222-224 pre-edit) and committed to the FIFO before TX entry, so it understated the true key-up phase by every delay in between — standby transition, 1 ms PLL settle, LBT when enabled, two ToA estimates, the FIFO burst: ~2.3-3.6 ms in the daemons' LBT-off steady state, ~9-14 ms with LBT on. The RX anchor inherited the full bias, eating 25-40% of the 12 ms TX head-start (and an LBT-on window could consume it entirely). Now the FHSS branch stores the ADMISSION slot's boundary and the phase byte is computed at header-pack time from that stored boundary. Deliberately NOT by re-calling in_slot_ms() at pack time: that wraps modulo SLOT_MS if key-up slips past the boundary and would pair the old slot's (epoch, hop_idx) with the next slot's offset, corrupting the RX anchor by a full slot. Measured against the stored boundary, a straddling frame honestly reports 200-255, which the linear RX anchor math decodes correctly on old and new RX firmware alike. The BENCH/DTS branch and both fail-closed exits clear the boundary-valid flag. RX HALF. The anchor truncated toa_us/1000 (sx1276_rx.c:199-201 pre-edit), discarding up to 0.999 ms — 0.904 ms on every full 255 B DTS fragment — again one-sidedly late. New sx1276_fhss_clock_anchor_rx() rounds half-up. It lives in the HW-free clock TU precisely so the bench can pin it: sx1276_rx.c is linked by zero bench targets, sx1276_fhss_clock.c by two. Header spec (lora_pkt_hdr.h byte 3) updated: the offset now includes pre-key-up delays and may exceed 199 on a straddle. Wire layout unchanged; schema_ver stays 1; no python mirror parses this byte (firmware strips the header before delivery). TESTS. 9 new cases in check-fhss-clock (34 total): the half-up rule at the 99499/99500 boundary, the F7 number itself (99904 -> 100, anchor lands at 9887), the 169088 slot-fit case, a straddle offset of 215 decoding linearly, and a u32-wrap-spanning anchor. The old truncation was pinned by NOTHING — which is how it stayed test-invisible — so these cases exist to make it un-reintroducible. Also fixed the pass-count string, which had already drifted (said 24, file had 25 CHECKs). Phase B (residual FIFO/TX-entry model, ~1 ms) is deliberately NOT included: its constants are derived rather than measured, and the map gates it on F8's phase telemetry. Stale "200 us" comments in both TUs corrected to the 1000 that always ran (closes the RS-10.19 ambiguity in favour of the define). Full mingw32-make check green (all 25 targets), firmware 38266 B. On-air verification of the corrected anchor rides F8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slot alignment was unverifiable from either host: the L072 parses the 8-byte
hop header (epoch, hop_idx, slot_offset_ms) and then strips it before
delivery, so the fields existed only inside the radio MCU. Every alignment
question this project has asked — including whether F7's corrected anchor
actually works on air — had to be answered with tractor-side log archaeology.
WIRE CHANGE (additive). Every RX_FRAME_URC now carries an 8-byte tail after
payload[len]: {u8 phase_flags (bit0 = hop header valid), u8 profile_id,
u8 hop_idx, u8 slot_offset_ms, u32le epoch}. The len byte EXCLUDES the tail,
which is the whole additive contract — pre-F8 parsers slice by it and keep
working. Tail is zero-filled with flags=0 when no valid header exists; that
gate is load-bearing because unrouted builds never write frame->hdr and would
otherwise serialize uninitialized stack. Budget: max 271 <= 311 payload cap,
+8 B/frame ~ +87 us at 921600.
Layout lives in a new PURE TU (host_rx_wire.c) rather than inline in
host_cmd.c, mirroring the host_rfco precedent — which is what makes it
bench-pinnable: new check-rx-frame-urc target (26th), 27 checks, including
byte-identity of the legacy prefix, the zero-tail guard with a poisoned hdr,
len=255 -> 271 with no overflow, and epoch endianness. Wire constants live in
host_types.h (the sync-checked wire-contract home); host_rx_wire.h references
them.
THE ONE PARSER THAT BREAKS, fixed first: the H7's mh_runtime_health.c used
STRICT length equality — the "hosts ignore extra bytes" convention was false
for exactly this parser, and flashing F8 firmware would have frozen
rx_frame_count and made runtime health read a dead RX. Relaxed to '<'
(truncation still rejected), tail parsed into five new health fields when
present, zero-defaulted on legacy lengths. Both file copies patched
identically (the src/ copy is invisible to the sync tooling — kept in sync
manually as the map warns). 3 new vector cases incl. the partial-tail-is-
legacy rule; local CI-style build passes at 9 vectors.
Python: parse_rx_frame extended in BOTH copies (v2 canonical + v1 legacy) —
None on all five keys when firmware predates F8, so consumers can distinguish
"old firmware" from "no valid hop header" (zeros). 6 new unit tests including
v1/v2 parity and F7 straddle values (>199) surviving the byte.
mh_wire.h (both copies) mirrors the two new constants; SYNC_KEYS extended in
the same commit per the checker's both-files rule. mh_wire_constants.py
regenerated locally (it turns out to be gitignored, not checked in — the map
overstated that). NOTE: check_mh_wire_sync.py fails on HOST_WIRE_SCHEMA_VER
(2 vs 1) and HOST_STATS_PAYLOAD_LEN (132 vs 68) — verified PRE-EXISTING on
main by stash-and-run, part of why the Method-G CI job is red; not touched.
DTS frames carry zeroed hop fields until F1 lands the virtual grid — flags
still read valid, fields are honestly zero, exactly what the TX side sends.
ORDERING FOR THE BENCH: flash the H7 (relaxed parser) BEFORE the L072 (F8
emitter) on any setup running the Method-G runtime, and re-push
method_h_stage2_tx_probe_v2.py to both X8s or the daemons keep the old dict
shape and the telemetry stays invisible.
Firmware 38390 B (+124). mingw32-make check green at 26 targets; base-station
suite 1079 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… register surface
RS-4.12's opmode-sync — the fix that makes host RXCONT arming visible to the
firmware mode tracker — was reachable only via HOST_ALLOW_REG_WRITE_DIAG=1,
whose own comment says keep conservative in production. Setting it to 0:
1. deafened RX permanently: every daemon connect activates a profile, which
parks the modem in STANDBY, and with the raw write refused nothing could
ever re-arm it (the run-31 signature, rx_frames=1/300 s); and
2. poisoned profile switching: the re-arm inside _apply_profile raises on
ERR_PROTO, the two-phase machinery reads that as apply-failure and
REVERTS a switch that succeeded on the wire (map correction C2).
So production shipped with all 13 diagnostic registers open just to keep RX
alive — including 0x1D/0x1E, the airtime-invariant bypass.
Fix (Option B from the verified map): a pure gate, host_reg_gate.c. RegOpMode
writes with value in {0x80 SLEEP, 0x81 STANDBY, 0x85 RXCONT} — the ONLY three
values production daemons send, all LoRa-mode — are accepted unconditionally;
the RS-4.12 sync hook is untouched and now reachable in flag=0 builds. The
value gate is what makes this safe: raw 0x83 (TX) would key the PA around
LBT/airtime/dwell accounting, and FSK values (bit7 clear) stay diagnostic.
Zero wire change, zero Python/H7 change, works against every deployed daemon.
The flag itself is now #ifndef-wrapped (a bare -D override was a fatal macro
redefinition under -Werror) with the override documented as EXTRA_CFLAGS —
CFLAGS= would clobber the MCU flag set. DEFAULT DELIBERATELY STAYS 1 for one
bench soak: the T2 TCXO probe writes opmode 0x00/0x01 and the SPI-isolation
probe writes 0x83, all needing diag builds. Flip is a separate commit.
Known deficit vs a dedicated host op, recorded: the raw path has no W1-9
retry — a TCXO-wedged chip silently ignores the write. Mitigation is
structural (_ensure_rxcont re-reads opmode every idle/post-TX cycle and
retries continuously). The routed variant (0x85 -> sx1276_rx_arm() with retry
+ fault) is a separate, bench-A/B'd follow-up because it adds up to ~5 ms of
blocking retry to a path with bench-measured timing.
New 27th bench target check-reg-write-gate: the three production values pass
with diag off; 0x83/0x87/0x86 and the FSK values the probes really send are
refused; the 13-address legacy list is pinned verbatim; and an exhaustive
65536-point property proves diag-off admits EXACTLY the 3 (reg,val) pairs.
Firmware 38446 B (+56). Full check green at 27 targets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…esync defects
The verified map documented two coupled defects and the naive fix's own trap;
all three are addressed.
DEFECT A — permanent cycling desync. sx1276_rx_service() fed the scan state
machine FRAME_VALID unconditionally, even for REJECTED_EPOCH_DRIFT frames. In
LOCKED that starved the 2 s loss demotion (REANCHOR refreshes channel_entry_ms
on every rejected frame); and when demotion did fire, recovery was incomplete:
the demotion edge resets only the CLOCK, never the scheduler epoch, so the next
frame heard during SCANNING was again REJECTED_EPOCH_DRIFT yet still LOCKED the
node — re-locked, desynced, forever. Nothing ever moved the local epoch toward
the remote.
DEFECT B — healthy follower dragged. Any well-formed header within +/-1 epoch
SNAPPED the scheduler and re-anchored the clock regardless of local clock
health, so a skewed peer (or forged v1 header — no MIC until schema_ver=2)
could walk a healthy clock one snap at a time.
DEFECT A' — the naive fix's trap (map correction C1, caught on paper before
any flash). Any "reject when fresh" gate keyed on anchor recency alone is
defeated by the TX lazy anchor: a demotion resets the clock, the node's next
TX re-validates it ON THE SAME STALE GRID, and the node locks itself out of
ever adopting the peer. Duplex nodes desync permanently; a cold-booted node
that transmits before its first receive can never join.
DESIGN: a three-tier health gate in consider_remote(), health computed by the
CALLER from existing clock state plus one new bit:
s_grid_adopted — set on the first ALIGNED/SNAPPED, cleared at boot, on the
LOCKED->SCANNING demotion edge, and in scan_reset. While clear, health is
UNANCHORED even if the clock is TX-self-anchored: a self-anchored grid has
no authority to refuse a remote one. This flag is the entire closure of A'.
UNANCHORED: accept ANY epoch (recovery — fixes A's re-lock loop)
FRESH (valid + adopted + age <= 2000 ms): refuse all disagreement,
new REJECTED_LOCKED_OUT decision (fixes B)
STALE: legacy +/-1 drift rule, unchanged behaviour
FRESH_MS deliberately equals SX1276_RX_SCAN_LOCK_LOSS_MS — a clock is
authoritative exactly as long as link loss would not yet have demoted — and
the equality is pinned by a _Static_assert. Scan feed is now gated on
ALIGNED/SNAPPED so rejected frames run the demotion countdown instead of
starving it. TX additionally refreshes anchor RECENCY every FHSS transmission
(phase-exact by the floor identity, pinned in check-fhss-clock), so a busier
adopted node stays FRESH and cannot be dragged by an idler — but the refresh
cannot re-arm lock-out after a demotion, because only RX adoption sets the flag.
Payload delivery for rejected headers is unchanged (frames still go up).
Precedence NOT_INIT > BAD_HOP > ALIGNED > health, pinned by vectors. Decision
enum appended only (counters index by value); counter DIM 5 -> 6.
Recorded behavioural risks: both-adopted-fresh split-brain resolves via the
2 s demotion of whichever node stops hearing accepted frames (bounded
oscillation if simultaneous — flag for the FHSS bench); a genuine clock glitch
while FRESH now costs ~2 s + reacquisition instead of a silent drag; and the
UNANCHORED accept-any widens the cold-boot forge window until the schema_ver=2
MIC — stated in the header comment.
Tests: scheduler vectors gain the FRESH and UNANCHORED tiers (lock-out with
no mutation, recovery at delta 4900 with epoch adopted, BAD_HOP/NOT_INIT
precedence) with all 14 legacy calls pinned at STALE; rx_counters DIM=6 with
the new slot; clock test gains age_ms (incl. u32-wrap), the FRESH_MS pin, and
the TX-refresh bit-identity property. Known coverage gap, stated: the
s_grid_adopted flag itself lives in sx1276_rx.c, which no bench target
compiles — it is verifiable only on the FHSS air bench (extend run-16..22:
force a demotion, confirm re-adoption within one acquisition).
Firmware 38554 B (+108, inside the 160-280 estimate). Full check green at 27
targets. Batch 1 (F6-F9) is now code-complete; on-air verification of all
four rides the next flash session, F8's telemetry being the instrument that
verifies the other three.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… checklist Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Implements Firmware Batch 1 (F6–F9) host+firmware changes for the Murata L072 coprocessor and H7 host, addressing phase bias, RX-frame phase telemetry, production-safe RX arming via a first-class RegOpMode gate, and epoch-drift lock-out/recovery behavior. The work also adds bench/property tests and updates Python tooling/parsers to keep host-side consumers compatible with the additive wire change.
Changes:
- F6/F7: Add FHSS clock health tiers + adoption flag and fix timing/phase anchoring (TX admission-boundary capture; RX ToA µs→ms half-up rounding).
- F8: Extend RX_FRAME_URC with an additive 8-byte telemetry tail and update all known host parsers + add golden-vector tests for wire packing.
- F9: Decouple production RX arming from the diagnostic reg-write surface via a pure allowlist gate for RegOpMode writes, plus exhaustive property testing.
Reviewed changes
Copilot reviewed 30 out of 35 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| LifeTrac-v25/DESIGN-CONTROLLER/tools/check_mh_wire_sync.py | Adds F8 tail constants to the H7↔L072 constant drift check. |
| LifeTrac-v25/DESIGN-CONTROLLER/TODO.md | Documents flash/on-air acceptance sequencing for Batch 1. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/x8_lora_bootloader_helper/method_h_stage2_tx_probe.py | Extends RX_FRAME_URC parser to read optional F8 tail fields. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/x8_lora_bootloader_helper/method_h_stage2_tx_probe_v2.py | Same as above for the v2 (canonical) parser. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/tractor_h7/murata_host/mh_wire.h | Mirrors new tail constants for host wire compatibility. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/tractor_h7/murata_host/mh_runtime_health.h | Adds storage for new RX phase telemetry fields. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/tractor_h7/murata_host/mh_runtime_health.c | Relaxes RX_FRAME length check and parses optional telemetry tail. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/tractor_h7/bench/h7_host_proto/mh_runtime_health_vectors.c | Adds vectors validating legacy/extended/partial-tail behavior. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/radio/sx1276_tx.c | Fixes phase sampling by storing admission boundary and sampling at header pack time; refreshes FHSS anchor recency. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/radio/sx1276_rx.c | Adds grid-adoption health gate, anchors via new RX helper, and gates scan-feed on accepted headers. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/radio/sx1276_fhss.c | Extends consider_remote() with local clock health tiers and adds LOCKED_OUT decision. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/radio/sx1276_fhss_clock.c | Adds rx-anchor helper (rounded ToA) and clock age helper. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/Makefile | Adds new bench targets for RX_FRAME_URC packing and reg-write gate policy. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/include/sx1276_rx.h | Updates consider_remote counter dimension for new decision enum value. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/include/sx1276_fhss.h | Adds clock health enum, LOCKED_OUT snap decision, and updated API signature. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/include/sx1276_fhss_clock.h | Documents new F6 freshness horizon and adds RX anchor/age APIs. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/include/lora_pkt_hdr.h | Updates header docs to reflect F7 boundary-straddle semantics. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/include/host_types.h | Documents F8 tail and introduces tail constants for the L072 wire contract. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/include/host_rx_wire.h | New: pure RX_FRAME_URC payload packer interface + layout contract. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/include/host_reg_gate.h | New: pure reg-write acceptance policy interface for F9. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/host/host_rx_wire.c | New: pure RX_FRAME_URC serializer with hdr_valid gate and zero-tail guard. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/host/host_reg_gate.c | New: reg-write policy with unconditional allowlist for production opmode values. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/host/host_cmd.c | Uses new pure helpers for RX_FRAME packing and REG_WRITE gating. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/config.h | Makes HOST_ALLOW_REG_WRITE_DIAG overrideable and documents F9 implications. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/build/firmware.map | Updates the checked-in link map for size/address verification. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/bench/host_proto/rx_frame_urc_vectors.c | New: golden vectors pinning RX_FRAME_URC legacy prefix + tail bytes. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/bench/host_proto/rx_counters.c | Updates counter tests for the new snap decision enum maximum. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/bench/host_proto/reg_write_gate.c | New: exhaustive + pinned tests for reg-write acceptance policy. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/bench/host_proto/fhss_scheduler_vectors.c | Updates consider_remote() call signature and adds tiered-health test cases. |
| LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/bench/host_proto/fhss_clock_test.c | Adds tests for RX anchor rounding and TX anchor-recency refresh identity. |
| LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_rx_frame_urc_tail.py | New: Python tests pinning additive tail parsing + v1/v2 parser parity. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Single source of truth — host-side mirror at | ||
| * bench/host_proto/fhss_scheduler.py | ||
| * MUST stay bit-identical (golden vectors in bench/host_proto/ | ||
| * (F6 2026-07-30: the python mirror this comment used to promise -- | ||
| * bench/host_proto/fhss_scheduler.py -- never existed; the golden | ||
| * vectors are C-only, in bench/host_proto/fhss_scheduler_vectors.c.) (golden vectors in bench/host_proto/ | ||
| * cross-check both implementations). |
| /* F8 (2026-07-30): phase-telemetry tail from the extended | ||
| * RX_FRAME_URC. All zero for legacy-length frames or when | ||
| * last_rx_phase_flags bit0 (MH_RX_FRAME_PHASE_VALID) is clear. */ |
…evidence pointers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both L072s flashed with the PR #87 build (RC=0 both, AN3155 over ttymxc3 via run_flash_l072.sh) and booted. FHSS acceptance run: phase_telemetry: valid=42 invalid=0 pre_f8=0 last_epoch=14 last_hop=45 slot_off=[14..14] ... epoch 14 -> 18 across five ~10 s intervals F8 PASS: every delivered frame carries the phase tail with flags=1; the epoch advances exactly one per 10 s (50 slots x 200 ms) — the first host-visible proof the slot clock runs at its designed rate. rx_decode_err=0 across 660 frames; the additive wire change is invisible to legacy parsers as designed. F7 PASS: slot_off reads 14-18 ms under FHSS. The head-start is 12 ms and pre-F7 firmware (admission-sampled) would have reported ~12; the observed values are 12 + the 2.3-3.6 ms of pre-key-up delays F7 now honestly includes. Far inside the slot budget; no straddles at this duty. Also adds the phase_telemetry stats line to image_rx_daemon (the F8 consumer — without it the telemetry existed but nothing surfaced it), and a week-in-review section in TODO.md summarising the 07-24..08-01 campaign with evidence pointers. Still open on the checklist (recorded in the evidence file): the F6 demotion/re-adoption air test and the F9 flag=0 soak build. Passive check: steady lock, epoch monotonic, no re-scan churn — nothing attributable to the F6/F9 gates misbehaving. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F9 (flag=0 build, direct wire probe, f9_gate_probe.py now in the helper dir): both boards flashed with EXTRA_CFLAGS=-DHOST_ALLOW_REG_WRITE_DIAG=0. RXCONT and STANDBY arming ACK with the diagnostic surface closed; ModemConfig1 (0x1D) and raw TX (0x01<-0x83) refused ERR_PROTO. A full 360 s FHSS bench run then passed on the same build — daemons connected, profile activated, RXCONT armed, 1318 frames, rx_decode_err=0. The run-31 failure class is structurally closed. F6 (forced demotion + out-of-tolerance recovery): synth_pub frozen 30 s at ~T+90 of a 360 s FHSS run, silencing the tractor ~3 epochs. Telemetry: epochs 1..9 steady at 41-43 frames/interval; silence begins at epoch 10; delivery RESUMES at epoch 12 — a delta of 2 beyond the +/-1 drift tolerance, which pre-F6 firmware could never accept (it would re-LOCK desynced via the unconditional scan feed and deliver nothing forever). Recovery completed within one stats interval, then nineteen clean epochs. slot_off stayed [14..18] across demotion and recovery — F7's phase byte unmoved. Both boards re-flashed with the DEFAULT (flag=1) build afterwards (RC=0 both) so the bench diagnostics keep working; the F9 default flip stays a separate commit per the soak rule. With this, all four Batch 1 items (F6/F7/F8/F9) are verified on hardware. PR #87 acceptance is complete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 46 changed files in this pull request and generated no new comments.
Suppressed comments (3)
LifeTrac-v25/DESIGN-CONTROLLER/firmware/tractor_h7/murata_host/mh_runtime_health.h:55
- The comment references
MH_RX_FRAME_PHASE_VALID, but that macro doesn’t exist here; the actual wire flag constant isHOST_RX_FRAME_URC_PHASE_VALID(defined inmh_wire.h). This is misleading when interpretinglast_rx_phase_flags.
LifeTrac-v25/DESIGN-CONTROLLER/firmware/murata_l072/include/sx1276_fhss.h:40 - The header comment’s “Single source of truth” note is currently malformed (dangling parenthesis / duplicated fragment) and hard to read. It should be rewritten to clearly point to the actual golden-vector source file.
LifeTrac-v25/DESIGN-CONTROLLER/base_station/image_rx_daemon.py:1547 _phase_statsis updated in the RX thread and then replaced with a new dict in the stats thread. That replacement can drop increments that happen concurrently (RX thread keeps updating the old dict after the swap), which can make the acceptance telemetry line undercount in busy runs. Resetting counters in-place avoids this lost-update pattern while preservinglast_epoch/last_hop.
ps = getattr(self, "_phase_stats", None)
if ps is not None and (ps["valid"] or ps["invalid"] or ps["none"]):
LOG.info(
"phase_telemetry: valid=%d invalid=%d pre_f8=%d "
"last_epoch=%s last_hop=%s slot_off=[%s..%s]",
ps["valid"], ps["invalid"], ps["none"],
ps["last_epoch"], ps["last_hop"],
ps["off_min"], ps["off_max"])
self._phase_stats = {"none": 0, "valid": 0, "invalid": 0,
"last_epoch": ps["last_epoch"],
"last_hop": ps["last_hop"],
"off_min": None, "off_max": None}
…ng constant name
Both Copilot review findings were real:
1. sx1276_fhss.h — the F6 edit that retired the never-existed python-mirror
claim collided with surviving fragments of the old text, leaving a
duplicated parenthetical ("...vectors.c.) (golden vectors..."). Rewritten
as one clean statement: the C implementation is the single source of
truth, there is no python mirror, and the golden vectors live in
bench/host_proto/fhss_scheduler_vectors.c.
2. mh_runtime_health.h (both copies) — the F8 struct comment referenced
MH_RX_FRAME_PHASE_VALID, a symbol that does not exist; the flag is
HOST_RX_FRAME_URC_PHASE_VALID in mh_wire.h. Corrected and the defining
header named, so the constant is greppable.
Comments only; both builds re-verified (firmware 38554 B, 27-target check
green, H7 vectors 9/9) and the file-copy parity check still holds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Firmware Batch 1 (F6–F9) from
CONTROL_PLANE_DESIGN.md§10 — the four latent correctness bugs in the L072 radio coprocessor, implemented against the verified maps inFIRMWARE_BATCH1_MAPS.md(each map was independently re-verified file:line against the tree before any code was written; one verification catch, F6/C1, changed the design load-bearingly).Everything here is host-verified: the full
mingw32-make checksuite grew 25 → 27 targets, all green, and the base-station suite is at 1079 passed. On-air verification is deliberately deferred to the next flash session — F8's telemetry is itself the instrument that verifies the other three, and the flash loop is the expensive path (RS-5.2: tractorgpio163dead, 7–10 s SWD).F7 — one-sided phase bias (
6015f044)The header's phase byte was sampled at admission and committed to the FIFO before standby/PLL/LBT/FIFO delays (~2.3–3.6 ms understated, ~9–14 with LBT on), and the RX anchor truncated µs→ms (−0.904 ms on every full DTS fragment) — both late, together eating up to 40% of the 12 ms TX head-start. Now: TX stores the admission slot's boundary and samples at header-pack time (deliberately not re-calling
in_slot_ms(), which wraps on a straddle and would corrupt the anchor by a full slot); RX rounds half-up via a pure helper in the clock TU so the bench can pin it. The old truncation was pinned by nothing — that's how it stayed invisible. 9 new clock cases.F8 — phase telemetry in RX_FRAME_URC (
070717df)Additive 8-byte tail on every RX frame URC ({flags, profile, hop_idx, slot_offset, epoch});
lenexcludes it, so legacy parsers keep working. The one parser that breaks was fixed first: the H7's health parser used strict length equality and would have silently frozenrx_frame_counton flash — relaxed, tail-aware, both file copies, 3 new vectors. Pure packer TU + 26th bench target pinning byte identity of the legacy prefix and the zero-tail guard (unrouted builds never writehdr; serializing it ungated would leak uninitialized stack). Python parsers extended in both copies with None-vs-zeros semantics distinguishing old firmware from "no valid header". Flash ordering: H7 before L072 on any Method-G bench.F9 — production RX arming decoupled from the diagnostic register surface (
1c7711f4)RS-4.12's opmode-sync was reachable only via
HOST_ALLOW_REG_WRITE_DIAG=1. At 0, nothing could re-arm RX after any profile activation (run-31 signature) — and a refused re-arm inside_apply_profilemade the base daemon revert profile switches that succeeded on the wire. Production therefore shipped with all 13 diagnostic registers open, including the FCC airtime-invariant bypass (0x1D/0x1E). Now a pure gate accepts RegOpMode ∈ {0x80, 0x81, 0x85} unconditionally — the only three values production daemons send — and everything else (raw TX 0x83 that would key the PA around LBT/dwell accounting, FSK values, the other 12 registers) stays flagged. Zero wire change. 27th bench target with a 65,536-point exhaustive property: diag-off admits exactly 3 (reg,val) pairs. Flag default stays 1 for one soak; the flip is a separate commit.F6 — epoch-drift lock-out with grid adoption (
adc04a0f)Two coupled defects: rejected-epoch frames still fed
FRAME_VALIDto the scan SM (starving the 2 s demotion, and re-LOCKING a desynced node from SCANNING — permanent cycling desync, since nothing ever moved the local epoch toward the remote); and any ±1-epoch header snapped a healthy clock (drag/spoof surface). The naive "reject when fresh" fix has its own trap, caught in map verification before any flash: the TX lazy anchor re-validates the stale grid immediately after every demotion, locking duplex nodes out of recovery forever and blocking cold-boot join.Design: three-tier health gate (UNANCHORED accepts any epoch = recovery; FRESH refuses all disagreement = new
REJECTED_LOCKED_OUT; STALE = legacy ±1 rule), with health computed by the caller from clock state plus a grid-adoption flag — set on first accepted remote grid, cleared on demotion/reset — which is the entire closure of the trap: a TX-self-anchored grid carries no refusal authority.FRESH_MS == LOCK_LOSS_MSpinned by_Static_assert. TX refreshes anchor recency each transmission (bit-identity of the grid pinned in the clock test) so a busier node isn't dragged by an idler. Scan feed gated on ALIGNED/SNAPPED. Known gap, stated in the commit: the adoption flag lives insx1276_rx.c, which no bench target compiles — air-bench item.Numbers
Pre-existing issues touched but deliberately not fixed here:
check_mh_wire_sync.pyfails onHOST_WIRE_SCHEMA_VER(2 vs 1) andHOST_STATS_PAYLOAD_LEN(132 vs 68) — verified pre-existing on main by stash-and-run; part of the red Method-G CI job.On-air acceptance plan (next flash session): flash H7 then L072; FHSS two-board run must show every RX_FRAME_URC with flags=1 and epoch/hop advancing, slot_offset ≤ guard (verifies F7+F8); force a demotion and confirm re-adoption within one acquisition while both nodes transmit (verifies F6); build once with
EXTRA_CFLAGS=-DHOST_ALLOW_REG_WRITE_DIAG=0and confirm arming, profile switching, and a FORBIDDEN 0x1D write (verifies F9).🤖 Generated with Claude Code