add reliable BLE telemetry transport
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "trikke_ble_protocol.h"
|
||||
#include "trikke_protocol.h"
|
||||
|
||||
static int fail(int code, const char *message)
|
||||
{
|
||||
fprintf(stderr, "BLE protocol fixture failure %d: %s\n", code, message);
|
||||
return code;
|
||||
}
|
||||
|
||||
static void put_u16_le(uint8_t *output, uint16_t value)
|
||||
{
|
||||
output[0] = (uint8_t)value;
|
||||
output[1] = (uint8_t)(value >> 8);
|
||||
}
|
||||
|
||||
static void put_u32_le(uint8_t *output, uint32_t value)
|
||||
{
|
||||
output[0] = (uint8_t)value;
|
||||
output[1] = (uint8_t)(value >> 8);
|
||||
output[2] = (uint8_t)(value >> 16);
|
||||
output[3] = (uint8_t)(value >> 24);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
uint8_t packet[TRIKKE_WIRE_HEADER_SIZE + 16] = {0};
|
||||
memcpy(packet, "TRK1", 4);
|
||||
packet[4] = TRIKKE_WIRE_VERSION;
|
||||
packet[6] = TRIKKE_WIRE_HEADER_SIZE;
|
||||
put_u16_le(packet + 10, 16);
|
||||
put_u32_le(packet + 12, 0x78563412);
|
||||
for (size_t i = TRIKKE_WIRE_HEADER_SIZE; i < sizeof(packet); ++i) {
|
||||
packet[i] = (uint8_t)i;
|
||||
}
|
||||
|
||||
uint8_t fragment[32] = {0};
|
||||
size_t size = trikke_ble_encode_fragment(
|
||||
fragment, sizeof(fragment), packet, sizeof(packet), 0, 20);
|
||||
if (size != 20 || memcmp(fragment, "\x12\x34\x56\x78\x00\x00\x34\x00", 8) != 0 ||
|
||||
memcmp(fragment + 8, packet, 12) != 0) {
|
||||
return fail(1, "first fragment envelope");
|
||||
}
|
||||
|
||||
size = trikke_ble_encode_fragment(
|
||||
fragment, sizeof(fragment), packet, sizeof(packet), 48, 20);
|
||||
if (size != 12 || fragment[4] != 48 ||
|
||||
memcmp(fragment + 8, packet + 48, 4) != 0) {
|
||||
return fail(2, "last fragment envelope");
|
||||
}
|
||||
|
||||
uint8_t ack[TRIKKE_BLE_ACK_SIZE] = {'A', 'C', 'K', '1', 0x12, 0x34, 0x56, 0x78};
|
||||
uint32_t sequence = 0;
|
||||
if (!trikke_ble_decode_ack(ack, sizeof(ack), &sequence) ||
|
||||
sequence != 0x78563412) {
|
||||
return fail(3, "ACK decoding");
|
||||
}
|
||||
ack[0] = 'N';
|
||||
if (trikke_ble_decode_ack(ack, sizeof(ack), &sequence) ||
|
||||
trikke_ble_encode_fragment(fragment, sizeof(fragment), packet,
|
||||
sizeof(packet), sizeof(packet), 20) != 0 ||
|
||||
trikke_ble_encode_fragment(fragment, sizeof(fragment), packet,
|
||||
sizeof(packet), 0, 8) != 0) {
|
||||
return fail(4, "invalid input rejection");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -88,10 +88,26 @@ int main(void)
|
||||
return fail(4, "saturated timestamp encoding or output");
|
||||
}
|
||||
|
||||
const trikke_wire_status_t status = {
|
||||
.sensor_read_failure_count = 5,
|
||||
.queue_overflow_count = 6,
|
||||
.transport_begin_retry_count = 7,
|
||||
.transport_disconnect_count = 8,
|
||||
.transport_send_failure_count = 9,
|
||||
.transport_replay_count = 10,
|
||||
.transport_invalid_ack_count = 11,
|
||||
};
|
||||
size = trikke_encode_status_packet(packet, sizeof(packet), 45, 5000000,
|
||||
11, 12, &status);
|
||||
if (size != TRIKKE_WIRE_HEADER_SIZE + TRIKKE_WIRE_STATUS_SIZE ||
|
||||
fwrite(packet, 1, size, stdout) != size) {
|
||||
return fail(5, "status 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 fail(5, "timestamp-delta boundary contract");
|
||||
return fail(6, "timestamp-delta boundary contract");
|
||||
}
|
||||
if (trikke_encode_metadata_packet(
|
||||
packet, TRIKKE_WIRE_HEADER_SIZE + TRIKKE_WIRE_METADATA_SIZE - 1,
|
||||
@@ -103,8 +119,11 @@ int main(void)
|
||||
TRIKKE_WIRE_MAX_RECORDS + 1) != 0 ||
|
||||
trikke_encode_sample_packet(packet, TRIKKE_WIRE_MAX_PACKET_SIZE - 1,
|
||||
0, 0, 0, full_packet,
|
||||
TRIKKE_WIRE_MAX_RECORDS) != 0) {
|
||||
return fail(6, "invalid argument rejection contract");
|
||||
TRIKKE_WIRE_MAX_RECORDS) != 0 ||
|
||||
trikke_encode_status_packet(
|
||||
packet, TRIKKE_WIRE_HEADER_SIZE + TRIKKE_WIRE_STATUS_SIZE - 1,
|
||||
0, 0, 0, 0, &status) != 0) {
|
||||
return fail(7, "invalid argument rejection contract");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user