preserve hardened USB smoke evidence
This commit is contained in:
@@ -141,9 +141,13 @@ optional `--wire` path preserves every received byte, including startup text and
|
|||||||
damaged or partial frames, for forensic comparison:
|
damaged or partial frames, for forensic comparison:
|
||||||
|
|
||||||
```sh
|
```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:
|
An existing `.trk` stream can be decoded again without hardware:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -99,3 +99,19 @@ sample gaps/resets, timestamp anomalies, sensor status, saturation flags, drops,
|
|||||||
and acquisition overruns. Sequence classification is wrap-aware. The capture
|
and acquisition overruns. Sequence classification is wrap-aware. The capture
|
||||||
tool also accepts `--wire PATH` to preserve every serial byte before parsing,
|
tool also accepts `--wire PATH` to preserve every serial byte before parsing,
|
||||||
including startup text, corrupt frames, and trailing fragments.
|
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`
|
||||||
|
|||||||
Vendored
+11
@@ -43,6 +43,17 @@ removed before production firmware was built and flashed.
|
|||||||
of startup text before the valid frames. That text includes the literal
|
of startup text before the valid frames. That text includes the literal
|
||||||
`TRK1`, producing one rejected candidate header as designed. Extracting all
|
`TRK1`, producing one rejected candidate header as designed. Extracting all
|
||||||
valid frames reproduces `direct_usb_3c95f3d.trk` byte-for-byte.
|
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
|
`tests/test_trikke_protocol.py` verifies the hashes, parses the captures in
|
||||||
fragmented chunks, and asserts these signatures so the hardware evidence remains
|
fragmented chunks, and asserts these signatures so the hardware evidence remains
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -215,6 +215,15 @@ class ProtocolContractTest(unittest.TestCase):
|
|||||||
"timing_anomalies": 8,
|
"timing_anomalies": 8,
|
||||||
"gaps": [],
|
"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():
|
for name, contract in expected.items():
|
||||||
@@ -285,29 +294,44 @@ class ProtocolContractTest(unittest.TestCase):
|
|||||||
self.assertEqual(contract["gaps"], gaps)
|
self.assertEqual(contract["gaps"], gaps)
|
||||||
|
|
||||||
def test_exact_usb_raw_wire_evidence(self) -> None:
|
def test_exact_usb_raw_wire_evidence(self) -> None:
|
||||||
wire = (
|
expected = {
|
||||||
ROOT / "tests" / "fixtures" / "direct_usb_3c95f3d.wire"
|
"direct_usb_3c95f3d": {
|
||||||
).read_bytes()
|
"sha256": "3bdaeadff7962c6eac48c4ebeda285c8eb359e439d5e1728104add2009122c03",
|
||||||
validated = (
|
"frames": 214,
|
||||||
ROOT / "tests" / "fixtures" / "direct_usb_3c95f3d.trk"
|
"skipped": 563,
|
||||||
).read_bytes()
|
},
|
||||||
self.assertEqual(
|
"direct_usb_73e5680": {
|
||||||
"3bdaeadff7962c6eac48c4ebeda285c8eb359e439d5e1728104add2009122c03",
|
"sha256": "82d6d17bbf0729e9bfc53f337ec9adf70bcb5e5898b039685eda5dfa19cf4eea",
|
||||||
hashlib.sha256(wire).hexdigest(),
|
"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()
|
parser = StreamParser()
|
||||||
frames = []
|
frames = []
|
||||||
for offset in range(0, len(wire), 113):
|
for offset in range(0, len(wire), 113):
|
||||||
frames.extend(parser.feed(wire[offset : offset + 113]))
|
frames.extend(parser.feed(wire[offset : offset + 113]))
|
||||||
|
|
||||||
self.assertEqual(214, len(frames))
|
self.assertEqual(contract["frames"], len(frames))
|
||||||
self.assertEqual(validated, b"".join(frame.raw for frame in frames))
|
self.assertEqual(
|
||||||
self.assertEqual(0, parser.startup_crc_errors)
|
validated, b"".join(frame.raw for frame in frames)
|
||||||
self.assertEqual(0, parser.crc_errors)
|
)
|
||||||
self.assertEqual(1, parser.header_errors)
|
self.assertEqual(0, parser.startup_crc_errors)
|
||||||
self.assertEqual(563, parser.skipped_bytes)
|
self.assertEqual(0, parser.crc_errors)
|
||||||
self.assertEqual(0, parser.buffered_bytes)
|
self.assertEqual(1, parser.header_errors)
|
||||||
|
self.assertEqual(contract["skipped"], parser.skipped_bytes)
|
||||||
|
self.assertEqual(0, parser.buffered_bytes)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import csv
|
|||||||
import glob
|
import glob
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -27,6 +28,11 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--port", help="serial port; auto-detected when omitted")
|
parser.add_argument("--port", help="serial port; auto-detected when omitted")
|
||||||
parser.add_argument("--baud", type=int, default=115200)
|
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("--output", type=Path, help="validated binary .trk output")
|
||||||
parser.add_argument("--csv", type=Path, help="decoded CSV output")
|
parser.add_argument("--csv", type=Path, help="decoded CSV output")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -109,6 +115,16 @@ def main() -> int:
|
|||||||
if args.wire is not None
|
if args.wire is not None
|
||||||
else 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 = csv.writer(decoded)
|
||||||
writer.writerow(CSV_COLUMNS)
|
writer.writerow(CSV_COLUMNS)
|
||||||
while not stop_requested:
|
while not stop_requested:
|
||||||
|
|||||||
Reference in New Issue
Block a user