diff --git a/README.md b/README.md index 0425fbf..f9e115d 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,13 @@ optional `--wire` path preserves every received byte, including startup text and damaged or partial frames, for forensic comparison: ```sh -python tools/capture_binary.py --wire captures/session.wire +python tools/capture_binary.py --reset --wire captures/session.wire ``` +`--reset` normalizes the USB DTR/RTS state, clears bytes from the prior session, +and resets the C3 while the new capture is already open. Omit it when attaching +to an intentionally uninterrupted stream. + An existing `.trk` stream can be decoded again without hardware: ```sh diff --git a/docs/transport-layer-validation-2026-08-17.md b/docs/transport-layer-validation-2026-08-17.md index 4b5da1b..c00544d 100644 --- a/docs/transport-layer-validation-2026-08-17.md +++ b/docs/transport-layer-validation-2026-08-17.md @@ -99,3 +99,19 @@ sample gaps/resets, timestamp anomalies, sensor status, saturation flags, drops, and acquisition overruns. Sequence classification is wrap-aware. The capture tool also accepts `--wire PATH` to preserve every serial byte before parsing, including startup text, corrupt frames, and trailing fragments. + +For deterministic fresh-session validation, `capture_binary.py --reset` releases +DTR/RTS, clears the prior input session, then pulses the C3 reset line while the +same reader remains open. This follows ESP-IDF monitor's USB Serial/JTAG reset +ordering and avoids a flash-to-capture port-open race. + +Commit `73e5680` was built, flashed, and then captured through this reset path. +The result contains 1,184 contiguous samples, sequences 0 through 1,183, with +zero packet/sample gaps, resets, CRC failures, reported drops, loop overruns, +trailing bytes, or timestamp saturation. Raw-wire offline decoding produced CSV +byte-for-byte identical to live rendering. Both artifacts are tracked: + +- `tests/fixtures/direct_usb_73e5680.trk`, SHA-256 + `fd34bb3bf8f92a64960024f1287e553c03076ff3714fe629ec629bec81ddf821` +- `tests/fixtures/direct_usb_73e5680.wire`, SHA-256 + `82d6d17bbf0729e9bfc53f337ec9adf70bcb5e5898b039685eda5dfa19cf4eea` diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index dcfa6fe..d288015 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -43,6 +43,17 @@ removed before production firmware was built and flashed. of startup text before the valid frames. That text includes the literal `TRK1`, producing one rejected candidate header as designed. Extracting all valid frames reproduces `direct_usb_3c95f3d.trk` byte-for-byte. +- `direct_usb_73e5680.trk` — SHA-256 + `fd34bb3bf8f92a64960024f1287e553c03076ff3714fe629ec629bec81ddf821`. + This exact firmware-hardening capture contains 1,184 contiguous samples, + sequences 0 through 1,183, with zero packet/sample gaps, resets, CRC failures, + reported drops, loop overruns, trailing bytes, or timestamp saturation. +- `direct_usb_73e5680.wire` — SHA-256 + `82d6d17bbf0729e9bfc53f337ec9adf70bcb5e5898b039685eda5dfa19cf4eea`. + This is the byte-for-byte `--reset --wire` capture corresponding to the + validated file above. Its 3,589 skipped startup bytes and one candidate-header + rejection are deterministic, and extracting its 151 valid frames reproduces + `direct_usb_73e5680.trk` byte-for-byte. `tests/test_trikke_protocol.py` verifies the hashes, parses the captures in fragmented chunks, and asserts these signatures so the hardware evidence remains diff --git a/tests/fixtures/direct_usb_73e5680.trk b/tests/fixtures/direct_usb_73e5680.trk new file mode 100644 index 0000000..d6712d6 Binary files /dev/null and b/tests/fixtures/direct_usb_73e5680.trk differ diff --git a/tests/fixtures/direct_usb_73e5680.wire b/tests/fixtures/direct_usb_73e5680.wire new file mode 100644 index 0000000..9978307 Binary files /dev/null and b/tests/fixtures/direct_usb_73e5680.wire differ diff --git a/tests/test_trikke_protocol.py b/tests/test_trikke_protocol.py index 148b988..1b066eb 100644 --- a/tests/test_trikke_protocol.py +++ b/tests/test_trikke_protocol.py @@ -215,6 +215,15 @@ class ProtocolContractTest(unittest.TestCase): "timing_anomalies": 8, "gaps": [], }, + "direct_usb_73e5680.trk": { + "sha256": "fd34bb3bf8f92a64960024f1287e553c03076ff3714fe629ec629bec81ddf821", + "sample_count": 1184, + "first_sequence": 0, + "last_sequence": 1183, + "max_dropped": 0, + "timing_anomalies": 2, + "gaps": [], + }, } for name, contract in expected.items(): @@ -285,29 +294,44 @@ class ProtocolContractTest(unittest.TestCase): self.assertEqual(contract["gaps"], gaps) def test_exact_usb_raw_wire_evidence(self) -> None: - wire = ( - ROOT / "tests" / "fixtures" / "direct_usb_3c95f3d.wire" - ).read_bytes() - validated = ( - ROOT / "tests" / "fixtures" / "direct_usb_3c95f3d.trk" - ).read_bytes() - self.assertEqual( - "3bdaeadff7962c6eac48c4ebeda285c8eb359e439d5e1728104add2009122c03", - hashlib.sha256(wire).hexdigest(), - ) + expected = { + "direct_usb_3c95f3d": { + "sha256": "3bdaeadff7962c6eac48c4ebeda285c8eb359e439d5e1728104add2009122c03", + "frames": 214, + "skipped": 563, + }, + "direct_usb_73e5680": { + "sha256": "82d6d17bbf0729e9bfc53f337ec9adf70bcb5e5898b039685eda5dfa19cf4eea", + "frames": 151, + "skipped": 3589, + }, + } + for stem, contract in expected.items(): + with self.subTest(fixture=stem): + wire = ( + ROOT / "tests" / "fixtures" / f"{stem}.wire" + ).read_bytes() + validated = ( + ROOT / "tests" / "fixtures" / f"{stem}.trk" + ).read_bytes() + self.assertEqual( + contract["sha256"], hashlib.sha256(wire).hexdigest() + ) - parser = StreamParser() - frames = [] - for offset in range(0, len(wire), 113): - frames.extend(parser.feed(wire[offset : offset + 113])) + parser = StreamParser() + frames = [] + for offset in range(0, len(wire), 113): + frames.extend(parser.feed(wire[offset : offset + 113])) - self.assertEqual(214, len(frames)) - self.assertEqual(validated, b"".join(frame.raw for frame in frames)) - self.assertEqual(0, parser.startup_crc_errors) - self.assertEqual(0, parser.crc_errors) - self.assertEqual(1, parser.header_errors) - self.assertEqual(563, parser.skipped_bytes) - self.assertEqual(0, parser.buffered_bytes) + self.assertEqual(contract["frames"], len(frames)) + self.assertEqual( + validated, b"".join(frame.raw for frame in frames) + ) + self.assertEqual(0, parser.startup_crc_errors) + self.assertEqual(0, parser.crc_errors) + self.assertEqual(1, parser.header_errors) + self.assertEqual(contract["skipped"], parser.skipped_bytes) + self.assertEqual(0, parser.buffered_bytes) if __name__ == "__main__": diff --git a/tools/capture_binary.py b/tools/capture_binary.py index 5d7bbb2..a8f1dcb 100644 --- a/tools/capture_binary.py +++ b/tools/capture_binary.py @@ -6,6 +6,7 @@ import csv import glob import signal import sys +import time from contextlib import ExitStack from datetime import datetime from pathlib import Path @@ -27,6 +28,11 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--port", help="serial port; auto-detected when omitted") parser.add_argument("--baud", type=int, default=115200) + parser.add_argument( + "--reset", + action="store_true", + help="hard-reset the ESP32-C3 after opening the serial port", + ) parser.add_argument("--output", type=Path, help="validated binary .trk output") parser.add_argument("--csv", type=Path, help="decoded CSV output") parser.add_argument( @@ -109,6 +115,16 @@ def main() -> int: if args.wire is not None else None ) + if args.reset: + # Match ESP-IDF monitor's USB Serial/JTAG hard-reset state: + # release DTR/RTS first, discard the old session, pulse reset, + # and keep this same reader open for the new boot stream. + sensor.dtr = False + sensor.rts = False + sensor.reset_input_buffer() + sensor.rts = True + time.sleep(0.2) + sensor.rts = False writer = csv.writer(decoded) writer.writerow(CSV_COLUMNS) while not stop_requested: