Add reliable Android BLE ride recorder

This commit is contained in:
Jay
2026-08-20 12:40:39 -04:00
parent 3bad2275cc
commit 6e408a3048
40 changed files with 2781 additions and 120 deletions
+37
View File
@@ -66,5 +66,42 @@ int main(void)
sizeof(packet), 0, 8) != 0) {
return fail(4, "invalid input rejection");
}
const uint8_t begin[TRIKKE_BLE_BEGIN_SESSION_SIZE] = {
'B', 'G', 'N', '1', 0x08, 0x07, 0x06, 0x05,
0x04, 0x03, 0x02, 0x01,
};
uint64_t session_token = 0;
if (!trikke_ble_decode_begin_session(
begin, sizeof(begin), &session_token) ||
session_token != UINT64_C(0x0102030405060708)) {
return fail(5, "begin-session decoding");
}
uint8_t invalid_begin[TRIKKE_BLE_BEGIN_SESSION_SIZE] = {0};
memcpy(invalid_begin, begin, sizeof(begin));
invalid_begin[3] = '2';
if (trikke_ble_decode_begin_session(
invalid_begin, sizeof(invalid_begin), &session_token) ||
trikke_ble_decode_begin_session(
begin, sizeof(begin) - 1, &session_token)) {
return fail(6, "invalid begin-session rejection");
}
bool subscribed = false;
if (trikke_ble_update_subscription(true, false, &subscribed) ||
subscribed ||
!trikke_ble_update_subscription(true, true, &subscribed) ||
!subscribed) {
return fail(7, "CCCD-before-control subscription ordering");
}
subscribed = false;
if (trikke_ble_update_subscription(false, true, &subscribed) ||
subscribed ||
!trikke_ble_update_subscription(true, true, &subscribed) ||
!subscribed ||
!trikke_ble_update_subscription(false, true, &subscribed) ||
subscribed) {
return fail(8, "control-before-CCCD subscription ordering");
}
return 0;
}
+29
View File
@@ -78,6 +78,35 @@ removed before production firmware was built and flashed.
beyond the 10.24-second queue window, so sequences 1,024 through 4,021 were
intentionally lost; the single 2,998-sample gap, matching queue-overflow total,
demonstrates bounded buffer exhaustion rather than a suspended output task.
- `ride_20260820_090607.trk` — SHA-256
`c938934c0905748d2f8d8be61cff1ff446d8415b34850628654967398fd7d91f`.
This is the Android recorder acceptance capture after increasing the firmware
queue to 3,072 samples (30.72 seconds). Bluetooth was deliberately disabled
from the phone for five seconds and then restored. The completed capture
contains 2,578 consecutive packets and 19,984 consecutive samples, both
starting at sequence zero, with zero CRC/header errors, packet/sample gaps,
sensor failures, queue overflows, transport send failures, invalid ACKs,
loop overruns, or trailing bytes. Firmware status records one disconnect and
one replay. The Android recorder durably deduplicated that exact replay, as
recorded in `ride_20260820_090607.session.json` (SHA-256
`b16a8f0c213a14fd2760b6efba1c6288f3631e57a331c553893e281dce047888`),
and closed the session with `complete=true` and no error.
All 19,983 sample intervals are contiguous. Of those, 8,745 differ from
exactly 10 ms. Across all intervals, absolute deviation is 0 us median, 40 us
p95, 240 us p99, and 1,630 us maximum; among only the off-grid intervals it is
10 us median, 60 us p95, and 750 us p99. Cumulative error is -726 us over
199.83 seconds. The capture also has six explicitly flagged ADXL345 overruns;
timestamps are taken before both I2C reads and therefore understate delays
caused by preemption between the timestamp and physical sensor access.
The post-rename phone smoke test for application ID
`com.jsjdesigns.trikkerecorder` was verified live but not retained as a fixture.
It closed cleanly with 366 consecutive frames and 2,832 consecutive samples,
zero CRC/header errors, packet/sample gaps, drops, queue overflows, loop
overruns, or trailing bytes. The package-only rename did not change the Android
protocol or storage implementation, and the retained outage fixture above
remains the authoritative end-to-end acceptance artifact.
`tests/test_trikke_protocol.py` verifies the hashes, parses the captures in
fragmented chunks, and asserts these signatures so the hardware evidence remains
+25
View File
@@ -0,0 +1,25 @@
{
"schema": 1,
"captureFile": "ride_20260820_090607.trk",
"sessionTokenHex": "77f213814e3c41da",
"complete": true,
"startWallClockMs": 1787231167523,
"startElapsedRealtimeNs": 2415494841543927,
"endWallClockMs": 1787231378168,
"endElapsedRealtimeNs": 2415705485735774,
"firstFrame": {"packetSequence":0,"deviceBaseTimestampUs":7652604,"phoneElapsedRealtimeNs":2415505638698090,"phoneWallClockMs":1787231178321},
"lastFrame": {"packetSequence":2577,"deviceBaseTimestampUs":207411553,"phoneElapsedRealtimeNs":2415705468358690,"phoneWallClockMs":1787231378150},
"frames": 2578,
"samples": 19984,
"duplicateReplays": 1,
"packetGaps": 0,
"packetResets": 0,
"sampleGaps": 0,
"sampleResets": 0,
"droppedSamples": 0,
"loopOverruns": 0,
"queueOverflows": 0,
"transportDisconnects": 1,
"transportReplays": 1,
"error": null
}
Binary file not shown.
+55 -1
View File
@@ -1,4 +1,5 @@
import hashlib
import json
import shutil
import subprocess
import sys
@@ -18,7 +19,7 @@ from trikke_protocol import ( # noqa: E402
StreamParser,
sample_to_csv_row,
)
from trikke_ble import BleFrameReassembler, encode_ack # noqa: E402
from trikke_ble import BleFrameReassembler, encode_ack, encode_begin_session # noqa: E402
class ProtocolContractTest(unittest.TestCase):
@@ -197,6 +198,10 @@ class ProtocolContractTest(unittest.TestCase):
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(
b"BGN1\x08\x07\x06\x05\x04\x03\x02\x01",
encode_begin_session(0x0102030405060708),
)
self.assertEqual(0, reassembler.rejected_fragment_count)
self.assertIsNone(reassembler.feed(fragment(20, 20)))
@@ -327,6 +332,25 @@ class ProtocolContractTest(unittest.TestCase):
"transport_invalid_ack_count": 0,
},
},
"ride_20260820_090607.trk": {
"sha256": "c938934c0905748d2f8d8be61cff1ff446d8415b34850628654967398fd7d91f",
"sample_count": 19984,
"first_sequence": 0,
"last_sequence": 19983,
"max_dropped": 0,
"timing_anomalies": 8745,
"accel_overruns": 6,
"gaps": [],
"final_status": {
"sensor_read_failure_count": 0,
"queue_overflow_count": 0,
"transport_begin_retry_count": 2,
"transport_disconnect_count": 1,
"transport_send_failure_count": 0,
"transport_replay_count": 1,
"transport_invalid_ack_count": 0,
},
},
}
for name, contract in expected.items():
@@ -379,6 +403,11 @@ class ProtocolContractTest(unittest.TestCase):
contract["timing_anomalies"],
integrity.timing_anomaly_count,
)
if "accel_overruns" in contract:
self.assertEqual(
contract["accel_overruns"],
integrity.accel_overrun_count,
)
self.assertEqual(
contract["max_dropped"],
integrity.final_dropped_sample_count,
@@ -403,6 +432,31 @@ class ProtocolContractTest(unittest.TestCase):
]
self.assertEqual(contract["gaps"], gaps)
def test_android_reconnect_session_sidecar(self) -> None:
data = (
ROOT
/ "tests"
/ "fixtures"
/ "ride_20260820_090607.session.json"
).read_bytes()
self.assertEqual(
"b16a8f0c213a14fd2760b6efba1c6288f3631e57a331c553893e281dce047888",
hashlib.sha256(data).hexdigest(),
)
summary = json.loads(data)
self.assertTrue(summary["complete"])
self.assertIsNone(summary["error"])
self.assertEqual("ride_20260820_090607.trk", summary["captureFile"])
self.assertEqual(2578, summary["frames"])
self.assertEqual(19984, summary["samples"])
self.assertEqual(1, summary["duplicateReplays"])
self.assertEqual(0, summary["packetGaps"])
self.assertEqual(0, summary["sampleGaps"])
self.assertEqual(0, summary["droppedSamples"])
self.assertEqual(0, summary["queueOverflows"])
self.assertEqual(1, summary["transportDisconnects"])
self.assertEqual(1, summary["transportReplays"])
def test_exact_usb_raw_wire_evidence(self) -> None:
expected = {
"direct_usb_3c95f3d": {