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
+11 -8
View File
@@ -106,15 +106,17 @@ does not by itself prove corruption or a hostile receiver.
## Buffering
Acquisition runs in a dedicated higher-priority task and writes complete samples
to a 1024-entry RAM queue. The lower-priority output task batches up to eight
records per frame. At 100 Hz this queue represents about 10.24 seconds of
Under BLE, acquisition does not start until the version-2 session handshake has
established a clean capture boundary. Once started, the dedicated
higher-priority acquisition task writes complete samples to a statically
reserved 3072-entry RAM queue. The lower-priority output task batches up to eight
records per frame. At 100 Hz this queue represents about 30.72 seconds of
decoupling when the transport reports backpressure or failure accurately. A
failed write retains and retries the same encoded packet rather than dequeuing
more samples, so the queue accumulates the outage backlog. After reconnection,
the oldest retained data is sent first. If the queue fills, acquisition drops
new samples rather than overwriting older ones; sequence gaps and the cumulative
lost-sample counter expose that permanent loss.
more samples, so the queue accumulates the outage backlog. After a same-token
reconnection, the oldest retained data is sent first. If the queue fills,
acquisition drops new samples rather than overwriting older ones; sequence gaps
and the cumulative lost-sample counter expose that permanent loss.
The shared transport state machine distinguishes three nonfatal states. `RETRY`
is valid only from initial submission: it means zero bytes were accepted and the
@@ -136,7 +138,8 @@ the endpoint accepts them. CRC and sequence checks make resulting loss visible,
but an application acknowledgement and replay window are still required to
guarantee receipt. Accordingly, USB `COMPLETE` means endpoint drain, while
reliable BLE reserves `COMPLETE` for an application ACK of the exact frame. See
`ble-transport-v1.md` for fragmentation, replay, and UUIDs.
`ble-transport-v2.md` for session establishment, fragmentation, replay, and
UUIDs.
Receivers report bytes left in an incomplete trailing frame when capture ends.
Those bytes cannot pass CRC validation and are not silently admitted as samples.
-68
View File
@@ -1,68 +0,0 @@
# Reliable BLE Transport — Version 1
BLE carries the unchanged, CRC-protected `TRK1` frames defined in
`binary-record-v1.md`. The default peripheral name is `TrikkeSensor`.
## GATT service
| Purpose | UUID | Properties |
| --- | --- | --- |
| Service | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10` | Primary service |
| Data | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11` | Notify |
| ACK | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12` | Write, write without response |
The firmware prefers a 256-byte ATT MTU, allowing the largest 196-byte `TRK1`
frame and its eight-byte BLE envelope to fit in one notification. Smaller MTUs
remain protocol-compatible; firmware sends at most eight fragments per bounded
poll, although sustained 100 Hz delivery still depends on the negotiated link.
## Data notification envelope
Every notification starts with an eight-byte little-endian envelope:
| Offset | Size | Field |
| ---: | ---: | --- |
| 0 | 4 | `TRK1` packet sequence |
| 4 | 2 | Byte offset within the complete `TRK1` frame |
| 6 | 2 | Complete `TRK1` frame size |
| 8 | remaining | Consecutive frame bytes at that offset |
Offset zero starts or restarts a frame. A receiver appends only consecutive
offsets for the same sequence and total size, then validates the complete
`TRK1` header and CRC. A malformed or missing fragment is not acknowledged.
## Application ACK and replay
After validating and persisting a frame, the receiver writes exactly eight bytes
to the ACK characteristic: ASCII `ACK1`, then the acknowledged packet sequence
as little-endian uint32. Firmware accepts an ACK only for the frame it currently
owns and only from the active subscribed connection.
`COMPLETE` is not reported to the output task until that ACK arrives. Until then:
- a one-second ACK timeout replays the frame from offset zero;
- disconnect or notification unsubscription preserves the frame;
- the next subscription replays it from offset zero;
- BLE polling returns `PENDING`, never `RETRY`, after ownership begins.
The ACK itself can be lost after the receiver persisted the frame. Receivers
therefore compare the sequence and raw bytes with their last persisted frame,
avoid writing a duplicate, and ACK the replay again. The reference
`tools/capture_ble.py` implements this ordering.
The rejected-ACK counter is diagnostic, not a pure corruption count. A valid
duplicate ACK can arrive after the output task has already completed that frame
and begun the next one; firmware then rejects and counts the now-stale write.
BLE notification success only means the fragment entered the stack. The `ACK1`
write is the end-to-end boundary. It deliberately confirms application
persistence rather than radio or ATT delivery alone.
Version 1 is an unauthenticated, single-connection prototype service. It does
not yet provide pairing, authorization, or confidentiality against a nearby
peer; those are separate from the loss/replay guarantees above. A nearby peer
can also deny availability by subscribing and never acknowledging: firmware
correctly retains and replays the owned frame, but the RAM queue eventually
fills while the legitimate receiver remains excluded. Pairing and connection
authorization are required before treating this as a hostile-environment
logger.
+119
View File
@@ -0,0 +1,119 @@
# Reliable BLE Transport — Version 2
BLE carries the unchanged, CRC-protected `TRK1` frames defined in
`binary-record-v1.md`. The default peripheral name is `TrikkeSensor`. Version 2
adds a mandatory, idempotent recording-session handshake; the data envelope and
application ACK remain unchanged from version 1.
## GATT service
| Purpose | UUID | Properties |
| --- | --- | --- |
| Service | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10` | Primary service |
| Data | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11` | Notify |
| ACK | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12` | Write, write without response |
| Control | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c13` | Write |
The firmware prefers a 256-byte ATT MTU, allowing the largest 196-byte `TRK1`
frame and its eight-byte BLE envelope to fit in one notification. Smaller MTUs
remain protocol-compatible; firmware sends at most eight fragments per bounded
poll, although sustained 100 Hz delivery still depends on the negotiated link.
## Recording-session handshake
Every new capture generates a random uint64 session token. The reference
receivers first write exactly 12 bytes to Control—ASCII `BGN1`, then that token
as little-endian uint64—and enable data notifications after that write succeeds.
Firmware also accepts the reverse order: it tracks the raw CCCD state separately
from session authorization and treats the connection as subscribed as soon as
both conditions are true. Notifications never flow before authorization.
When the token differs from the C3's active token, firmware stores it in
RTC-retained memory and performs a controlled software restart. Acquisition and
output tasks remain stopped after boot. The receiver reconnects and repeats the
same `BGN1` write; firmware recognizes the retained token, authorizes that BLE
connection, starts acquisition with empty queues and zeroed volatile counters,
and accepts the subsequent notification subscription.
The Android acceptance capture measured 10.797 seconds from the user's Start
action to the first durably persisted frame. That includes the initial control
write, controlled restart, advertising and scan latency, GATT reconnection,
same-token authorization, notification subscription, and first frame delivery.
This startup interval is expected and is not part of the recorded sensor stream.
The same token must be written on every reconnect during one recording. That
write is idempotent: it authorizes the new connection without restarting or
discarding the in-flight frame and sample backlog. A different token is an
explicit new-session boundary and deliberately discards all prior volatile
state through the controlled restart.
Firmware does not start acquisition after boot and does not honor a notification
subscription until a valid control write authorizes the connection. This avoids
pre-session queue overflow and prevents an older client from bypassing the
session boundary. The token provides idempotence, not authentication or secrecy.
RTC token recovery is intentionally accepted only after `ESP_RST_SW`. A panic,
watchdog, brownout, or power-on reset discards the token and all volatile stream
state. When the still-recording receiver reconnects and rewrites its unchanged
token, firmware treats it as a new token, performs one additional controlled
restart, and authorizes the following same-token reconnect. Packet and sample
sequences restart at zero inside the receiver's existing file, where the reset is
observable through integrity tracking. This favors a known clean state over
silently treating an uncontrolled reset as continuation of the old session.
## Data notification envelope
Every notification starts with an eight-byte little-endian envelope:
| Offset | Size | Field |
| ---: | ---: | --- |
| 0 | 4 | `TRK1` packet sequence |
| 4 | 2 | Byte offset within the complete `TRK1` frame |
| 6 | 2 | Complete `TRK1` frame size |
| 8 | remaining | Consecutive frame bytes at that offset |
Offset zero starts or restarts a frame. A receiver appends only consecutive
offsets for the same sequence and total size, then validates the complete
`TRK1` header and CRC. A malformed or missing fragment is not acknowledged.
## Application ACK and replay
After validating and persisting a frame, the receiver writes exactly eight bytes
to the ACK characteristic: ASCII `ACK1`, then the acknowledged packet sequence
as little-endian uint32. Firmware accepts an ACK only for the frame it currently
owns and only from the active subscribed connection.
`COMPLETE` is not reported to the output task until that ACK arrives. Until then:
- a one-second ACK timeout replays the frame from offset zero;
- disconnect or notification unsubscription preserves the frame;
- the next subscription, after the same-token control write, replays it from
offset zero;
- BLE polling returns `PENDING`, never `RETRY`, after ownership begins.
The ACK itself can be lost after the receiver persisted the frame. Receivers
therefore compare the sequence and raw bytes with their last persisted frame,
avoid writing a duplicate, and ACK the replay again. Both reference receivers
implement this ordering.
The rejected-ACK counter is diagnostic, not a pure corruption count. A valid
duplicate ACK can arrive after the output task has already completed that frame
and begun the next one; firmware then rejects and counts the now-stale write.
BLE notification success only means the fragment entered the stack. The `ACK1`
write is the end-to-end boundary. It deliberately confirms application
persistence rather than radio or ATT delivery alone.
The current firmware reserves a 3072-sample acquisition queue, providing 30.72
seconds of transport-outage tolerance at the nominal 100 Hz rate. This interval
includes link-loss detection, scanning, GATT reconnection, session
reauthorization, notification subscription, and replay—not merely the time a
phone's Bluetooth control is visibly off. Longer interruptions remain bounded
and detectable through sample-sequence gaps and the queue-overflow counter.
Version 2 remains an unauthenticated, single-connection prototype service. It
does not yet provide pairing, authorization, or confidentiality against a nearby
peer; those are separate from the session and replay guarantees above. A nearby
peer can also deny availability by connecting and withholding the control write,
or by subscribing and never acknowledging. Pairing and connection authorization
are required before treating this as a hostile-environment logger.