Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 58 additions & 23 deletions LifeTrac-v25/DESIGN-CONTROLLER/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Tasks are organized by phase. Hardware purchases come first because lead times d

One paragraph per finding, newest context first; every claim carries evidence
in `bench-evidence/` or a commit. PR #86 (merged, `ffae3e51`) holds the bench
campaign; PR #87 (open) holds firmware Batch 1.
campaign; PR #87 (merged, `47cff1b7`) holds firmware Batch 1.

**Link measurements that changed the architecture.**
- **The ack was destroying the command channel**: suppressing the tractor echo
Expand Down Expand Up @@ -168,9 +168,11 @@ campaign; PR #87 (open) holds firmware Batch 1.
power change (allowance 17, configured 14). Residual: RS-10.18.
- **FHSS per-link hop seed** (CFG 0x17/0x18; was hardcoded (0,0,0) — every
radio hopped identically). `LIFETRAC_FHSS_LINK_ID` is LINK-scoped.
- **Batch 1 code-complete on PR #87** (F7 phase bias, F8 RX_FRAME_URC phase
tail, F9 opmode gate, F6 epoch-drift lock-out with grid adoption). 27 bench
targets green; NOT yet flashed — acceptance checklist below.
- **Batch 1 merged on PR #87 (`47cff1b7`) and ACCEPTED ON AIR 2026-07-30**
(F7 phase bias, F8 RX_FRAME_URC phase tail, F9 opmode gate, F6 epoch-drift
lock-out with grid adoption). All four verified on the two-board bench —
see `bench-evidence/FW_BATCH1_acceptance_2026-07-30/RESULTS.md` and the
checked-off acceptance list below.

**Instruments and infrastructure.**
- Loss attribution (`lost_frag_idx`, `air_gap_by_class`) with out-of-order
Expand Down Expand Up @@ -1274,7 +1276,16 @@ No-regrets work, correct under every surviving architecture (do first):
decided by the 53 test files that mutate `sys.path`. It is an
order-dependent failure, not a logic defect — do not "fix" the assertion.

- [ ] **RS-5.8 The L072 flash size budget has never actually been enforced.**
- [x] **RS-5.8 CLOSED 2026-08-01 (branch `post-batch1-cleanup`).** Decision:
under the unified layout the enforceable hazard is the app load image
growing into the CFG region, so the budget is **[MM_APP_BASE, MM_CFG_BASE)
= 184 KB** — `MM_APP_SIZE` (192 K nominal) is not the guard. The checker now
evaluates exactly that (`app_size = MM_CFG_BASE - MM_APP_BASE`), and the
Makefile banner was reconciled to the same wording ("APP = 184 KB (unified;
boot merged, RS-5.8)"), one source of truth. Passes locally at 23,036 B
load-image / 184 KB. This should turn the months-red "L072 cross-compile"
CI job green for the first time. Original item follows.
- [ ] **RS-5.8 (original) The L072 flash size budget has never actually been enforced.**
`tools/check_size_budget.py` (added `e599269b`, 2026-05-05) is run by the
"L072 cross-compile" CI job after `arm-none-eabi-size`, and that job has been
**red for months** — so the guard against overflowing the 192 KB part has
Expand Down Expand Up @@ -1907,24 +1918,48 @@ for whoever tries to use one; the decision for each is *wire it or delete it*.
and it is the companion signal to the RS-10.2 coding-rate trigger. Small fix,
do it before the first field run.

### Firmware Batch 1 status (2026-07-30): CODE-COMPLETE, awaiting flash

F6/F7/F8/F9 are implemented on PR #87 (branch `fw-batch1-f6-f9`) against the
verified maps in [`FIRMWARE_BATCH1_MAPS.md`](FIRMWARE_BATCH1_MAPS.md), host
suites green (27 bench targets, 1079 python). NOT yet flashed. On-air
acceptance, in order, next flash session:
1. Flash H7 FIRST (relaxed RX_FRAME parser), then L072 — reversed order
freezes the H7's rx_frame_count under Method-G.
2. Re-push `method_h_stage2_tx_probe_v2.py` to both X8s (stale deployed copy
keeps the old parse_rx_frame dict shape and hides the F8 telemetry).
3. FHSS two-board run: every RX_FRAME_URC flags=1, epoch/hop advancing,
slot_offset <= guard (verifies F7+F8).
4. Force a demotion; confirm re-adoption within one acquisition while both
nodes transmit (verifies F6 — the s_grid_adopted flag is air-testable
only).
5. One build with `EXTRA_CFLAGS=-DHOST_ALLOW_REG_WRITE_DIAG=0`: arming works,
profile switch completes without the revert path, 0x1D write FORBIDDEN
(verifies F9). The default flip is a separate commit after this soak.
### Firmware Batch 1 status (2026-07-30): MERGED + ACCEPTED ON AIR

