From 1cf0a9ac779ea25aec534996f5d0f2561fd96151 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 17 Aug 2026 14:30:56 -0400 Subject: [PATCH] preserve transport outage validation evidence --- README.md | 19 +++-- docs/binary-record-v1.md | 22 +++-- .../binary-transport-validation-2026-08-17.md | 39 +++++++-- main/trikke_sensor_main.c | 2 + tests/fixtures/README.md | 25 ++++++ tests/fixtures/forced_outage_3s.trk | Bin 0 -> 52948 bytes tests/fixtures/forced_outage_7s.trk | Bin 0 -> 45416 bytes tests/protocol_fixture.c | 18 ++-- tests/test_trikke_protocol.py | 77 +++++++++++++++++- 9 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/forced_outage_3s.trk create mode 100644 tests/fixtures/forced_outage_7s.trk diff --git a/README.md b/README.md index 784e885..ff94ccd 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,19 @@ overruns. Hardware data-ready interrupts and FIFO acquisition are deferred to th later sensor-side acquisition refinement. Completed samples enter a 512-record RAM queue, providing 5.12 seconds of -blocked or disconnected transport tolerance at 100 Hz. A failed write retains -and retries its packet while this queue accumulates the backlog. A lower-priority -output task batches up to eight records into versioned `TRK1` frames, isolating -acquisition from brief USB or future BLE stalls. CRC, packet and sample sequences, -timestamps, and cumulative loss/overrun counters make any queue overflow -detectable. +transport-outage tolerance at 100 Hz when the transport reports backpressure or +failure accurately. A failed write retains and retries its packet while this +queue accumulates the backlog. A lower-priority output task batches up to eight +records into versioned `TRK1` frames, isolating acquisition from brief transport +stalls. CRC, packet and sample sequences, timestamps, and cumulative +loss/overrun counters make permanent loss detectable by the receiver. + +The current ESP-IDF USB VFS path reports physical disconnects, but a connected +host that stops draining can time out below stdio and still appear successful to +firmware. The host can detect resulting loss from packet/sample sequences and +CRC framing, but the device cannot count that case. A direct USB driver with +bounded drain waits, and ultimately receiver acknowledgements with replay, are +deferred to the common USB/BLE transport layer. Measured end-to-end framing overhead is about 2.47 kB/s at 100 Hz, or 8.47 MiB/hour before BLE link overhead. diff --git a/docs/binary-record-v1.md b/docs/binary-record-v1.md index f2a7782..c3fa500 100644 --- a/docs/binary-record-v1.md +++ b/docs/binary-record-v1.md @@ -81,12 +81,22 @@ before that first valid frame separately from CRC failures after synchronization Acquisition runs in a dedicated higher-priority task and writes complete samples to a 512-entry RAM queue. The lower-priority output task batches up to eight records per frame. At 100 Hz this queue represents about 5.12 seconds of -decoupling from a blocked or disconnected transport. A failed write retains and -retries the same encoded packet rather than dequeuing more samples, so the queue -accumulates the outage backlog. After reconnection, the oldest retained data is -sent first. If the queue fills, acquisition drops new samples rather than -overwriting older ones; sequence gaps and the cumulative lost-sample counter -expose that permanent loss. +decoupling when the transport reports backpressure or failure accurately. A +failed write retains and retries the same encoded packet rather than dequeuing +more samples, so the queue accumulates the outage backlog. After reconnection, +the oldest retained data is sent first. If the queue fills, acquisition drops +new samples rather than overwriting older ones; sequence gaps and the cumulative +lost-sample counter expose that permanent loss. + +That retry guarantee requires the transport's success result to mean that the +complete frame was accepted for eventual delivery. The current USB Serial/JTAG +VFS/stdio path does not fully satisfy that contract: if the host remains +connected but stops draining, its internal timeout can discard bytes while the +stdio write appears successful. CRC and sequence checks make that loss visible +to a receiver, but it does not increment the device's drop counter. A direct +driver path with bounded transmit-drain waits can report this condition; an +application acknowledgement and replay window is required for end-to-end +delivery confirmation. Receivers report bytes left in an incomplete trailing frame when capture ends. Those bytes cannot pass CRC validation and are not silently admitted as samples. diff --git a/docs/binary-transport-validation-2026-08-17.md b/docs/binary-transport-validation-2026-08-17.md index e7ac234..302d0fa 100644 --- a/docs/binary-transport-validation-2026-08-17.md +++ b/docs/binary-transport-validation-2026-08-17.md @@ -15,6 +15,8 @@ data. encoder/parser contract cases - ESP-IDF firmware build and flash on the assembled ESP32-C3 prototype - Live USB capture followed by independent offline re-decoding +- Tracked three- and seven-second hardware-outage captures with regression + assertions for integrity, retention order, and overflow accounting ## USB text-conversion finding @@ -27,6 +29,22 @@ Before binary output begins, firmware now changes the USB Serial/JTAG VFS transm mode to `ESP_LINE_ENDINGS_LF`, which means no byte modification. Startup logs and readable metadata are flushed first. +## USB VFS resilience qualification + +The 5.12-second queue guarantee applies when the transport reports failure or +backpressure accurately. The current ESP-IDF USB Serial/JTAG VFS/stdio path +surfaces a physical disconnect, but it has a weaker connected-stall case: when a +host remains connected and stops draining, the lower-level timeout can discard +bytes even though stdio reports a successful write. Firmware therefore cannot +retain that particular frame or increment its drop counter. The receiver still +detects the loss through CRC resynchronization and packet/sample sequence gaps. + +ESP-IDF's direct USB Serial/JTAG driver provides bounded writes and an explicit +transmit-drain wait, allowing a connected stall to become observable to the +transport policy. That is a useful improvement for the common transport layer. +It is not proof of receiver delivery; application acknowledgements and replay +are needed for that stronger guarantee and are planned with BLE integration. + ## Final hardware capture `captures/binary_v1_smoke2.trk` and its decoded CSV contain: @@ -58,11 +76,11 @@ at 100 Hz). The hardware capture's zero timing anomalies and zero loop overruns confirm that packet encoding, CRC, float metadata, and USB output did not disturb the acquisition cadence. -Output failure is transactional: firmware retains and retries the same encoded -packet with a scheduler delay instead of discarding it or dequeuing more samples. -The queue therefore accumulates a disconnected-transport backlog. If an outage -outlasts the queue, acquisition drops and counts new samples while preserving the -oldest queued data for ordered delivery after reconnection. +When output failure is reported, handling is transactional: firmware retains and +retries the same encoded packet with a scheduler delay instead of discarding it +or dequeuing more samples. The queue therefore accumulates the transport backlog. +If an outage outlasts the queue, acquisition drops and counts new samples while +preserving the oldest queued data for ordered delivery after reconnection. ## Forced transport-outage validation @@ -80,3 +98,14 @@ then dropped 138 new samples after reaching capacity. Delivery resumed at sample timestamp difference from sample 511 to 650 was exactly 1,390,000 us, matching 139 sample intervals, and no saturation flag was emitted. This verifies both the oldest-data retention policy and the new exact timestamp re-anchor after overflow. + +The exact validated byte streams are tracked as executable regression fixtures: + +- `tests/fixtures/forced_outage_3s.trk`, SHA-256 + `01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6` +- `tests/fixtures/forced_outage_7s.trk`, SHA-256 + `2ea8a5742944bdebc13bec2ccdbceba75f0bb71e48c856b0f86285878e190cd3` + +The protocol tests verify both hashes, fragmented parsing with no CRC/header or +trailing-byte errors, the complete sequence ranges, the sole 138-sample overflow +gap, cumulative counters, and the exact 1,390,000 us timestamp re-anchor. diff --git a/main/trikke_sensor_main.c b/main/trikke_sensor_main.c index 34c85f8..e0a0810 100644 --- a/main/trikke_sensor_main.c +++ b/main/trikke_sensor_main.c @@ -201,6 +201,8 @@ static void output_task(void *argument) } ++sample_count; + // output_task is the queue's sole consumer. This peek-then-receive + // sequence relies on that invariant; transports must not dequeue here. while (sample_count < TRIKKE_WIRE_MAX_RECORDS) { trikke_wire_sample_t next_sample = {0}; if (xQueuePeek(context->sample_queue, &next_sample, diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..1448dd0 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,25 @@ +# Hardware outage fixtures + +These captures came from the assembled XIAO ESP32-C3 prototype. A temporary +validation build made the packet writer report failure while acquisition kept +running; that failure injection was removed before the production firmware was +built and flashed. The files contain only complete, CRC-valid `TRK1` frames. + +- `forced_outage_3s.trk` — SHA-256 + `01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6`. + It contains 2,144 contiguous samples, sequences 0 through 2,143, with no + reported drops, overruns, or timestamp saturation. +- `forced_outage_7s.trk` — SHA-256 + `2ea8a5742944bdebc13bec2ccdbceba75f0bb71e48c856b0f86285878e190cd3`. + It contains 1,840 delivered samples. The sole sequence gap is 511 to 650: + 138 samples were dropped after the 512-entry queue filled, the cumulative + drop count reached 138, and the corresponding timestamp delta is exactly + 1,390,000 us. + +`tests/test_trikke_protocol.py` verifies the hashes, parses the captures in +fragmented chunks, and asserts these signatures so the hardware evidence remains +executable regression data. To inspect either file manually: + +```sh +python3 tools/decode_binary.py tests/fixtures/forced_outage_3s.trk /tmp/outage.csv +``` diff --git a/tests/fixtures/forced_outage_3s.trk b/tests/fixtures/forced_outage_3s.trk new file mode 100644 index 0000000000000000000000000000000000000000..96cd20186ff0e6f68f6652870294e3a37aa91970 GIT binary patch literal 52948 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 B=tTek literal 0 HcmV?d00001 diff --git a/tests/protocol_fixture.c b/tests/protocol_fixture.c index 8715a55..9ebe68c 100644 --- a/tests/protocol_fixture.c +++ b/tests/protocol_fixture.c @@ -3,6 +3,12 @@ #include "trikke_protocol.h" +static int fail(int code, const char *message) +{ + fprintf(stderr, "protocol fixture failure %d: %s\n", code, message); + return code; +} + int main(void) { uint8_t packet[TRIKKE_WIRE_MAX_PACKET_SIZE] = {0}; @@ -20,7 +26,7 @@ int main(void) size_t size = trikke_encode_metadata_packet( packet, sizeof(packet), 41, 1234567, 2, 3, &metadata); if (size == 0 || fwrite(packet, 1, size, stdout) != size) { - return 1; + return fail(1, "metadata encoding or output"); } const trikke_wire_sample_t samples[] = { @@ -52,7 +58,7 @@ int main(void) size = trikke_encode_sample_packet(packet, sizeof(packet), 42, 2, 3, samples, 2); if (size == 0 || fwrite(packet, 1, size, stdout) != size) { - return 1; + return fail(2, "basic sample encoding or output"); } trikke_wire_sample_t full_packet[TRIKKE_WIRE_MAX_RECORDS] = {0}; @@ -69,7 +75,7 @@ int main(void) TRIKKE_WIRE_MAX_RECORDS); if (size != TRIKKE_WIRE_MAX_PACKET_SIZE || fwrite(packet, 1, size, stdout) != size) { - return 2; + return fail(3, "maximum-size sample encoding or output"); } const trikke_wire_sample_t saturated[] = { @@ -79,13 +85,13 @@ int main(void) size = trikke_encode_sample_packet(packet, sizeof(packet), 44, 2, 3, saturated, 2); if (size == 0 || fwrite(packet, 1, size, stdout) != size) { - return 3; + return fail(4, "saturated timestamp encoding or output"); } if (!trikke_wire_timestamp_delta_fits(0, 655350) || trikke_wire_timestamp_delta_fits(0, 655351) || trikke_wire_timestamp_delta_fits(1, 0)) { - return 4; + return fail(5, "timestamp-delta boundary contract"); } if (trikke_encode_metadata_packet( packet, TRIKKE_WIRE_HEADER_SIZE + TRIKKE_WIRE_METADATA_SIZE - 1, @@ -98,7 +104,7 @@ int main(void) trikke_encode_sample_packet(packet, TRIKKE_WIRE_MAX_PACKET_SIZE - 1, 0, 0, 0, full_packet, TRIKKE_WIRE_MAX_RECORDS) != 0) { - return 5; + return fail(6, "invalid argument rejection contract"); } return 0; } diff --git a/tests/test_trikke_protocol.py b/tests/test_trikke_protocol.py index c997922..208d63c 100644 --- a/tests/test_trikke_protocol.py +++ b/tests/test_trikke_protocol.py @@ -1,3 +1,4 @@ +import hashlib import shutil import subprocess import sys @@ -41,9 +42,13 @@ class ProtocolContractTest(unittest.TestCase): ], check=True, ) - cls.encoded = subprocess.run( - [str(executable)], check=True, capture_output=True - ).stdout + fixture = subprocess.run([str(executable)], capture_output=True) + if fixture.returncode != 0: + stderr = fixture.stderr.decode(errors="replace").strip() + raise AssertionError( + f"protocol fixture exited {fixture.returncode}: {stderr}" + ) + cls.encoded = fixture.stdout @classmethod def tearDownClass(cls) -> None: @@ -131,6 +136,72 @@ class ProtocolContractTest(unittest.TestCase): self.assertEqual(3, len(frames)) self.assertEqual(36 + 2 * 20 - 5, parser.buffered_bytes) + def test_hardware_outage_validation_artifacts(self) -> None: + expected = { + "forced_outage_3s.trk": { + "sha256": "01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6", + "sample_count": 2144, + "last_sequence": 2143, + "max_dropped": 0, + "gaps": [], + }, + "forced_outage_7s.trk": { + "sha256": "2ea8a5742944bdebc13bec2ccdbceba75f0bb71e48c856b0f86285878e190cd3", + "sample_count": 1840, + "last_sequence": 1977, + "max_dropped": 138, + "gaps": [(511, 650, 1_390_000)], + }, + } + + for name, contract in expected.items(): + with self.subTest(fixture=name): + data = (ROOT / "tests" / "fixtures" / name).read_bytes() + self.assertEqual( + contract["sha256"], hashlib.sha256(data).hexdigest() + ) + + parser = StreamParser() + frames = [] + for offset in range(0, len(data), 257): + frames.extend(parser.feed(data[offset : offset + 257])) + + self.assertEqual(0, parser.startup_crc_errors) + self.assertEqual(0, parser.crc_errors) + self.assertEqual(0, parser.header_errors) + self.assertEqual(0, parser.skipped_bytes) + self.assertEqual(0, parser.buffered_bytes) + self.assertTrue(frames) + + samples = [sample for frame in frames for sample in frame.samples] + self.assertEqual(contract["sample_count"], len(samples)) + self.assertEqual(0, samples[0].sequence) + self.assertEqual(contract["last_sequence"], samples[-1].sequence) + self.assertEqual( + contract["max_dropped"], + max(frame.dropped_sample_count for frame in frames), + ) + self.assertEqual( + 0, max(frame.loop_overrun_count for frame in frames) + ) + self.assertFalse( + any( + frame.flags & PACKET_FLAG_TIMESTAMP_DELTA_SATURATED + for frame in frames + ) + ) + + gaps = [ + ( + left.sequence, + right.sequence, + right.timestamp_us - left.timestamp_us, + ) + for left, right in zip(samples, samples[1:]) + if right.sequence != left.sequence + 1 + ] + self.assertEqual(contract["gaps"], gaps) + if __name__ == "__main__": unittest.main()