Skip to content

Firmware Batch 1 (F6-F9): phase bias, slot telemetry, first-class RX arming, epoch-drift lock-out - #87

Merged
dorkmo merged 9 commits into
mainfrom
fw-batch1-f6-f9
Aug 1, 2026
Merged

Firmware Batch 1 (F6-F9): phase bias, slot telemetry, first-class RX arming, epoch-drift lock-out#87
dorkmo merged 9 commits into
mainfrom
fw-batch1-f6-f9

Conversation

@dorkmo

@dorkmo dorkmo commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

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 in FIRMWARE_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 check suite 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: tractor gpio163 dead, 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}); len excludes 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 frozen rx_frame_count on 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 write hdr; 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_profile made 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_VALID to 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_MS pinned 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 in sx1276_rx.c, which no bench target compiles — air-bench item.

Numbers

firmware size 38,266 → 38,554 B (+288 across all four; each within its map's estimate)
bench targets 25 → 27, all green (incl. 40 contract + 12,096 fuzz + 65,536-point gate property)
base-station suite 1079 passed / 2 skipped
wire changes one, additive (RX_FRAME_URC tail); schema_ver unchanged

Pre-existing issues touched but deliberately not fixed here: 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 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=0 and confirm arming, profile switching, and a FORBIDDEN 0x1D write (verifies F9).

🤖 Generated with Claude Code

dorkmo and others added 4 commits August 1, 2026 11:27
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>
Copilot AI review requested due to automatic review settings August 1, 2026 17:09
… checklist

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 36 to 40
* 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).
Comment on lines +53 to +55
/* 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. */
dorkmo and others added 3 commits August 1, 2026 14:56
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 is HOST_RX_FRAME_URC_PHASE_VALID (defined in mh_wire.h). This is misleading when interpreting last_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_stats is 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 preserving last_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>
@dorkmo
dorkmo merged commit 47cff1b into main Aug 1, 2026
10 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants