preserve transport outage validation evidence

This commit is contained in:
Jay
2026-08-17 14:30:56 -04:00
parent 291c6b54e6
commit 1cf0a9ac77
9 changed files with 176 additions and 26 deletions
+13 -6
View File
@@ -66,12 +66,19 @@ overruns. Hardware data-ready interrupts and FIFO acquisition are deferred to th
later sensor-side acquisition refinement. later sensor-side acquisition refinement.
Completed samples enter a 512-record RAM queue, providing 5.12 seconds of 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 transport-outage tolerance at 100 Hz when the transport reports backpressure or
and retries its packet while this queue accumulates the backlog. A lower-priority failure accurately. A failed write retains and retries its packet while this
output task batches up to eight records into versioned `TRK1` frames, isolating queue accumulates the backlog. A lower-priority output task batches up to eight
acquisition from brief USB or future BLE stalls. CRC, packet and sample sequences, records into versioned `TRK1` frames, isolating acquisition from brief transport
timestamps, and cumulative loss/overrun counters make any queue overflow stalls. CRC, packet and sample sequences, timestamps, and cumulative
detectable. 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 Measured end-to-end framing overhead is about 2.47 kB/s at 100 Hz, or 8.47
MiB/hour before BLE link overhead. MiB/hour before BLE link overhead.
+16 -6
View File
@@ -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 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 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 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 decoupling when the transport reports backpressure or failure accurately. A
retries the same encoded packet rather than dequeuing more samples, so the queue failed write retains and retries the same encoded packet rather than dequeuing
accumulates the outage backlog. After reconnection, the oldest retained data is more samples, so the queue accumulates the outage backlog. After reconnection,
sent first. If the queue fills, acquisition drops new samples rather than the oldest retained data is sent first. If the queue fills, acquisition drops
overwriting older ones; sequence gaps and the cumulative lost-sample counter new samples rather than overwriting older ones; sequence gaps and the cumulative
expose that permanent loss. 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. 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. Those bytes cannot pass CRC validation and are not silently admitted as samples.
+34 -5
View File
@@ -15,6 +15,8 @@ data.
encoder/parser contract cases encoder/parser contract cases
- ESP-IDF firmware build and flash on the assembled ESP32-C3 prototype - ESP-IDF firmware build and flash on the assembled ESP32-C3 prototype
- Live USB capture followed by independent offline re-decoding - 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 ## 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 mode to `ESP_LINE_ENDINGS_LF`, which means no byte modification. Startup logs and
readable metadata are flushed first. 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 ## Final hardware capture
`captures/binary_v1_smoke2.trk` and its decoded CSV contain: `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 confirm that packet encoding, CRC, float metadata, and USB output did not disturb
the acquisition cadence. the acquisition cadence.
Output failure is transactional: firmware retains and retries the same encoded When output failure is reported, handling is transactional: firmware retains and
packet with a scheduler delay instead of discarding it or dequeuing more samples. retries the same encoded packet with a scheduler delay instead of discarding it
The queue therefore accumulates a disconnected-transport backlog. If an outage or dequeuing more samples. The queue therefore accumulates the transport backlog.
outlasts the queue, acquisition drops and counts new samples while preserving the If an outage outlasts the queue, acquisition drops and counts new samples while
oldest queued data for ordered delivery after reconnection. preserving the oldest queued data for ordered delivery after reconnection.
## Forced transport-outage validation ## 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 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 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. 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.
+2
View File
@@ -201,6 +201,8 @@ static void output_task(void *argument)
} }
++sample_count; ++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) { while (sample_count < TRIKKE_WIRE_MAX_RECORDS) {
trikke_wire_sample_t next_sample = {0}; trikke_wire_sample_t next_sample = {0};
if (xQueuePeek(context->sample_queue, &next_sample, if (xQueuePeek(context->sample_queue, &next_sample,
+25
View File
@@ -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
```
Binary file not shown.
Binary file not shown.
+12 -6
View File
@@ -3,6 +3,12 @@
#include "trikke_protocol.h" #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) int main(void)
{ {
uint8_t packet[TRIKKE_WIRE_MAX_PACKET_SIZE] = {0}; uint8_t packet[TRIKKE_WIRE_MAX_PACKET_SIZE] = {0};
@@ -20,7 +26,7 @@ int main(void)
size_t size = trikke_encode_metadata_packet( size_t size = trikke_encode_metadata_packet(
packet, sizeof(packet), 41, 1234567, 2, 3, &metadata); packet, sizeof(packet), 41, 1234567, 2, 3, &metadata);
if (size == 0 || fwrite(packet, 1, size, stdout) != size) { if (size == 0 || fwrite(packet, 1, size, stdout) != size) {
return 1; return fail(1, "metadata encoding or output");
} }
const trikke_wire_sample_t samples[] = { const trikke_wire_sample_t samples[] = {
@@ -52,7 +58,7 @@ int main(void)
size = trikke_encode_sample_packet(packet, sizeof(packet), 42, 2, 3, size = trikke_encode_sample_packet(packet, sizeof(packet), 42, 2, 3,
samples, 2); samples, 2);
if (size == 0 || fwrite(packet, 1, size, stdout) != size) { 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}; trikke_wire_sample_t full_packet[TRIKKE_WIRE_MAX_RECORDS] = {0};
@@ -69,7 +75,7 @@ int main(void)
TRIKKE_WIRE_MAX_RECORDS); TRIKKE_WIRE_MAX_RECORDS);
if (size != TRIKKE_WIRE_MAX_PACKET_SIZE || if (size != TRIKKE_WIRE_MAX_PACKET_SIZE ||
fwrite(packet, 1, size, stdout) != size) { fwrite(packet, 1, size, stdout) != size) {
return 2; return fail(3, "maximum-size sample encoding or output");
} }
const trikke_wire_sample_t saturated[] = { const trikke_wire_sample_t saturated[] = {
@@ -79,13 +85,13 @@ int main(void)
size = trikke_encode_sample_packet(packet, sizeof(packet), 44, 2, 3, size = trikke_encode_sample_packet(packet, sizeof(packet), 44, 2, 3,
saturated, 2); saturated, 2);
if (size == 0 || fwrite(packet, 1, size, stdout) != size) { 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) || if (!trikke_wire_timestamp_delta_fits(0, 655350) ||
trikke_wire_timestamp_delta_fits(0, 655351) || trikke_wire_timestamp_delta_fits(0, 655351) ||
trikke_wire_timestamp_delta_fits(1, 0)) { trikke_wire_timestamp_delta_fits(1, 0)) {
return 4; return fail(5, "timestamp-delta boundary contract");
} }
if (trikke_encode_metadata_packet( if (trikke_encode_metadata_packet(
packet, TRIKKE_WIRE_HEADER_SIZE + TRIKKE_WIRE_METADATA_SIZE - 1, 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, trikke_encode_sample_packet(packet, TRIKKE_WIRE_MAX_PACKET_SIZE - 1,
0, 0, 0, full_packet, 0, 0, 0, full_packet,
TRIKKE_WIRE_MAX_RECORDS) != 0) { TRIKKE_WIRE_MAX_RECORDS) != 0) {
return 5; return fail(6, "invalid argument rejection contract");
} }
return 0; return 0;
} }
+74 -3
View File
@@ -1,3 +1,4 @@
import hashlib
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -41,9 +42,13 @@ class ProtocolContractTest(unittest.TestCase):
], ],
check=True, check=True,
) )
cls.encoded = subprocess.run( fixture = subprocess.run([str(executable)], capture_output=True)
[str(executable)], check=True, capture_output=True if fixture.returncode != 0:
).stdout stderr = fixture.stderr.decode(errors="replace").strip()
raise AssertionError(
f"protocol fixture exited {fixture.returncode}: {stderr}"
)
cls.encoded = fixture.stdout
@classmethod @classmethod
def tearDownClass(cls) -> None: def tearDownClass(cls) -> None:
@@ -131,6 +136,72 @@ class ProtocolContractTest(unittest.TestCase):
self.assertEqual(3, len(frames)) self.assertEqual(3, len(frames))
self.assertEqual(36 + 2 * 20 - 5, parser.buffered_bytes) 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__": if __name__ == "__main__":
unittest.main() unittest.main()