harden USB telemetry transport
This commit is contained in:
Vendored
+21
-5
@@ -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
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user