add reliable BLE telemetry transport

This commit is contained in:
Jay
2026-08-18 06:24:10 -04:00
parent 00f52ecf0f
commit 7617010d8e
26 changed files with 1499 additions and 47 deletions
+73 -6
View File
@@ -14,9 +14,11 @@ from trikke_protocol import ( # noqa: E402
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
PACKET_TYPE_METADATA,
PACKET_TYPE_SAMPLES,
PACKET_TYPE_STATUS,
StreamParser,
sample_to_csv_row,
)
from trikke_ble import BleFrameReassembler, encode_ack # noqa: E402
class ProtocolContractTest(unittest.TestCase):
@@ -79,6 +81,34 @@ class ProtocolContractTest(unittest.TestCase):
)
cls.transport_fixture_passed = True
ble_protocol_executable = Path(cls.tempdir.name) / "ble_protocol_fixture"
subprocess.run(
[
compiler,
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-I",
str(ROOT / "main"),
str(ROOT / "main" / "trikke_ble_protocol.c"),
str(ROOT / "tests" / "ble_protocol_fixture.c"),
"-o",
str(ble_protocol_executable),
],
check=True,
)
ble_protocol_fixture = subprocess.run(
[str(ble_protocol_executable)], capture_output=True
)
if ble_protocol_fixture.returncode != 0:
stderr = ble_protocol_fixture.stderr.decode(errors="replace").strip()
raise AssertionError(
"BLE protocol fixture exited "
f"{ble_protocol_fixture.returncode}: {stderr}"
)
cls.ble_protocol_fixture_passed = True
@classmethod
def tearDownClass(cls) -> None:
cls.tempdir.cleanup()
@@ -90,8 +120,8 @@ class ProtocolContractTest(unittest.TestCase):
for offset in range(0, len(stream), 7):
frames.extend(parser.feed(stream[offset : offset + 7]))
self.assertEqual(4, len(frames))
metadata_frame, sample_frame, full_frame, saturated_frame = frames
self.assertEqual(5, len(frames))
metadata_frame, sample_frame, full_frame, saturated_frame, status_frame = frames
self.assertEqual(PACKET_TYPE_METADATA, metadata_frame.packet_type)
self.assertEqual(41, metadata_frame.packet_sequence)
self.assertEqual(2, metadata_frame.dropped_sample_count)
@@ -125,6 +155,16 @@ class ProtocolContractTest(unittest.TestCase):
)
self.assertEqual(4_655_350, saturated_frame.samples[-1].timestamp_us)
self.assertEqual(PACKET_TYPE_STATUS, status_frame.packet_type)
self.assertEqual(45, status_frame.packet_sequence)
self.assertEqual(5, status_frame.status.sensor_read_failure_count)
self.assertEqual(6, status_frame.status.queue_overflow_count)
self.assertEqual(7, status_frame.status.transport_begin_retry_count)
self.assertEqual(8, status_frame.status.transport_disconnect_count)
self.assertEqual(9, status_frame.status.transport_send_failure_count)
self.assertEqual(10, status_frame.status.transport_replay_count)
self.assertEqual(11, status_frame.status.transport_invalid_ack_count)
row = sample_to_csv_row(
sample_frame.samples[0], metadata_frame.metadata, 3
)
@@ -135,6 +175,33 @@ class ProtocolContractTest(unittest.TestCase):
def test_transport_state_machine_contract(self) -> None:
self.assertTrue(self.transport_fixture_passed)
def test_ble_fragment_and_ack_contract(self) -> None:
self.assertTrue(self.ble_protocol_fixture_passed)
def test_ble_reassembly_and_replay_contract(self) -> None:
frame = self.encoded[: 36 + 48]
sequence = int.from_bytes(frame[12:16], "little")
def fragment(offset: int, size: int) -> bytes:
data = frame[offset : offset + size]
return (
sequence.to_bytes(4, "little")
+ offset.to_bytes(2, "little")
+ len(frame).to_bytes(2, "little")
+ data
)
reassembler = BleFrameReassembler()
self.assertIsNone(reassembler.feed(fragment(0, 20)))
# A replay from offset zero discards the partial attempt cleanly.
self.assertIsNone(reassembler.feed(fragment(0, 40)))
self.assertEqual(frame, reassembler.feed(fragment(40, len(frame) - 40)))
self.assertEqual(b"ACK1" + sequence.to_bytes(4, "little"), encode_ack(sequence))
self.assertEqual(0, reassembler.rejected_fragment_count)
self.assertIsNone(reassembler.feed(fragment(20, 20)))
self.assertEqual(1, reassembler.rejected_fragment_count)
def test_integrity_sequence_wrap_classification(self) -> None:
self.assertEqual(
(0, 0), IntegrityTracker._classify_sequence(0xFFFFFFFF, 0)
@@ -152,7 +219,7 @@ class ProtocolContractTest(unittest.TestCase):
frames = parser.feed(bytes(damaged) + self.encoded[first_size:])
self.assertEqual(1, parser.startup_crc_errors)
self.assertEqual(0, parser.crc_errors)
self.assertEqual(3, len(frames))
self.assertEqual(4, len(frames))
self.assertEqual(PACKET_TYPE_SAMPLES, frames[0].packet_type)
def test_crc_failure_after_sync_is_stream_error(self) -> None:
@@ -168,14 +235,14 @@ class ProtocolContractTest(unittest.TestCase):
)
self.assertEqual(0, parser.startup_crc_errors)
self.assertEqual(1, parser.crc_errors)
self.assertEqual(3, len(frames))
self.assertEqual(4, len(frames))
self.assertEqual(PACKET_TYPE_METADATA, frames[0].packet_type)
def test_trailing_partial_frame_is_observable(self) -> None:
parser = StreamParser()
frames = parser.feed(self.encoded[:-5])
self.assertEqual(3, len(frames))
self.assertEqual(36 + 2 * 20 - 5, parser.buffered_bytes)
self.assertEqual(4, len(frames))
self.assertEqual(36 + 32 - 5, parser.buffered_bytes)
def test_hardware_outage_validation_artifacts(self) -> None:
expected = {