F6/F7/F8/F9 merged on PR #87 (`47cff1b7`) against the verified maps in
[`FIRMWARE_BATCH1_MAPS.md`](FIRMWARE_BATCH1_MAPS.md), host suites green
(27 bench targets, 1079 python). Flash session ran 2026-07-30; all five
acceptance steps passed — full record in
`bench-evidence/FW_BATCH1_acceptance_2026-07-30/RESULTS.md`:
1. [x] Flash H7 FIRST (relaxed RX_FRAME parser), then L072 — done in that
order; Method-G rx_frame_count live throughout.
2. [x] Re-pushed `method_h_stage2_tx_probe_v2.py` to both X8s.
3. [x] FHSS two-board run: phase_telemetry valid=all, epoch exactly 1/10 s,
slot_off 14–18 ms (= 12 ms designed head-start + honest delays)
(verifies F7+F8).
4. [x] Forced 30 s silence: demotion, then resume at Δepoch=2 within one
acquisition — impossible pre-F6 (verifies F6).
5. [x] flag=0 build: arming + profile switch clean, 0x1D/0x83 write
FORBIDDEN via committed `f9_gate_probe.py`, 360 s soak (verifies F9).
Default flipped to 0 on `post-batch1-cleanup` as the follow-up commit;
bench builds now need `EXTRA_CFLAGS="-DHOST_ALLOW_REG_WRITE_DIAG=1"`.

### Post-Batch-1 cleanup (2026-08-01, branch `post-batch1-cleanup`)

