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 0000000..96cd201 Binary files /dev/null and b/tests/fixtures/forced_outage_3s.trk differ diff --git a/tests/fixtures/forced_outage_7s.trk b/tests/fixtures/forced_outage_7s.trk new file mode 100644 index 0000000..f68da69 Binary files /dev/null and b/tests/fixtures/forced_outage_7s.trk differ 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()