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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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"
|
#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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user