- [x] **RS-5.8 budget decision + fix** — see the RS-5.8 closure above.
- [x] **F9 default flip** — `HOST_ALLOW_REG_WRITE_DIAG` now defaults 0 in
`config.h` after the passed soak; boards currently run the flag=1 bench
build (diag surface needed for bench work).
- [x] **F10 — `0x6C` CMD_OP_TILE_STALE end-to-end.** The measured-harmful
reassembly-timeout keyframe self-heal (+1.88 pts loss, −16% frames, +31%
timeouts) is now default-OFF (`LIFETRAC_KF_ON_REASM_TIMEOUT=0`); its
legitimate job is covered by the receiver-driven stale-tile report:
web_ui scans `Canvas.arrived_ms` every 3 s for tiles >20 s stale (horizon
must exceed the encoder sweep rotation — a static-scene receiver cannot
tell "static" from "lost" except via the sweep's periodic re-ship) and
publishes a u16le base_seq + tile bitmap (14 B at 12×8 ≈ 15.4 ms on air,
84× cheaper than the keyframe it replaces); image_rx_daemon radiates one
copy per report (level-triggered, no retry); the tractor folds marks into
the existing age-escalation by back-dating `tile_last_seq`, so repairs
ride the next scheduled frame at zero extra image airtime. Keyframe
requests remain for cold start / base_seq mismatch / mode change only.
10 new tests; suite 1089/2446 subtests green. **Not yet verified on air**
— needs a bench run with deliberate fragment loss to watch a marked tile
re-ship (queue for the next flash/bench session).

### RS-11 — Next-session sequencing, and the one instrument that gates it (added 2026-07-29)

Expand Down
37 changes: 35 additions & 2 deletions LifeTrac-v25/DESIGN-CONTROLLER/base_station/image_rx_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
CMD_OP_RADIO_PROFILE_CONF,
CMD_OP_ENCODE_MODE_ACK,
CMD_OP_PROBE,
CMD_OP_TILE_STALE,
CMD_OP_PROBE_ECHO,
)
from image_pipeline.frame_format import encode_tile_delta_frame # noqa: E402
Expand Down Expand Up @@ -154,6 +155,8 @@ def _env_float(name: str, default: float, lo: "float | None" = None,
# does not have to change.
MQTT_TOPIC_OUT = "lifetrac/v25/video/tile_delta"
KEYFRAME_REQ_TOPIC = "lifetrac/v25/cmd/req_keyframe" # camera_service listens
# F10: raw CMD_OP_TILE_STALE args from web_ui, radiated verbatim.
TILE_STALE_TOPIC = "lifetrac/v25/cmd/tile_stale"
# 2026-07-24: rolling RX-side link-speed sample for the web UI's image
# panel (web_ui forwards it into /ws/state as snapshot.link_stats).
LINK_STATS_TOPIC = "lifetrac/v25/video/link_stats"
Expand Down Expand Up @@ -184,6 +187,15 @@ def _env_float(name: str, default: float, lo: "float | None" = None,
# sparse cadence keeps the base's TX duty (and the run-33 bidirectional
# UART-stress window) tiny. The reassembler self-heals regardless.
KEYFRAME_CMD_MIN_GAP_S = 10.0
# F10 (2026-08-01): reassembly-timeout keyframe requests were MEASURED net
# harmful (n=2/side: +1.88 pts fragment loss, -16% frames delivered, +31%
# timeouts — they amplify the condition they fire on) and their legitimate
# job (canvas staleness) is now covered by the 0x6C stale-tile report,
# which is 84x cheaper and rides the next scheduled frame. Default OFF;
# the keyframe request remains for cold start / base_seq mismatch / mode
# change, which fire through web_ui's Canvas.apply path, not this one.
KF_ON_REASM_TIMEOUT = os.environ.get(
"LIFETRAC_KF_ON_REASM_TIMEOUT", "0") == "1"

# codec-id -> short name for link_stats (mirrors frame_format CODEC_*).
_CODEC_NAMES = {0: "webp", 1: "mono_g4", 2: "btc4_tile", 3: "btc4_frame",
Expand Down Expand Up @@ -1033,6 +1045,22 @@ def _subscribe_control(self, client) -> None:
ENCODE_MODE_OVERRIDE_TOPIC, self._on_encode_mode_msg)
client.message_callback_add(
KEYFRAME_REQ_TOPIC, self._on_req_keyframe_msg)
client.message_callback_add(
TILE_STALE_TOPIC, self._on_tile_stale_msg)

def _on_tile_stale_msg(self, _client, _userdata, msg) -> None:
# F10: level-triggered advisory — radiate as a ONE-SHOT single
# copy. A lost report is superseded by the next periodic one, so
# no pending/retry machinery and no extra copies. Body arrives
# pre-packed from web_ui (u16le base_seq + bitmap).
body = bytes(msg.payload or b"")
if len(body) < 3 or len(body) > 200:
return
try:
self._ctrl_out.put_nowait(
pack_command_frame(CMD_OP_TILE_STALE, body))
except queue.Full:
pass
Comment on lines +1051 to +1063

def _rx_worker(self) -> None:
try:
Expand Down Expand Up @@ -1066,7 +1094,8 @@ def _rx_worker(self) -> None:
with self._lock:
self.stats.reassembler_timeouts += (cur_timeout - last_timeouts)
last_timeouts = cur_timeout
self._kf_req.poke(f"reassembly timeout #{cur_timeout}")
if KF_ON_REASM_TIMEOUT:
self._kf_req.poke(f"reassembly timeout #{cur_timeout}")
self._drain_ctrl_idle(link)
self._maybe_switch_profile(link)
# Defensive: heal a silently dropped RXCONT (throttled).
Expand Down Expand Up @@ -1216,7 +1245,9 @@ def _rx_worker(self) -> None:
if cur_timeout != last_timeouts:
self.stats.reassembler_timeouts += (cur_timeout - last_timeouts)
last_timeouts = cur_timeout
self._kf_req.poke(f"reassembly timeout #{cur_timeout}")
if KF_ON_REASM_TIMEOUT:
self._kf_req.poke(
f"reassembly timeout #{cur_timeout}")

if completed is not None:
# RS-1.x: a COMPLETED frame means that was the train's
Expand Down Expand Up @@ -1702,6 +1733,7 @@ def _on_connect(_c, _u, _f, rc):
self._client.subscribe(RADIO_PROFILE_TOPIC, qos=1)
self._client.subscribe(ENCODE_MODE_OVERRIDE_TOPIC, qos=1)
self._client.subscribe(KEYFRAME_REQ_TOPIC, qos=0)
self._client.subscribe(TILE_STALE_TOPIC, qos=0)
else:
LOG.error("MQTT connect rc=%s", rc)
self._client.on_connect = _on_connect
Expand Down Expand Up @@ -1729,6 +1761,7 @@ def _ctrl_on_connect(_c, _u, _f, rc):
self._ctrl_client.subscribe(
ENCODE_MODE_OVERRIDE_TOPIC, qos=1)
self._ctrl_client.subscribe(KEYFRAME_REQ_TOPIC, qos=0)
self._ctrl_client.subscribe(TILE_STALE_TOPIC, qos=0)
self._ctrl_client.on_connect = _ctrl_on_connect
self._ctrl_client.connect(self.ctrl_mqtt_host,
self.mqtt_port, keepalive=30)
Expand Down
40 changes: 39 additions & 1 deletion LifeTrac-v25/DESIGN-CONTROLLER/base_station/lora_proto.py
Original file line number Diff line number Diff line change
Expand Up @@ -981,11 +981,23 @@ def add_parity_fragments(fragments: list[bytes], frag_seq: int,
# Free by symbol quantization: 9..12 B payload all cost 10.304 ms at
# BW500, so the 2 B ref costs nothing over a bare skip frame.
CMD_OP_CTRL_DITTO = 0x6B # args: u16le ref_seq
# F10 (2026-08-01, from the measured keyframe-self-heal harm — RESULTS
# 2026-07-29 §8): receiver-driven STALE-TILE REPORT, base -> tractor.
# args: u16le base_seq_ref + u8 bitmap[(n_tiles+7)//8], bit k = tile k
# has not refreshed on the base canvas within the staleness horizon.
# ADVISORY dirty marks, not a retransmission command: the tractor folds
# marked tiles into its existing age-escalation, so the repair rides
# the next scheduled image frame at zero extra image airtime. Level-
# triggered — a lost report is superseded by the next one. 14 B body at
# the 12x8 grid = 15.4 ms on air, vs 1298.8 ms for the keyframe the
# old self-heal fired (84x cheaper), and the keyframe request remains
# only for cold start / base_seq mismatch / mode change.
CMD_OP_TILE_STALE = 0x6C # args: u16le base_seq + stale bitmap
_CMD_OPS = frozenset({CMD_OP_REQ_KEYFRAME, CMD_OP_ENCODE_MODE,
CMD_OP_RADIO_PROFILE, CMD_OP_RADIO_PROFILE_ACK,
CMD_OP_RADIO_PROFILE_CONF, CMD_OP_ENCODE_MODE_ACK,
CMD_OP_PROBE, CMD_OP_PROBE_ECHO,
CMD_OP_CTRL_DITTO})
CMD_OP_CTRL_DITTO, CMD_OP_TILE_STALE})
COMMAND_FRAME_MAX_ARGS = 200


Expand Down Expand Up @@ -1018,6 +1030,32 @@ def parse_command_frame(body: bytes) -> tuple[int, bytes] | None:
# the whole 16 B frame. See CMD_OP_CTRL_DITTO above for why ref_seq is
# safety-critical rather than an optimization.

def pack_tile_stale(base_seq: int, stale_indices, n_tiles: int) -> bytes:
"""F10: args for CMD_OP_TILE_STALE. Pure; raises on out-of-range."""
if not 0 < n_tiles <= 1024:
raise ValueError(f"n_tiles out of range: {n_tiles}")
bitmap = bytearray((n_tiles + 7) // 8)
for idx in stale_indices:
i = int(idx)
if not 0 <= i < n_tiles:
raise ValueError(f"tile index {i} out of range for {n_tiles}")
bitmap[i // 8] |= 1 << (i % 8)
return (int(base_seq) & 0xFFFF).to_bytes(2, "little") + bytes(bitmap)


def parse_tile_stale(args: bytes) -> "tuple[int, list[int]] | None":
"""F10: inverse of pack_tile_stale. None on a truncated body."""
if len(args) < 3:
return None
base_seq = int.from_bytes(args[0:2], "little")
out = []
for byte_i, b in enumerate(args[2:]):
for bit in range(8):
if b & (1 << bit):
out.append(byte_i * 8 + bit)
return base_seq, out


def pack_ctrl_ditto(ref_seq: int) -> bytes:
"""Pack a ditto referencing the ControlFrame sequence being repeated."""
return pack_command_frame(CMD_OP_CTRL_DITTO,
Expand Down
119 changes: 119 additions & 0 deletions LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_tile_stale.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""F10 — the 0x6C stale-tile report: proto roundtrip and the base-side scan.

The reassembly-timeout keyframe self-heal was measured net harmful (n=2/side:
+1.88 pts loss, −16% frames, +31% timeouts) but could not simply be deleted:
the canvas is persistent, so a lost tile update displays stale imagery until
the encoder happens to resend. The 0x6C report closes that loop from the only
side that knows what arrived, 84× cheaper than the keyframe it replaces, and
rides the tractor's existing age-escalation machinery.
"""

import os
import sys
import unittest
from unittest import mock

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from lora_proto import ( # noqa: E402
CMD_OP_TILE_STALE,
pack_command_frame,
parse_command_frame,
pack_tile_stale,
parse_tile_stale,
)
from image_pipeline.canvas import Canvas # noqa: E402

# web_ui connects to the MQTT broker at import time (and RAISES after 30 s
# if none is listening — CI has no broker), so it must be imported under the
# same paho stub the other web_ui tests use. Skip cleanly on pure-firmware
# checkouts without fastapi/paho.
try:
import paho.mqtt.client # noqa: F401
import fastapi # noqa: F401
except ImportError:
raise unittest.SkipTest("paho-mqtt + fastapi required for web_ui import")

with mock.patch("paho.mqtt.client.Client") as _mqtt_class:
_instance = _mqtt_class.return_value
_instance.connect = mock.MagicMock()
_instance.loop_start = mock.MagicMock()
_instance.subscribe = mock.MagicMock()
_instance.publish = mock.MagicMock()
import importlib
import web_ui
importlib.reload(web_ui) # rebind module-level mqtt stub
compute_stale_tiles = web_ui.compute_stale_tiles

Comment on lines +24 to +26

class ProtoRoundtripTests(unittest.TestCase):

def test_roundtrip(self) -> None:
body = pack_tile_stale(0x1234, [0, 7, 8, 42, 95], 96)
self.assertEqual(len(body), 2 + 12, "12x8 grid = 14 B body")
parsed = parse_tile_stale(body)
self.assertIsNotNone(parsed)
base_seq, tiles = parsed
self.assertEqual(base_seq, 0x1234)
self.assertEqual(tiles, [0, 7, 8, 42, 95])

def test_empty_bitmap(self) -> None:
base_seq, tiles = parse_tile_stale(pack_tile_stale(1, [], 96))
self.assertEqual(tiles, [])

def test_out_of_range_index_raises(self) -> None:
with self.assertRaises(ValueError):
pack_tile_stale(0, [96], 96)

def test_truncated_body_returns_none(self) -> None:
self.assertIsNone(parse_tile_stale(b"\x01\x00"))

def test_rides_the_command_frame(self) -> None:
body = pack_tile_stale(7, [3], 96)
frame = pack_command_frame(CMD_OP_TILE_STALE, body)
op, args = parse_command_frame(frame)
self.assertEqual(op, CMD_OP_TILE_STALE)
self.assertEqual(parse_tile_stale(args), (7, [3]))

def test_wire_cost_is_one_minimum_command_frame_class(self) -> None:
"""14 B body + 2 B cmd hdr + 8 B hop hdr = 24 B on air ≈ 15.4 ms at
DTS — the 84x-cheaper-than-a-keyframe claim rests on this size."""
body = pack_tile_stale(0, list(range(96)), 96)
self.assertEqual(2 + len(body), 16)


class StaleScanTests(unittest.TestCase):

def _canvas_with_keyframe(self, now_ms: int) -> Canvas:
c = Canvas(clock_ms=lambda: now_ms)
c._has_keyframe = True
return c

def test_no_keyframe_reports_nothing(self) -> None:
c = Canvas(clock_ms=lambda: 50_000)
self.assertEqual(compute_stale_tiles(c, 50_000, 20_000), [],
"an unpopulated canvas has nothing to be stale")

def test_fresh_tiles_not_reported(self) -> None:
c = self._canvas_with_keyframe(100_000)
for t in c._tiles:
t.arrived_ms = 95_000
self.assertEqual(compute_stale_tiles(c, 100_000, 20_000), [])

def test_stale_and_never_arrived_are_reported(self) -> None:
c = self._canvas_with_keyframe(100_000)
for t in c._tiles:
t.arrived_ms = 95_000
c._tiles[5].arrived_ms = 70_000 # 30 s old > 20 s horizon
c._tiles[9].arrived_ms = 0 # never arrived post-keyframe
self.assertEqual(compute_stale_tiles(c, 100_000, 20_000), [5, 9])

def test_threshold_boundary_is_exclusive(self) -> None:
c = self._canvas_with_keyframe(100_000)
for t in c._tiles:
t.arrived_ms = 80_000 # exactly 20 s: NOT stale (>)
self.assertEqual(compute_stale_tiles(c, 100_000, 20_000), [])


if __name__ == "__main__":
unittest.main()
Loading
Loading