harden USB telemetry transport

This commit is contained in:
Jay
2026-08-17 14:51:12 -04:00
parent 1cf0a9ac77
commit 3c95f3d7be
17 changed files with 709 additions and 114 deletions
+21 -5
View File
@@ -1,9 +1,11 @@
# 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.
These captures came from the assembled XIAO ESP32-C3 prototype. The files
contain only complete, CRC-valid `TRK1` frames.
The two forced-outage captures used a temporary validation build that made the
packet writer report failure while acquisition kept running. That injection was
removed before production firmware was built and flashed.
- `forced_outage_3s.trk` — SHA-256
`01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6`.
@@ -15,10 +17,24 @@ built and flashed. The files contain only complete, CRC-valid `TRK1` frames.
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.
- `direct_usb_stall.trk` — SHA-256
`40f874b7eaa7f705524ecdd75f832e8a724252366633116ac015fc75dfd16558`.
This came from the direct USB driver build after leaving the enumerated USB
endpoint without a serial reader long enough to overflow the acquisition
queue. It contains 864 samples. The retained block ends at sequence 511,
delivery resumes at 1,706, and both the sole 1,194-sample gap and the device's
cumulative drop counter equal 1,194. There are no packet gaps, CRC failures,
loop overruns, trailing bytes, or timestamp-saturation flags.
The first captured sample is sequence 8 because the flashing process still
had the endpoint open long enough to drain sequences 0 through 7 before the
capture application opened. That is deliberate evidence of the remaining
distinction: USB endpoint drain is observable, but application receipt
requires the planned acknowledgement/replay layer.
`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:
executable regression data. To inspect a file manually:
```sh
python3 tools/decode_binary.py tests/fixtures/forced_outage_3s.trk /tmp/outage.csv
Binary file not shown.
+67 -1
View File
@@ -10,6 +10,7 @@ ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
from trikke_protocol import ( # noqa: E402
IntegrityTracker,
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
PACKET_TYPE_METADATA,
PACKET_TYPE_SAMPLES,
@@ -50,6 +51,34 @@ class ProtocolContractTest(unittest.TestCase):
)
cls.encoded = fixture.stdout
transport_executable = Path(cls.tempdir.name) / "transport_fixture"
subprocess.run(
[
compiler,
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-I",
str(ROOT / "main"),
str(ROOT / "main" / "trikke_transport.c"),
str(ROOT / "tests" / "transport_fixture.c"),
"-o",
str(transport_executable),
],
check=True,
)
transport_fixture = subprocess.run(
[str(transport_executable)], capture_output=True
)
if transport_fixture.returncode != 0:
stderr = transport_fixture.stderr.decode(errors="replace").strip()
raise AssertionError(
"transport fixture exited "
f"{transport_fixture.returncode}: {stderr}"
)
cls.transport_fixture_passed = True
@classmethod
def tearDownClass(cls) -> None:
cls.tempdir.cleanup()
@@ -103,6 +132,18 @@ class ProtocolContractTest(unittest.TestCase):
self.assertEqual((2, 1, 258), tuple(row[14:17]))
self.assertEqual(3, row[-1])
def test_transport_state_machine_contract(self) -> None:
self.assertTrue(self.transport_fixture_passed)
def test_integrity_sequence_wrap_classification(self) -> None:
self.assertEqual(
(0, 0), IntegrityTracker._classify_sequence(0xFFFFFFFF, 0)
)
self.assertEqual(
(2, 0), IntegrityTracker._classify_sequence(0xFFFFFFFE, 1)
)
self.assertEqual((0, 1), IntegrityTracker._classify_sequence(1000, 0))
def test_crc_failure_resynchronizes_to_next_frame(self) -> None:
first_size = 36 + 48
damaged = bytearray(self.encoded[:first_size])
@@ -141,6 +182,7 @@ class ProtocolContractTest(unittest.TestCase):
"forced_outage_3s.trk": {
"sha256": "01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6",
"sample_count": 2144,
"first_sequence": 0,
"last_sequence": 2143,
"max_dropped": 0,
"gaps": [],
@@ -148,10 +190,19 @@ class ProtocolContractTest(unittest.TestCase):
"forced_outage_7s.trk": {
"sha256": "2ea8a5742944bdebc13bec2ccdbceba75f0bb71e48c856b0f86285878e190cd3",
"sample_count": 1840,
"first_sequence": 0,
"last_sequence": 1977,
"max_dropped": 138,
"gaps": [(511, 650, 1_390_000)],
},
"direct_usb_stall.trk": {
"sha256": "40f874b7eaa7f705524ecdd75f832e8a724252366633116ac015fc75dfd16558",
"sample_count": 864,
"first_sequence": 8,
"last_sequence": 2065,
"max_dropped": 1194,
"gaps": [(511, 1706, 11_950_000)],
},
}
for name, contract in expected.items():
@@ -175,7 +226,7 @@ class ProtocolContractTest(unittest.TestCase):
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["first_sequence"], samples[0].sequence)
self.assertEqual(contract["last_sequence"], samples[-1].sequence)
self.assertEqual(
contract["max_dropped"],
@@ -191,6 +242,21 @@ class ProtocolContractTest(unittest.TestCase):
)
)
integrity = IntegrityTracker()
for frame in frames:
integrity.observe(frame)
self.assertEqual(0, integrity.packet_gap_count)
self.assertEqual(0, integrity.packet_reset_count)
self.assertEqual(
contract["max_dropped"], integrity.sample_gap_count
)
self.assertEqual(0, integrity.sample_reset_count)
self.assertEqual(
contract["max_dropped"],
integrity.final_dropped_sample_count,
)
self.assertEqual(0, integrity.final_loop_overrun_count)
gaps = [
(
left.sequence,
+129
View File
@@ -0,0 +1,129 @@
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include "trikke_transport.h"
typedef struct {
trikke_transport_status_t begin_status;
trikke_transport_status_t poll_status;
unsigned int begin_calls;
unsigned int poll_calls;
const uint8_t *packet;
size_t packet_size;
} mock_transport_t;
static int fail(int code, const char *message)
{
fprintf(stderr, "transport fixture failure %d: %s\n", code, message);
return code;
}
static trikke_transport_status_t mock_begin(
void *context,
const uint8_t *packet,
size_t packet_size)
{
mock_transport_t *mock = context;
++mock->begin_calls;
mock->packet = packet;
mock->packet_size = packet_size;
return mock->begin_status;
}
static trikke_transport_status_t mock_poll(void *context)
{
mock_transport_t *mock = context;
++mock->poll_calls;
return mock->poll_status;
}
int main(void)
{
const uint8_t packet[] = {0x54, 0x52, 0x4B, 0x31};
mock_transport_t mock = {
.begin_status = TRIKKE_TRANSPORT_RETRY,
.poll_status = TRIKKE_TRANSPORT_PENDING,
};
const trikke_transport_t transport = {
.context = &mock,
.begin = mock_begin,
.poll = mock_poll,
};
trikke_transport_sender_t sender;
trikke_transport_sender_init(&sender);
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_RETRY ||
sender.pending || mock.begin_calls != 1 || mock.poll_calls != 0 ||
mock.packet != packet || mock.packet_size != sizeof(packet)) {
return fail(1, "zero-accept submission must remain retryable");
}
mock.begin_status = TRIKKE_TRANSPORT_PENDING;
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_PENDING ||
!sender.pending || mock.begin_calls != 2 || mock.poll_calls != 0) {
return fail(2, "accepted submission must become pending");
}
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_PENDING ||
!sender.pending || mock.begin_calls != 2 || mock.poll_calls != 1) {
return fail(3, "pending transfer must poll without resubmission");
}
mock.poll_status = TRIKKE_TRANSPORT_COMPLETE;
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_COMPLETE ||
sender.pending || mock.begin_calls != 2 || mock.poll_calls != 2) {
return fail(4, "completed transfer must return to idle");
}
mock.begin_status = TRIKKE_TRANSPORT_COMPLETE;
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_COMPLETE ||
sender.pending || mock.begin_calls != 3) {
return fail(5, "synchronous completion contract");
}
mock.begin_status = TRIKKE_TRANSPORT_PENDING;
mock.poll_status = TRIKKE_TRANSPORT_RETRY;
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_PENDING ||
trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_RETRY ||
sender.pending) {
return fail(6, "backend-confirmed safe retry must return to idle");
}
if (trikke_transport_sender_step(
NULL, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_FATAL ||
trikke_transport_sender_step(
&sender, NULL, packet, sizeof(packet)) != TRIKKE_TRANSPORT_FATAL ||
trikke_transport_sender_step(
&sender, &transport, NULL, sizeof(packet)) !=
TRIKKE_TRANSPORT_FATAL ||
trikke_transport_sender_step(
&sender, &transport, packet, 0) != TRIKKE_TRANSPORT_FATAL) {
return fail(7, "invalid arguments must fail closed");
}
mock.begin_status = (trikke_transport_status_t)99;
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_FATAL ||
sender.pending) {
return fail(8, "unknown backend status must fail closed");
}
return 0;
}