preserve transport outage validation evidence
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+25
@@ -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
|
||||
```
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user