Add reliable Android BLE ride recorder
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+29
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user