diff --git a/.gitignore b/.gitignore
index e01875c..8b34ee6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,6 @@ sdkconfig.old
managed_components/
__pycache__/
.DS_Store
+android/.gradle/
+android/local.properties
+android/app/build/
diff --git a/README.md b/README.md
index f45a8ea..c37d091 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,10 @@ This milestone does four things:
4. Maps both sensors into a shared enclosure frame and carries the metadata
needed to derive calibrated readings without replacing raw data.
-The wired sensor path is proven. This milestone adds the first reliable BLE
-transport and a macOS-compatible reference capture client; phone-side storage
-remains the next consumer implementation.
+The wired sensor path and reliable BLE transport are proven. The repository now
+also contains an Android 12+ prototype recorder that stores the authoritative
+binary stream durably before acknowledging each frame. The macOS reference
+client remains available for transport diagnosis.
## Wiring
@@ -70,33 +71,59 @@ Future fusion, integration, and filtering must derive each `dt` from those
timestamps rather than assume a uniform 10 ms interval. Sensor DRDY/FIFO
acquisition remains the later refinement for reducing the jitter itself.
-The ESP32-C3 schedules nominal 100 Hz polling in a dedicated acquisition task,
-but each
-sensor has an independent internal
+The 200-second Android acceptance capture provides the current loaded-system
+characterization. Of 19,983 contiguous intervals, 8,745 (43.8%) were off-grid.
+Across all intervals, absolute deviation was 0 us median, 40 us p95, 240 us p99,
+and 1,630 us maximum; among off-grid intervals it was 10 us median, 60 us p95,
+and 750 us p99. Cumulative error was -726 us. These timestamp statistics are a
+lower bound on acquisition disturbance because firmware timestamps the poll
+before performing both I2C reads. Six ADXL345 overrun flags show that preemption
+between the timestamp and the physical read occasionally crossed a sensor sample
+boundary even though the recorded timestamp delta did not expose the full delay.
+The loss is rare (0.03%), explicitly flagged, and reinforces the planned
+data-ready/FIFO acquisition refinement.
+
+After the BLE session handshake, the ESP32-C3 schedules nominal 100 Hz polling
+in a dedicated acquisition task, but each sensor has an independent internal
sample clock. The status registers are read immediately before each XYZ read so a
consumer can distinguish a fresh sample from a repeated poll and identify gyro
overruns. Hardware data-ready interrupts and FIFO acquisition are deferred to the
later sensor-side acquisition refinement.
-Completed samples enter a 1024-record RAM queue, providing 10.24 seconds of
-transport-outage tolerance at 100 Hz when the transport reports backpressure or
-failure accurately. A failed write retains and retries its packet while this
-queue accumulates the backlog. A lower-priority output task batches up to eight
-records into versioned `TRK1` frames, isolating acquisition from brief transport
-stalls. CRC, packet and sample sequences, timestamps, and cumulative
-loss/overrun counters make permanent loss detectable by the receiver.
+Completed samples enter a statically reserved 3072-record RAM queue, providing
+30.72 seconds of transport-outage tolerance at 100 Hz when the transport reports
+backpressure or failure accurately. A failed write retains and retries its
+packet while this queue accumulates the backlog. A lower-priority output task
+batches up to eight records into versioned `TRK1` frames, isolating acquisition
+from brief transport stalls. CRC, packet and sample sequences, timestamps, and
+cumulative loss/overrun counters make permanent loss detectable by the receiver.
+
+The 30.72-second depth is based on end-to-end phone behavior rather than the
+visible Bluetooth-off interval alone. A five-second Android Bluetooth outage
+produced roughly 12.5 seconds of effective transport interruption and overflowed
+the prior 10.24-second queue by 223 samples. Repeating the same test with the
+3,072-record queue delivered all 19,984 samples contiguously; one replay was
+durably deduplicated and all loss counters remained zero. The capture and its
+completed Android sidecar are preserved under `tests/fixtures/`.
+
+The static queue occupies 96 KiB. The BLE build leaves 132,389 bytes of DRAM for
+task stacks, NimBLE runtime allocation, and future features; the 200-second
+acceptance capture proves the present configuration, but another large buffer or
+memory-heavy feature requires a fresh runtime and build-time memory review.
The default build exposes a custom NimBLE GATT service named `TrikkeSensor`.
-Each unchanged `TRK1` frame is fragmented as needed, persisted by the receiver,
-and then acknowledged by exact packet sequence. A missing ACK causes a replay;
-disconnect or subscription loss retains the same frame and restarts it from byte
-zero after reconnection. The receiver deduplicates these deliberate replays.
+Acquisition remains stopped until a valid session-token handshake establishes a
+clean capture boundary. Each unchanged `TRK1` frame is then fragmented as needed,
+persisted by the receiver, and acknowledged by exact packet sequence. A missing
+ACK causes a replay; disconnect or subscription loss retains the same frame and
+restarts it from byte zero after a same-token reconnection. The receiver
+deduplicates these deliberate replays.
The preserved USB telemetry option uses ESP-IDF's interrupt-driven USB
Serial/JTAG driver behind
a transport-neutral state machine. A complete frame is submitted atomically to
the driver ring and remains pending across bounded drain timeouts; firmware does
-not resubmit it ambiguously or dequeue another frame. The 1024-sample queue
+not resubmit it ambiguously or dequeue another frame. The 3072-sample queue
therefore also protects a connected endpoint that temporarily stops draining.
USB drain confirms that bytes left the device endpoint, not that the capture
@@ -141,7 +168,29 @@ python3 tools/capture_ble.py
The client scans for `TrikkeSensor`, stores only complete CRC-valid `TRK1`
frames, flushes the binary and CSV outputs, and only then writes the application
-ACK. See [the BLE transport specification](docs/ble-transport-v1.md).
+ACK. See [the BLE transport specification](docs/ble-transport-v2.md).
+
+## Android recorder
+
+The `android/` project is the prototype ride recorder. Each new recording sends
+an idempotent session token that gives the C3 a repeatable software session
+boundary with empty queues, reset counters, and acquisition starting only after
+the phone is ready. Reconnects reuse that token and preserve the outage backlog.
+The app reassembles and validates complete frames, appends each new frame to
+app-private storage, calls `fsync`, and only then sends `ACK1`. Exact replays are
+acknowledged without being appended twice. Recording runs in a connected-device
+foreground service with a partial wake lock so it can continue while the screen
+is off.
+
+Build its debug APK with:
+
+```sh
+cd android
+./gradlew testDebugUnitTest lintDebug assembleDebug
+```
+
+See [the Android recorder guide](android/README.md) for installation, capture,
+export, and the first coordinated phone test.
## TRK1 output and USB validation
diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..2978a0a
--- /dev/null
+++ b/android/README.md
@@ -0,0 +1,124 @@
+# Trikke Recorder for Android
+
+Prototype v0.2 records the existing reliable BLE transport to an authoritative
+`.trk` file on an Android phone. It deliberately does not filter, fuse, convert,
+or upload telemetry.
+
+The permanent Android application ID and Kotlin namespace are
+`com.jsjdesigns.trikkerecorder`.
+
+## Platform and behavior
+
+- Android 12 (API 31) or newer
+- BLE central connection to the `TrikkeSensor` service
+- User-started connected-device foreground service
+- Partial wake lock during an active recording
+- App-private capture storage with explicit export through Android's document UI
+- A `.session.json` sidecar containing phone wall/monotonic clock anchors and
+ final integrity counters
+
+Every notification is reassembled using its packet sequence, offset, and total
+size. A complete frame must also pass the TRK1 shape and CRC checks. For a new
+frame the recorder then performs this ordering:
+
+1. Append the unchanged frame to the `.trk` file.
+2. Flush the stream and synchronize its file descriptor.
+3. Update in-memory integrity state.
+4. Write the exact `ACK1` packet sequence to the C3.
+
+If the ACK is lost, the firmware replays the frame. An exact replay of the last
+persisted packet is not appended again, but it is acknowledged again. The app
+refuses to acknowledge a CRC-invalid frame or the same sequence carrying
+different bytes.
+
+At Start, the app generates a random session token and sends it to the Control
+characteristic before subscribing. A new token makes the C3 perform one
+controlled software restart. The app reconnects with the same token, after which
+the C3 starts acquisition with empty queues and zeroed counters. Temporary BLE
+reconnects during that recording reuse the token and therefore preserve queued
+samples instead of resetting the session.
+
+The validated phone/C3 pair takes about 10.8 seconds from tapping Start to the
+first persisted frame because a new session deliberately includes a controlled
+C3 restart and two connection passes. A panic, watchdog, brownout, or power-on
+reset during recording invalidates the retained token; recovery adds another
+controlled restart, and the resulting packet/sample sequence reset remains
+visible in the sidecar integrity counters.
+
+## Build and install
+
+Android Studio is the easiest route: open the `android/` directory, allow the
+Gradle sync, select the phone, and run the `app` configuration.
+
+The command-line equivalent on this Mac is:
+
+```sh
+cd android
+export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
+export ANDROID_HOME="$HOME/Library/Android/sdk"
+./gradlew testDebugUnitTest lintDebug assembleDebug
+"$ANDROID_HOME/platform-tools/adb" install -r app/build/outputs/apk/debug/app-debug.apk
+```
+
+The tests exercise the Android parser against the real
+`ble_mtu_race_4bf00eb.trk` hardware fixture as well as fragment replay, malformed
+ordering, CRC rejection, unsigned sequence wrap, durable append, and replay
+deduplication.
+
+## Record and export
+
+1. Power the sensor and open **Trikke Recorder**.
+2. Tap **Start recording** and grant Nearby Devices and notification permission.
+3. Wait for `Recording: Connected and subscribed` and confirm that the sample
+ count is increasing.
+4. The activity may be left, the screen may be locked, and the phone may be put
+ in a pocket. Keep the persistent recording notification active.
+5. Reopen the app and tap **Stop and close safely**.
+6. After the status reaches `Stopped`, tap **Export last .trk** and select a
+ destination.
+
+The app-private `.session.json` starts with `complete: false`. A normal Stop
+closes and synchronizes the binary file, then rewrites the sidecar with
+`complete: true`, final counts, and first/last phone-to-device clock anchors. If
+Android or the user force-stops the process, every previously acknowledged frame
+remains in the `.trk` file, while the incomplete sidecar makes the abnormal end
+visible.
+
+Phone receipt time is only an alignment anchor; BLE delivery latency means it is
+not the sensor's physical sample time. Analysis must continue to use the device
+timestamps carried by each TRK1 frame.
+
+## First coordinated acceptance test
+
+Do this before a ride:
+
+1. Record for two minutes with the enclosure flat and still.
+2. Lock the phone for at least one minute and verify the sample counter resumes
+ visibly when the app is reopened.
+3. Cause a roughly three-second outage by switching Bluetooth off, switch it
+ back on, and wait for `Recording` again.
+4. Stop and export the `.trk` file.
+5. Decode it from the repository root:
+
+ ```sh
+ python3 tools/decode_binary.py ride_YYYYMMDD_HHMMSS.trk ride.csv
+ ```
+
+Acceptance requires a valid complete decode, no unexplained packet or sample
+gaps, and no queue overflow for the short interruption. Duplicate replays and
+the firmware disconnect/replay counters may increase and are expected.
+
+After that passes, repeat for 15–30 minutes with the screen locked and include
+several short real-world range/interference interruptions.
+
+## Prototype limitations
+
+- The BLE service is still unauthenticated and single-connection, as documented
+ in `docs/ble-transport-v2.md`.
+- The recorder supports one active session and one known sensor.
+- Only the binary capture is exported from the UI in this pass. The sidecar is
+ retained app-private for diagnosis.
+- A phone force-stop cannot run cleanup code. The persisted binary prefix remains
+ useful, but the session is intentionally marked incomplete.
+- No GPS, Samsung Health import, CSV rendering, or live motion analysis is in
+ this milestone.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..92733db
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,49 @@
+plugins {
+ id("com.android.application")
+}
+
+android {
+ namespace = "com.jsjdesigns.trikkerecorder"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.jsjdesigns.trikkerecorder"
+ minSdk = 31
+ targetSdk = 36
+ versionCode = 2
+ versionName = "0.2.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ testOptions {
+ unitTests.all {
+ it.useJUnit()
+ }
+ }
+
+ sourceSets {
+ getByName("test").resources.directories.add(
+ "../../tests/fixtures",
+ )
+ }
+}
+
+dependencies {
+ testImplementation("junit:junit:4.13.2")
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..d6417b6
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Prototype v0 keeps release builds unobfuscated for diagnosability.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..cf0ff00
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/BleTransport.kt b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/BleTransport.kt
new file mode 100644
index 0000000..04b07be
--- /dev/null
+++ b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/BleTransport.kt
@@ -0,0 +1,354 @@
+package com.jsjdesigns.trikkerecorder
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.bluetooth.BluetoothStatusCodes
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanFilter
+import android.bluetooth.le.ScanResult
+import android.bluetooth.le.ScanSettings
+import android.content.Context
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.ParcelUuid
+import android.os.SystemClock
+import com.jsjdesigns.trikkerecorder.protocol.BleFrameReassembler
+import java.util.UUID
+
+@SuppressLint("MissingPermission")
+class BleTransport(
+ context: Context,
+ private val listener: Listener,
+) {
+ interface Listener {
+ fun onTransportState(state: String, detail: String)
+ fun onFragment(fragment: ByteArray, connectionEpoch: Long, elapsedNs: Long, wallMs: Long)
+ }
+
+ private val appContext = context.applicationContext
+ private val handler = Handler(Looper.getMainLooper())
+ private val adapter: BluetoothAdapter? =
+ appContext.getSystemService(BluetoothManager::class.java)?.adapter
+ private var scannerCallback: ScanCallback? = null
+ private var gatt: BluetoothGatt? = null
+ private var ackCharacteristic: BluetoothGattCharacteristic? = null
+ private var controlCharacteristic: BluetoothGattCharacteristic? = null
+ private var running = false
+ private var sessionToken = 0L
+ private var connectionEpoch = 0L
+ private var readyEpoch = -1L
+
+ private val scanTimeout = Runnable {
+ stopScan()
+ if (running) {
+ listener.onTransportState("Scanning", "TrikkeSensor not seen; scanning again")
+ handler.postDelayed(::beginScan, SCAN_PAUSE_MS)
+ }
+ }
+
+ fun start(sessionToken: Long) {
+ if (running) return
+ this.sessionToken = sessionToken
+ running = true
+ beginScan()
+ }
+
+ fun stop() {
+ running = false
+ handler.removeCallbacksAndMessages(null)
+ stopScan()
+ readyEpoch = -1L
+ ackCharacteristic = null
+ controlCharacteristic = null
+ gatt?.disconnect()
+ gatt?.close()
+ gatt = null
+ }
+
+ fun acknowledge(packetSequence: Long, epoch: Long) {
+ handler.post {
+ val activeGatt = gatt
+ val characteristic = ackCharacteristic
+ if (!running || epoch != readyEpoch || activeGatt == null || characteristic == null) {
+ return@post
+ }
+ val ack = BleFrameReassembler.encodeAck(packetSequence)
+ val accepted = if (Build.VERSION.SDK_INT >= 33) {
+ activeGatt.writeCharacteristic(
+ characteristic,
+ ack,
+ BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT,
+ ) == BluetoothStatusCodes.SUCCESS
+ } else {
+ @Suppress("DEPRECATION")
+ characteristic.setValue(ack)
+ @Suppress("DEPRECATION")
+ characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
+ @Suppress("DEPRECATION")
+ activeGatt.writeCharacteristic(characteristic)
+ }
+ if (!accepted) {
+ listener.onTransportState("Recording", "ACK enqueue delayed; awaiting replay")
+ }
+ }
+ }
+
+ private fun beginScan() {
+ if (!running || scannerCallback != null || gatt != null) return
+ val scanner = adapter?.bluetoothLeScanner
+ if (adapter?.isEnabled != true || scanner == null) {
+ listener.onTransportState("Waiting", "Bluetooth is off")
+ handler.postDelayed(::beginScan, RETRY_MS)
+ return
+ }
+
+ val callback = object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ if (!running || scannerCallback !== this) return
+ stopScan()
+ connect(result)
+ }
+
+ override fun onScanFailed(errorCode: Int) {
+ if (scannerCallback === this) scannerCallback = null
+ handler.removeCallbacks(scanTimeout)
+ listener.onTransportState("Waiting", "BLE scan failed ($errorCode); retrying")
+ if (running) handler.postDelayed(::beginScan, RETRY_MS)
+ }
+ }
+ scannerCallback = callback
+ val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
+ val settings = ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .build()
+ try {
+ scanner.startScan(listOf(filter), settings, callback)
+ listener.onTransportState("Scanning", "Looking for TrikkeSensor")
+ handler.postDelayed(scanTimeout, SCAN_WINDOW_MS)
+ } catch (error: RuntimeException) {
+ scannerCallback = null
+ listener.onTransportState("Waiting", "BLE scan unavailable: ${error.message}")
+ if (running) handler.postDelayed(::beginScan, RETRY_MS)
+ }
+ }
+
+ private fun stopScan() {
+ handler.removeCallbacks(scanTimeout)
+ val callback = scannerCallback ?: return
+ scannerCallback = null
+ try {
+ adapter?.bluetoothLeScanner?.stopScan(callback)
+ } catch (_: RuntimeException) {
+ // Bluetooth may have been switched off while the scan was active.
+ }
+ }
+
+ private fun connect(result: ScanResult) {
+ if (!running) return
+ connectionEpoch++
+ val epoch = connectionEpoch
+ listener.onTransportState("Connecting", result.device.address)
+ gatt = result.device.connectGatt(
+ appContext,
+ false,
+ callback(epoch),
+ BluetoothDevice.TRANSPORT_LE,
+ )
+ if (gatt == null) restart("Connection could not be started")
+ }
+
+ private fun callback(epoch: Long) = object : BluetoothGattCallback() {
+ override fun onConnectionStateChange(callbackGatt: BluetoothGatt, status: Int, newState: Int) {
+ handler.post {
+ if (callbackGatt !== gatt || epoch != connectionEpoch) {
+ callbackGatt.close()
+ return@post
+ }
+ if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) {
+ listener.onTransportState("Connecting", "Discovering telemetry service")
+ callbackGatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
+ if (!callbackGatt.discoverServices()) restart("Service discovery did not start")
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED || status != BluetoothGatt.GATT_SUCCESS) {
+ restart("Disconnected (GATT $status)")
+ }
+ }
+ }
+
+ override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
+ handler.post {
+ if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ restart("Service discovery failed ($status)")
+ return@post
+ }
+ val service = callbackGatt.getService(SERVICE_UUID)
+ val data = service?.getCharacteristic(DATA_UUID)
+ ackCharacteristic = service?.getCharacteristic(ACK_UUID)
+ controlCharacteristic = service?.getCharacteristic(CONTROL_UUID)
+ if (data == null || ackCharacteristic == null || controlCharacteristic == null) {
+ restart("Telemetry characteristics are missing")
+ return@post
+ }
+ if (!callbackGatt.requestMtu(PREFERRED_MTU)) beginSession(callbackGatt, epoch)
+ }
+ }
+
+ override fun onMtuChanged(callbackGatt: BluetoothGatt, mtu: Int, status: Int) {
+ handler.post {
+ if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
+ beginSession(callbackGatt, epoch)
+ }
+ }
+
+ @Deprecated("Used through Android 12")
+ override fun onCharacteristicChanged(
+ callbackGatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ ) {
+ @Suppress("DEPRECATION")
+ deliver(callbackGatt, characteristic, characteristic.value?.copyOf() ?: return, epoch)
+ }
+
+ override fun onCharacteristicChanged(
+ callbackGatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray,
+ ) {
+ deliver(callbackGatt, characteristic, value.copyOf(), epoch)
+ }
+
+ override fun onDescriptorWrite(callbackGatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
+ handler.post {
+ if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
+ if (descriptor.uuid != CCCD_UUID || status != BluetoothGatt.GATT_SUCCESS) {
+ restart("Notification subscription failed ($status)")
+ return@post
+ }
+ readyEpoch = epoch
+ listener.onTransportState("Recording", "Connected and subscribed")
+ }
+ }
+
+ override fun onCharacteristicWrite(
+ callbackGatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ status: Int,
+ ) {
+ handler.post {
+ if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
+ if (characteristic.uuid == CONTROL_UUID) {
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ restart("Session preparation failed ($status)")
+ return@post
+ }
+ val data = callbackGatt.getService(SERVICE_UUID)?.getCharacteristic(DATA_UUID)
+ if (data == null) restart("Data characteristic vanished")
+ else enableNotifications(callbackGatt, data, epoch)
+ } else if (characteristic.uuid == ACK_UUID && status != BluetoothGatt.GATT_SUCCESS) {
+ listener.onTransportState("Recording", "ACK write failed ($status); awaiting replay")
+ }
+ }
+ }
+ }
+
+ private fun deliver(
+ callbackGatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray,
+ epoch: Long,
+ ) {
+ val elapsedNs = SystemClock.elapsedRealtimeNanos()
+ val wallMs = System.currentTimeMillis()
+ handler.post {
+ if (callbackGatt !== gatt || epoch != readyEpoch || characteristic.uuid != DATA_UUID) return@post
+ listener.onFragment(value, epoch, elapsedNs, wallMs)
+ }
+ }
+
+ private fun enableNotifications(
+ callbackGatt: BluetoothGatt,
+ data: BluetoothGattCharacteristic,
+ epoch: Long,
+ ) {
+ if (callbackGatt !== gatt || epoch != connectionEpoch) return
+ if (!callbackGatt.setCharacteristicNotification(data, true)) {
+ restart("Local notification registration failed")
+ return
+ }
+ val descriptor = data.getDescriptor(CCCD_UUID)
+ if (descriptor == null) {
+ restart("Notification descriptor is missing")
+ return
+ }
+ val accepted = if (Build.VERSION.SDK_INT >= 33) {
+ callbackGatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) ==
+ BluetoothStatusCodes.SUCCESS
+ } else {
+ @Suppress("DEPRECATION")
+ descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
+ @Suppress("DEPRECATION")
+ callbackGatt.writeDescriptor(descriptor)
+ }
+ if (!accepted) restart("Notification subscription did not start")
+ }
+
+ private fun beginSession(callbackGatt: BluetoothGatt, epoch: Long) {
+ if (callbackGatt !== gatt || epoch != connectionEpoch) return
+ val control = controlCharacteristic
+ if (control == null) {
+ restart("Session control characteristic is missing")
+ return
+ }
+ listener.onTransportState("Preparing", "Establishing a clean recording session")
+ val command = BleFrameReassembler.encodeBeginSession(sessionToken)
+ val accepted = if (Build.VERSION.SDK_INT >= 33) {
+ callbackGatt.writeCharacteristic(
+ control,
+ command,
+ BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT,
+ ) == BluetoothStatusCodes.SUCCESS
+ } else {
+ @Suppress("DEPRECATION")
+ control.setValue(command)
+ @Suppress("DEPRECATION")
+ control.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
+ @Suppress("DEPRECATION")
+ callbackGatt.writeCharacteristic(control)
+ }
+ if (!accepted) restart("Session preparation did not start")
+ }
+
+ private fun restart(reason: String) {
+ readyEpoch = -1L
+ ackCharacteristic = null
+ controlCharacteristic = null
+ val oldGatt = gatt
+ gatt = null
+ oldGatt?.disconnect()
+ oldGatt?.close()
+ if (running) {
+ listener.onTransportState("Reconnecting", reason)
+ handler.postDelayed(::beginScan, RETRY_MS)
+ }
+ }
+
+ companion object {
+ val SERVICE_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10")
+ val DATA_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11")
+ val ACK_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12")
+ val CONTROL_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c13")
+ private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+ private const val PREFERRED_MTU = 256
+ private const val SCAN_WINDOW_MS = 10_000L
+ private const val SCAN_PAUSE_MS = 500L
+ private const val RETRY_MS = 1_000L
+ }
+}
diff --git a/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/MainActivity.kt b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/MainActivity.kt
new file mode 100644
index 0000000..276ffcb
--- /dev/null
+++ b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/MainActivity.kt
@@ -0,0 +1,256 @@
+package com.jsjdesigns.trikkerecorder
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.bluetooth.BluetoothAdapter
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.graphics.Typeface
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.view.Gravity
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
+import java.io.File
+import java.util.Locale
+
+class MainActivity : Activity() {
+ private lateinit var phaseView: TextView
+ private lateinit var detailView: TextView
+ private lateinit var countersView: TextView
+ private lateinit var recordButton: Button
+ private lateinit var exportButton: Button
+ private var snapshot = RecorderSnapshot()
+ private var pendingStart = false
+ private var exportPath: String? = null
+
+ private val stateReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action != TelemetryService.ACTION_STATE) return
+ snapshot = RecorderSnapshot(
+ active = intent.getBooleanExtra(TelemetryService.EXTRA_ACTIVE, false),
+ phase = intent.getStringExtra(TelemetryService.EXTRA_PHASE) ?: "Idle",
+ detail = intent.getStringExtra(TelemetryService.EXTRA_DETAIL) ?: "",
+ capturePath = intent.getStringExtra(TelemetryService.EXTRA_CAPTURE_PATH),
+ durationSeconds = intent.getLongExtra(TelemetryService.EXTRA_DURATION_SECONDS, 0),
+ frameCount = intent.getLongExtra(TelemetryService.EXTRA_FRAMES, 0),
+ sampleCount = intent.getLongExtra(TelemetryService.EXTRA_SAMPLES, 0),
+ duplicateCount = intent.getLongExtra(TelemetryService.EXTRA_DUPLICATES, 0),
+ rejectedFragments = intent.getLongExtra(TelemetryService.EXTRA_REJECTED_FRAGMENTS, 0),
+ invalidFrames = intent.getLongExtra(TelemetryService.EXTRA_INVALID_FRAMES, 0),
+ packetGaps = intent.getLongExtra(TelemetryService.EXTRA_PACKET_GAPS, 0),
+ sampleGaps = intent.getLongExtra(TelemetryService.EXTRA_SAMPLE_GAPS, 0),
+ droppedSamples = intent.getLongExtra(TelemetryService.EXTRA_DROPPED, 0),
+ queueOverflows = intent.getLongExtra(TelemetryService.EXTRA_QUEUE_OVERFLOWS, -1)
+ .takeIf { it >= 0 },
+ )
+ render()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ buildUi()
+ snapshot = TelemetryService.currentSnapshot
+ if (snapshot.capturePath == null) {
+ val previous = getSharedPreferences(TelemetryService.PREFERENCES, MODE_PRIVATE)
+ .getString(TelemetryService.LAST_CAPTURE, null)
+ if (previous != null) snapshot = snapshot.copy(capturePath = previous)
+ }
+ render()
+ }
+
+ @SuppressLint("UnspecifiedRegisterReceiverFlag")
+ override fun onStart() {
+ super.onStart()
+ val filter = IntentFilter(TelemetryService.ACTION_STATE)
+ if (Build.VERSION.SDK_INT >= 33) registerReceiver(stateReceiver, filter, RECEIVER_NOT_EXPORTED)
+ else @Suppress("DEPRECATION") registerReceiver(stateReceiver, filter)
+ }
+
+ override fun onStop() {
+ unregisterReceiver(stateReceiver)
+ super.onStop()
+ }
+
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray,
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ if (requestCode == REQUEST_PERMISSIONS && hasBluetoothPermissions()) ensureBluetoothAndStart()
+ else if (requestCode == REQUEST_PERMISSIONS) showLocalMessage("Nearby devices permission is required")
+ }
+
+ @Deprecated("Used for the platform Bluetooth-enable and document-create flows")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ when (requestCode) {
+ REQUEST_ENABLE_BLUETOOTH -> {
+ if (resultCode == RESULT_OK && pendingStart) startRecorder()
+ else showLocalMessage("Bluetooth must be enabled to record")
+ pendingStart = false
+ }
+ REQUEST_EXPORT -> if (resultCode == RESULT_OK) {
+ val destination = data?.data ?: return
+ exportCapture(destination)
+ }
+ }
+ }
+
+ private fun buildUi() {
+ val density = resources.displayMetrics.density
+ val padding = (24 * density).toInt()
+ val content = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER_HORIZONTAL
+ setPadding(padding, padding, padding, padding)
+ }
+ phaseView = TextView(this).apply {
+ textSize = 30f
+ setTypeface(typeface, Typeface.BOLD)
+ }
+ detailView = TextView(this).apply {
+ textSize = 17f
+ gravity = Gravity.CENTER_HORIZONTAL
+ setPadding(0, padding / 2, 0, padding)
+ }
+ countersView = TextView(this).apply {
+ textSize = 17f
+ typeface = Typeface.MONOSPACE
+ setLineSpacing(0f, 1.25f)
+ }
+ recordButton = Button(this).apply {
+ setOnClickListener {
+ if (snapshot.active) stopRecorder() else requestStart()
+ }
+ }
+ exportButton = Button(this).apply {
+ text = getString(R.string.export_capture)
+ setOnClickListener { chooseExportDestination() }
+ }
+ content.addView(phaseView, matchWrap())
+ content.addView(detailView, matchWrap())
+ content.addView(countersView, matchWrap())
+ content.addView(recordButton, matchWrap(topMargin = padding))
+ content.addView(exportButton, matchWrap(topMargin = padding / 2))
+ setContentView(ScrollView(this).apply { addView(content) })
+ }
+
+ private fun matchWrap(topMargin: Int = 0) = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply { this.topMargin = topMargin }
+
+ private fun render() {
+ phaseView.text = snapshot.phase
+ detailView.text = snapshot.detail
+ countersView.text = buildString {
+ appendLine("duration ${formatDuration(snapshot.durationSeconds)}")
+ appendLine("frames ${snapshot.frameCount}")
+ appendLine("samples ${snapshot.sampleCount}")
+ appendLine("duplicate replays ${snapshot.duplicateCount}")
+ appendLine("fragment rejects ${snapshot.rejectedFragments}")
+ appendLine("invalid frames ${snapshot.invalidFrames}")
+ appendLine("packet gaps ${snapshot.packetGaps}")
+ appendLine("sample gaps ${snapshot.sampleGaps}")
+ appendLine("device drops ${snapshot.droppedSamples}")
+ append("queue overflows ${snapshot.queueOverflows ?: "waiting for status"}")
+ }
+ recordButton.text = if (snapshot.active) "Stop and close safely" else "Start recording"
+ recordButton.isEnabled = snapshot.phase != "Stopping"
+ exportButton.isEnabled = !snapshot.active && snapshot.capturePath?.let(::File)?.isFile == true
+ }
+
+ private fun requestStart() {
+ if (!hasBluetoothPermissions()) {
+ val permissions = mutableListOf(
+ Manifest.permission.BLUETOOTH_SCAN,
+ Manifest.permission.BLUETOOTH_CONNECT,
+ )
+ if (Build.VERSION.SDK_INT >= 33) permissions += Manifest.permission.POST_NOTIFICATIONS
+ requestPermissions(permissions.toTypedArray(), REQUEST_PERMISSIONS)
+ return
+ }
+ ensureBluetoothAndStart()
+ }
+
+ private fun hasBluetoothPermissions(): Boolean =
+ checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
+ checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
+
+ @SuppressLint("MissingPermission")
+ private fun ensureBluetoothAndStart() {
+ val adapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter
+ if (adapter?.isEnabled != true) {
+ pendingStart = true
+ @Suppress("DEPRECATION")
+ startActivityForResult(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), REQUEST_ENABLE_BLUETOOTH)
+ } else {
+ startRecorder()
+ }
+ }
+
+ private fun startRecorder() {
+ startForegroundService(Intent(this, TelemetryService::class.java).setAction(TelemetryService.ACTION_START))
+ }
+
+ private fun stopRecorder() {
+ startService(Intent(this, TelemetryService::class.java).setAction(TelemetryService.ACTION_STOP))
+ }
+
+ private fun chooseExportDestination() {
+ val source = snapshot.capturePath?.let(::File) ?: return
+ exportPath = source.absolutePath
+ @Suppress("DEPRECATION")
+ startActivityForResult(
+ Intent(Intent.ACTION_CREATE_DOCUMENT)
+ .addCategory(Intent.CATEGORY_OPENABLE)
+ .setType("application/octet-stream")
+ .putExtra(Intent.EXTRA_TITLE, source.name),
+ REQUEST_EXPORT,
+ )
+ }
+
+ private fun exportCapture(destination: Uri) {
+ val source = exportPath?.let(::File) ?: return
+ Thread {
+ try {
+ contentResolver.openOutputStream(destination, "wt")!!.use { output ->
+ source.inputStream().use { input -> input.copyTo(output) }
+ output.flush()
+ }
+ runOnUiThread { showLocalMessage("Exported ${source.name}") }
+ } catch (error: Exception) {
+ runOnUiThread { showLocalMessage("Export failed: ${error.message}") }
+ }
+ }.start()
+ }
+
+ private fun showLocalMessage(message: String) {
+ detailView.text = message
+ }
+
+ private fun formatDuration(seconds: Long): String = String.format(
+ Locale.US,
+ "%02d:%02d:%02d",
+ seconds / 3_600,
+ (seconds / 60) % 60,
+ seconds % 60,
+ )
+
+ companion object {
+ private const val REQUEST_PERMISSIONS = 20
+ private const val REQUEST_ENABLE_BLUETOOTH = 21
+ private const val REQUEST_EXPORT = 22
+ }
+}
diff --git a/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/TelemetryService.kt b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/TelemetryService.kt
new file mode 100644
index 0000000..2cac617
--- /dev/null
+++ b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/TelemetryService.kt
@@ -0,0 +1,317 @@
+package com.jsjdesigns.trikkerecorder
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import android.os.PowerManager
+import android.os.SystemClock
+import com.jsjdesigns.trikkerecorder.protocol.BleFrameReassembler
+import com.jsjdesigns.trikkerecorder.protocol.TrkProtocol
+import com.jsjdesigns.trikkerecorder.storage.CommitResult
+import com.jsjdesigns.trikkerecorder.storage.SessionRecorder
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.security.SecureRandom
+import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicBoolean
+
+data class RecorderSnapshot(
+ val active: Boolean = false,
+ val phase: String = "Idle",
+ val detail: String = "Ready",
+ val capturePath: String? = null,
+ val durationSeconds: Long = 0,
+ val frameCount: Long = 0,
+ val sampleCount: Long = 0,
+ val duplicateCount: Long = 0,
+ val rejectedFragments: Long = 0,
+ val invalidFrames: Long = 0,
+ val packetGaps: Long = 0,
+ val sampleGaps: Long = 0,
+ val droppedSamples: Long = 0,
+ val queueOverflows: Long? = null,
+)
+
+class TelemetryService : Service(), BleTransport.Listener {
+ private val processor = Executors.newSingleThreadExecutor()
+ private val stopping = AtomicBoolean(false)
+ private val reassembler = BleFrameReassembler()
+ private lateinit var transport: BleTransport
+ private var recorder: SessionRecorder? = null
+ private var phase = "Idle"
+ private var detail = "Ready"
+ private var invalidFrames = 0L
+ private var lastPublishMs = 0L
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var recordingStartElapsedMs = 0L
+ private var recordingEndElapsedMs = 0L
+ private var sessionToken = 0L
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ transport = BleTransport(this, this)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_STOP -> finishRecording(null)
+ else -> startRecording()
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() {
+ transport.stop()
+ releaseWakeLock()
+ if (!stopping.get()) {
+ recorder?.close(System.currentTimeMillis(), SystemClock.elapsedRealtimeNanos(), "Service destroyed")
+ }
+ processor.shutdown()
+ super.onDestroy()
+ }
+
+ override fun onTransportState(state: String, detail: String) {
+ this.phase = state
+ this.detail = detail
+ publish(force = true)
+ }
+
+ override fun onFragment(fragment: ByteArray, connectionEpoch: Long, elapsedNs: Long, wallMs: Long) {
+ processor.execute {
+ try {
+ val raw = reassembler.feed(fragment) ?: run {
+ publish()
+ return@execute
+ }
+ val frame = TrkProtocol.parse(raw)
+ if (frame == null) {
+ invalidFrames++
+ publish(force = true)
+ return@execute
+ }
+ val activeRecorder = recorder ?: return@execute
+ when (activeRecorder.commit(frame, elapsedNs, wallMs)) {
+ CommitResult.PERSISTED,
+ CommitResult.DUPLICATE,
+ -> transport.acknowledge(frame.packetSequence, connectionEpoch)
+ CommitResult.CONFLICT -> fatal("Packet sequence replayed with different bytes")
+ }
+ publish()
+ } catch (error: Exception) {
+ fatal("Storage failure: ${error.message ?: error.javaClass.simpleName}")
+ }
+ }
+ }
+
+ private fun startRecording() {
+ if (recorder != null || stopping.get()) return
+ phase = "Starting"
+ detail = "Opening capture"
+ recordingStartElapsedMs = SystemClock.elapsedRealtime()
+ recordingEndElapsedMs = 0L
+ startForeground(NOTIFICATION_ID, notification(snapshot()))
+ acquireWakeLock()
+ val startWallMs = System.currentTimeMillis()
+ sessionToken = SecureRandom().nextLong()
+ val stem = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date(startWallMs))
+ val directory = File(filesDir, "captures")
+ try {
+ recorder = SessionRecorder(
+ directory = directory,
+ stem = "ride_$stem",
+ startWallClockMs = startWallMs,
+ startElapsedRealtimeNs = SystemClock.elapsedRealtimeNanos(),
+ sessionToken = sessionToken,
+ )
+ } catch (error: Exception) {
+ phase = "Error"
+ detail = "Cannot open capture: ${error.message}"
+ releaseWakeLock()
+ publish(force = true)
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ return
+ }
+ detail = "Capture file opened"
+ publish(force = true)
+ transport.start(sessionToken)
+ }
+
+ private fun fatal(message: String) {
+ mainExecutor.execute { finishRecording(message) }
+ }
+
+ private fun finishRecording(error: String?) {
+ if (recorder == null || !stopping.compareAndSet(false, true)) return
+ phase = "Stopping"
+ detail = error ?: "Closing capture"
+ recordingEndElapsedMs = SystemClock.elapsedRealtime()
+ transport.stop()
+ publish(force = true)
+ processor.execute {
+ val activeRecorder = recorder ?: return@execute
+ try {
+ activeRecorder.close(
+ endWallClockMs = System.currentTimeMillis(),
+ endElapsedRealtimeNs = SystemClock.elapsedRealtimeNanos(),
+ error = error,
+ )
+ getSharedPreferences(PREFERENCES, MODE_PRIVATE).edit()
+ .putString(LAST_CAPTURE, activeRecorder.captureFile.absolutePath)
+ .apply()
+ phase = if (error == null) "Stopped" else "Error"
+ detail = error ?: "Capture safely closed"
+ } catch (closeError: Exception) {
+ phase = "Error"
+ detail = "Could not close capture: ${closeError.message}"
+ }
+ publish(force = true)
+ mainExecutor.execute {
+ releaseWakeLock()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ }
+ }
+
+ @SuppressLint("WakelockTimeout")
+ private fun acquireWakeLock() {
+ if (wakeLock?.isHeld == true) return
+ wakeLock = getSystemService(PowerManager::class.java)
+ .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TrikkeRecorder:RideRecording")
+ .apply {
+ setReferenceCounted(false)
+ acquire()
+ }
+ }
+
+ private fun releaseWakeLock() {
+ wakeLock?.let { if (it.isHeld) it.release() }
+ wakeLock = null
+ }
+
+ private fun snapshot(): RecorderSnapshot {
+ val activeRecorder = recorder
+ val stats = activeRecorder?.stats()
+ val elapsedEnd = recordingEndElapsedMs.takeIf { it != 0L } ?: SystemClock.elapsedRealtime()
+ val durationSeconds = if (recordingStartElapsedMs == 0L) 0L
+ else ((elapsedEnd - recordingStartElapsedMs).coerceAtLeast(0L) / 1_000L)
+ return RecorderSnapshot(
+ active = activeRecorder != null && !stopping.get(),
+ phase = phase,
+ detail = detail,
+ capturePath = activeRecorder?.captureFile?.absolutePath,
+ durationSeconds = durationSeconds,
+ frameCount = stats?.frameCount ?: 0,
+ sampleCount = stats?.sampleCount ?: 0,
+ duplicateCount = stats?.duplicateCount ?: 0,
+ rejectedFragments = reassembler.rejectedFragmentCount,
+ invalidFrames = invalidFrames,
+ packetGaps = stats?.integrity?.packetGaps ?: 0,
+ sampleGaps = stats?.integrity?.sampleGaps ?: 0,
+ droppedSamples = stats?.integrity?.droppedSamples ?: 0,
+ queueOverflows = stats?.integrity?.status?.queueOverflows,
+ )
+ }
+
+ private fun publish(force: Boolean = false) {
+ val now = SystemClock.elapsedRealtime()
+ if (!force && now - lastPublishMs < 250) return
+ lastPublishMs = now
+ val value = snapshot()
+ currentSnapshot = value
+ sendBroadcast(
+ Intent(ACTION_STATE)
+ .setPackage(packageName)
+ .putExtra(EXTRA_ACTIVE, value.active)
+ .putExtra(EXTRA_PHASE, value.phase)
+ .putExtra(EXTRA_DETAIL, value.detail)
+ .putExtra(EXTRA_CAPTURE_PATH, value.capturePath)
+ .putExtra(EXTRA_DURATION_SECONDS, value.durationSeconds)
+ .putExtra(EXTRA_FRAMES, value.frameCount)
+ .putExtra(EXTRA_SAMPLES, value.sampleCount)
+ .putExtra(EXTRA_DUPLICATES, value.duplicateCount)
+ .putExtra(EXTRA_REJECTED_FRAGMENTS, value.rejectedFragments)
+ .putExtra(EXTRA_INVALID_FRAMES, value.invalidFrames)
+ .putExtra(EXTRA_PACKET_GAPS, value.packetGaps)
+ .putExtra(EXTRA_SAMPLE_GAPS, value.sampleGaps)
+ .putExtra(EXTRA_DROPPED, value.droppedSamples)
+ .putExtra(EXTRA_QUEUE_OVERFLOWS, value.queueOverflows ?: -1L),
+ )
+ if (value.active) {
+ getSystemService(NotificationManager::class.java)
+ .notify(NOTIFICATION_ID, notification(value))
+ }
+ }
+
+ private fun notification(value: RecorderSnapshot): Notification {
+ val openIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ val stopIntent = PendingIntent.getService(
+ this,
+ 1,
+ Intent(this, TelemetryService::class.java).setAction(ACTION_STOP),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ return Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
+ .setContentTitle("Trikke ride recording")
+ .setContentText("${value.phase}: ${value.sampleCount} samples")
+ .setContentIntent(openIntent)
+ .setOngoing(true)
+ .addAction(Notification.Action.Builder(null, "Stop", stopIntent).build())
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ getSystemService(NotificationManager::class.java).createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW,
+ ),
+ )
+ }
+
+ companion object {
+ const val ACTION_START = "com.jsjdesigns.trikkerecorder.START"
+ const val ACTION_STOP = "com.jsjdesigns.trikkerecorder.STOP"
+ const val ACTION_STATE = "com.jsjdesigns.trikkerecorder.STATE"
+ const val PREFERENCES = "trikke_recorder"
+ const val LAST_CAPTURE = "last_capture"
+ const val EXTRA_ACTIVE = "active"
+ const val EXTRA_PHASE = "phase"
+ const val EXTRA_DETAIL = "detail"
+ const val EXTRA_CAPTURE_PATH = "capture_path"
+ const val EXTRA_DURATION_SECONDS = "duration_seconds"
+ const val EXTRA_FRAMES = "frames"
+ const val EXTRA_SAMPLES = "samples"
+ const val EXTRA_DUPLICATES = "duplicates"
+ const val EXTRA_REJECTED_FRAGMENTS = "rejected_fragments"
+ const val EXTRA_INVALID_FRAMES = "invalid_frames"
+ const val EXTRA_PACKET_GAPS = "packet_gaps"
+ const val EXTRA_SAMPLE_GAPS = "sample_gaps"
+ const val EXTRA_DROPPED = "dropped"
+ const val EXTRA_QUEUE_OVERFLOWS = "queue_overflows"
+ private const val CHANNEL_ID = "ride_recording"
+ private const val NOTIFICATION_ID = 4100
+
+ @Volatile
+ var currentSnapshot = RecorderSnapshot()
+ private set
+ }
+}
diff --git a/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/protocol/BleFrameReassembler.kt b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/protocol/BleFrameReassembler.kt
new file mode 100644
index 0000000..2cfc44d
--- /dev/null
+++ b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/protocol/BleFrameReassembler.kt
@@ -0,0 +1,113 @@
+package com.jsjdesigns.trikkerecorder.protocol
+
+class BleFrameReassembler {
+ var rejectedFragmentCount: Long = 0
+ private set
+
+ private var sequence: Long? = null
+ private var totalSize = 0
+ private var frame = ByteArray(0)
+
+ fun reset() {
+ sequence = null
+ totalSize = 0
+ frame = ByteArray(0)
+ }
+
+ fun feed(fragment: ByteArray): ByteArray? {
+ if (fragment.size <= HEADER_SIZE) {
+ reject()
+ return null
+ }
+
+ val fragmentSequence = fragment.u32(0)
+ val offset = fragment.u16(4)
+ val declaredSize = fragment.u16(6)
+ val dataSize = fragment.size - HEADER_SIZE
+ if (
+ declaredSize !in MIN_FRAME_SIZE..MAX_FRAME_SIZE ||
+ offset >= declaredSize ||
+ offset + dataSize > declaredSize
+ ) {
+ reject()
+ return null
+ }
+
+ if (offset == 0) {
+ sequence = fragmentSequence
+ totalSize = declaredSize
+ frame = ByteArray(0)
+ }
+ if (
+ sequence != fragmentSequence ||
+ totalSize != declaredSize ||
+ offset != frame.size
+ ) {
+ reject()
+ return null
+ }
+
+ frame += fragment.copyOfRange(HEADER_SIZE, fragment.size)
+ if (frame.size != totalSize) {
+ return null
+ }
+ val completed = frame
+ reset()
+ return completed
+ }
+
+ private fun reject() {
+ rejectedFragmentCount++
+ reset()
+ }
+
+ companion object {
+ const val HEADER_SIZE = 8
+ const val MIN_FRAME_SIZE = 36
+ const val MAX_FRAME_SIZE = 196
+
+ fun encodeAck(packetSequence: Long): ByteArray = byteArrayOf(
+ 'A'.code.toByte(),
+ 'C'.code.toByte(),
+ 'K'.code.toByte(),
+ '1'.code.toByte(),
+ packetSequence.toByte(),
+ (packetSequence ushr 8).toByte(),
+ (packetSequence ushr 16).toByte(),
+ (packetSequence ushr 24).toByte(),
+ )
+
+ fun encodeBeginSession(sessionToken: Long): ByteArray = byteArrayOf(
+ 'B'.code.toByte(),
+ 'G'.code.toByte(),
+ 'N'.code.toByte(),
+ '1'.code.toByte(),
+ sessionToken.toByte(),
+ (sessionToken ushr 8).toByte(),
+ (sessionToken ushr 16).toByte(),
+ (sessionToken ushr 24).toByte(),
+ (sessionToken ushr 32).toByte(),
+ (sessionToken ushr 40).toByte(),
+ (sessionToken ushr 48).toByte(),
+ (sessionToken ushr 56).toByte(),
+ )
+ }
+}
+
+internal fun ByteArray.u16(offset: Int): Int =
+ (this[offset].toInt() and 0xff) or
+ ((this[offset + 1].toInt() and 0xff) shl 8)
+
+internal fun ByteArray.u32(offset: Int): Long =
+ (this[offset].toLong() and 0xff) or
+ ((this[offset + 1].toLong() and 0xff) shl 8) or
+ ((this[offset + 2].toLong() and 0xff) shl 16) or
+ ((this[offset + 3].toLong() and 0xff) shl 24)
+
+internal fun ByteArray.u64(offset: Int): Long {
+ var value = 0L
+ for (index in 0 until 8) {
+ value = value or ((this[offset + index].toLong() and 0xff) shl (index * 8))
+ }
+ return value
+}
diff --git a/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/protocol/TrkProtocol.kt b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/protocol/TrkProtocol.kt
new file mode 100644
index 0000000..40222bc
--- /dev/null
+++ b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/protocol/TrkProtocol.kt
@@ -0,0 +1,168 @@
+package com.jsjdesigns.trikkerecorder.protocol
+
+import java.util.zip.CRC32
+
+data class TransportStatus(
+ val sensorReadFailures: Long,
+ val queueOverflows: Long,
+ val beginRetries: Long,
+ val disconnects: Long,
+ val sendFailures: Long,
+ val replays: Long,
+ val invalidAcks: Long,
+)
+
+data class TrkFrame(
+ val raw: ByteArray,
+ val packetType: Int,
+ val flags: Int,
+ val packetSequence: Long,
+ val baseTimestampUs: Long,
+ val droppedSampleCount: Long,
+ val loopOverrunCount: Long,
+ val sampleSequences: LongArray,
+ val status: TransportStatus?,
+) {
+ val sampleCount: Int
+ get() = sampleSequences.size
+}
+
+object TrkProtocol {
+ const val HEADER_SIZE = 36
+ const val MAX_FRAME_SIZE = 196
+ const val PACKET_METADATA = 1
+ const val PACKET_SAMPLES = 2
+ const val PACKET_STATUS = 3
+
+ fun parse(raw: ByteArray): TrkFrame? {
+ if (raw.size !in HEADER_SIZE..MAX_FRAME_SIZE) return null
+ if (
+ raw[0] != 'T'.code.toByte() || raw[1] != 'R'.code.toByte() ||
+ raw[2] != 'K'.code.toByte() || raw[3] != '1'.code.toByte()
+ ) return null
+
+ val version = raw[4].toInt() and 0xff
+ val packetType = raw[5].toInt() and 0xff
+ val headerSize = raw[6].toInt() and 0xff
+ val recordSize = raw[7].toInt() and 0xff
+ val recordCount = raw[8].toInt() and 0xff
+ val flags = raw[9].toInt() and 0xff
+ val payloadSize = raw.u16(10)
+ if (version != 1 || headerSize != HEADER_SIZE || HEADER_SIZE + payloadSize != raw.size) {
+ return null
+ }
+
+ val validShape = when (packetType) {
+ PACKET_METADATA -> recordSize == 0 && recordCount == 0 && payloadSize == 48
+ PACKET_SAMPLES ->
+ recordSize == 20 && recordCount in 1..8 && payloadSize == recordSize * recordCount
+ PACKET_STATUS -> recordSize == 0 && recordCount == 0 && payloadSize == 32
+ else -> false
+ }
+ if (!validShape || raw.u32(32) != calculateCrc(raw)) return null
+
+ val status = if (packetType == PACKET_STATUS) {
+ if (raw.u16(36) != 1 || raw.u16(38) != 32) return null
+ TransportStatus(
+ sensorReadFailures = raw.u32(40),
+ queueOverflows = raw.u32(44),
+ beginRetries = raw.u32(48),
+ disconnects = raw.u32(52),
+ sendFailures = raw.u32(56),
+ replays = raw.u32(60),
+ invalidAcks = raw.u32(64),
+ )
+ } else {
+ null
+ }
+
+ val sampleSequences = if (packetType == PACKET_SAMPLES) {
+ LongArray(recordCount) { index -> raw.u32(HEADER_SIZE + index * recordSize) }
+ } else {
+ LongArray(0)
+ }
+ return TrkFrame(
+ raw = raw,
+ packetType = packetType,
+ flags = flags,
+ packetSequence = raw.u32(12),
+ baseTimestampUs = raw.u64(16),
+ droppedSampleCount = raw.u32(24),
+ loopOverrunCount = raw.u32(28),
+ sampleSequences = sampleSequences,
+ status = status,
+ )
+ }
+
+ private fun calculateCrc(raw: ByteArray): Long {
+ val crc = CRC32()
+ crc.update(raw, 4, 28)
+ crc.update(raw, HEADER_SIZE, raw.size - HEADER_SIZE)
+ return crc.value
+ }
+}
+
+data class IntegritySnapshot(
+ val packetGaps: Long,
+ val packetResets: Long,
+ val sampleGaps: Long,
+ val sampleResets: Long,
+ val droppedSamples: Long,
+ val loopOverruns: Long,
+ val status: TransportStatus?,
+)
+
+class IntegrityTracker {
+ private var previousPacket: Long? = null
+ private var previousSample: Long? = null
+ private var packetGaps = 0L
+ private var packetResets = 0L
+ private var sampleGaps = 0L
+ private var sampleResets = 0L
+ private var droppedSamples = 0L
+ private var loopOverruns = 0L
+ private var status: TransportStatus? = null
+
+ fun observe(frame: TrkFrame) {
+ previousPacket?.let { previous ->
+ val result = classify(previous, frame.packetSequence)
+ packetGaps += result.first
+ packetResets += result.second
+ }
+ previousPacket = frame.packetSequence
+ droppedSamples = frame.droppedSampleCount
+ loopOverruns = frame.loopOverrunCount
+ frame.status?.let { status = it }
+
+ frame.sampleSequences.forEach { sequence ->
+ previousSample?.let { previous ->
+ val result = classify(previous, sequence)
+ sampleGaps += result.first
+ sampleResets += result.second
+ }
+ previousSample = sequence
+ }
+ }
+
+ fun snapshot() = IntegritySnapshot(
+ packetGaps = packetGaps,
+ packetResets = packetResets,
+ sampleGaps = sampleGaps,
+ sampleResets = sampleResets,
+ droppedSamples = droppedSamples,
+ loopOverruns = loopOverruns,
+ status = status,
+ )
+
+ companion object {
+ fun classify(previous: Long, current: Long): Pair {
+ val expected = (previous + 1) and 0xffff_ffffL
+ val forward = (current - expected) and 0xffff_ffffL
+ return when {
+ forward == 0L -> 0L to 0L
+ forward < 0x8000_0000L -> forward to 0L
+ else -> 0L to 1L
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/storage/SessionRecorder.kt b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/storage/SessionRecorder.kt
new file mode 100644
index 0000000..15706d4
--- /dev/null
+++ b/android/app/src/main/kotlin/com/jsjdesigns/trikkerecorder/storage/SessionRecorder.kt
@@ -0,0 +1,180 @@
+package com.jsjdesigns.trikkerecorder.storage
+
+import com.jsjdesigns.trikkerecorder.protocol.IntegritySnapshot
+import com.jsjdesigns.trikkerecorder.protocol.IntegrityTracker
+import com.jsjdesigns.trikkerecorder.protocol.TrkFrame
+import java.io.File
+import java.io.FileOutputStream
+
+enum class CommitResult {
+ PERSISTED,
+ DUPLICATE,
+ CONFLICT,
+}
+
+data class RecorderStats(
+ val frameCount: Long,
+ val sampleCount: Long,
+ val duplicateCount: Long,
+ val integrity: IntegritySnapshot,
+)
+
+class SessionRecorder(
+ directory: File,
+ stem: String,
+ private val startWallClockMs: Long,
+ private val startElapsedRealtimeNs: Long,
+ private val sessionToken: Long,
+) : AutoCloseable {
+ val captureFile = File(directory, "$stem.trk")
+ val summaryFile = File(directory, "$stem.session.json")
+
+ private val output: FileOutputStream
+ private val integrity = IntegrityTracker()
+ private var lastSequence: Long? = null
+ private var lastRaw: ByteArray? = null
+ private var frameCount = 0L
+ private var sampleCount = 0L
+ private var duplicateCount = 0L
+ private var firstFrame: FrameClock? = null
+ private var lastFrame: FrameClock? = null
+ private var closed = false
+
+ init {
+ check(directory.exists() || directory.mkdirs()) { "Cannot create ${directory.path}" }
+ output = FileOutputStream(captureFile, false)
+ writeSummary(complete = false, endWallClockMs = null, endElapsedRealtimeNs = null, error = null)
+ }
+
+ @Synchronized
+ fun commit(
+ frame: TrkFrame,
+ receivedElapsedRealtimeNs: Long,
+ receivedWallClockMs: Long,
+ ): CommitResult {
+ check(!closed) { "Recorder is closed" }
+ if (lastSequence == frame.packetSequence) {
+ if (lastRaw!!.contentEquals(frame.raw)) {
+ duplicateCount++
+ return CommitResult.DUPLICATE
+ }
+ return CommitResult.CONFLICT
+ }
+
+ output.write(frame.raw)
+ output.flush()
+ output.fd.sync()
+
+ integrity.observe(frame)
+ frameCount++
+ sampleCount += frame.sampleCount
+ lastSequence = frame.packetSequence
+ lastRaw = frame.raw.copyOf()
+ val clock = FrameClock(
+ packetSequence = frame.packetSequence,
+ deviceBaseTimestampUs = frame.baseTimestampUs,
+ phoneElapsedRealtimeNs = receivedElapsedRealtimeNs,
+ phoneWallClockMs = receivedWallClockMs,
+ )
+ if (firstFrame == null) firstFrame = clock
+ lastFrame = clock
+ return CommitResult.PERSISTED
+ }
+
+ @Synchronized
+ fun stats(): RecorderStats = RecorderStats(
+ frameCount = frameCount,
+ sampleCount = sampleCount,
+ duplicateCount = duplicateCount,
+ integrity = integrity.snapshot(),
+ )
+
+ @Synchronized
+ fun close(endWallClockMs: Long, endElapsedRealtimeNs: Long, error: String? = null) {
+ if (closed) return
+ closed = true
+ output.flush()
+ output.fd.sync()
+ output.close()
+ writeSummary(
+ complete = error == null,
+ endWallClockMs = endWallClockMs,
+ endElapsedRealtimeNs = endElapsedRealtimeNs,
+ error = error,
+ )
+ }
+
+ override fun close() {
+ close(System.currentTimeMillis(), System.nanoTime())
+ }
+
+ private fun writeSummary(
+ complete: Boolean,
+ endWallClockMs: Long?,
+ endElapsedRealtimeNs: Long?,
+ error: String?,
+ ) {
+ val stats = stats()
+ val status = stats.integrity.status
+ val json = buildString {
+ append("{\n")
+ append(" \"schema\": 1,\n")
+ append(" \"captureFile\": \"").append(captureFile.name).append("\",\n")
+ append(" \"sessionTokenHex\": \"")
+ .append(java.lang.Long.toUnsignedString(sessionToken, 16).padStart(16, '0'))
+ .append("\",\n")
+ append(" \"complete\": ").append(complete).append(",\n")
+ append(" \"startWallClockMs\": ").append(startWallClockMs).append(",\n")
+ append(" \"startElapsedRealtimeNs\": ").append(startElapsedRealtimeNs).append(",\n")
+ append(" \"endWallClockMs\": ").append(endWallClockMs ?: "null").append(",\n")
+ append(" \"endElapsedRealtimeNs\": ").append(endElapsedRealtimeNs ?: "null").append(",\n")
+ append(" \"firstFrame\": ").append(firstFrame?.json() ?: "null").append(",\n")
+ append(" \"lastFrame\": ").append(lastFrame?.json() ?: "null").append(",\n")
+ append(" \"frames\": ").append(stats.frameCount).append(",\n")
+ append(" \"samples\": ").append(stats.sampleCount).append(",\n")
+ append(" \"duplicateReplays\": ").append(stats.duplicateCount).append(",\n")
+ append(" \"packetGaps\": ").append(stats.integrity.packetGaps).append(",\n")
+ append(" \"packetResets\": ").append(stats.integrity.packetResets).append(",\n")
+ append(" \"sampleGaps\": ").append(stats.integrity.sampleGaps).append(",\n")
+ append(" \"sampleResets\": ").append(stats.integrity.sampleResets).append(",\n")
+ append(" \"droppedSamples\": ").append(stats.integrity.droppedSamples).append(",\n")
+ append(" \"loopOverruns\": ").append(stats.integrity.loopOverruns).append(",\n")
+ append(" \"queueOverflows\": ").append(status?.queueOverflows ?: "null").append(",\n")
+ append(" \"transportDisconnects\": ").append(status?.disconnects ?: "null").append(",\n")
+ append(" \"transportReplays\": ").append(status?.replays ?: "null").append(",\n")
+ append(" \"error\": ").append(error?.let { "\"${escape(it)}\"" } ?: "null").append("\n")
+ append("}\n")
+ }
+ FileOutputStream(summaryFile, false).use { summary ->
+ summary.write(json.toByteArray(Charsets.UTF_8))
+ summary.flush()
+ summary.fd.sync()
+ }
+ }
+
+ private fun escape(value: String): String = buildString {
+ value.forEach { character ->
+ when (character) {
+ '\\' -> append("\\\\")
+ '"' -> append("\\\"")
+ '\n' -> append("\\n")
+ '\r' -> append("\\r")
+ '\t' -> append("\\t")
+ else -> append(character)
+ }
+ }
+ }
+
+ private data class FrameClock(
+ val packetSequence: Long,
+ val deviceBaseTimestampUs: Long,
+ val phoneElapsedRealtimeNs: Long,
+ val phoneWallClockMs: Long,
+ ) {
+ fun json(): String =
+ "{\"packetSequence\":$packetSequence," +
+ "\"deviceBaseTimestampUs\":$deviceBaseTimestampUs," +
+ "\"phoneElapsedRealtimeNs\":$phoneElapsedRealtimeNs," +
+ "\"phoneWallClockMs\":$phoneWallClockMs}"
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..32f5e62
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..ae57448
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+ Trikke Recorder
+ Ride recording
+ Export last .trk
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..3f97def
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..2130cfd
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/test/kotlin/com/jsjdesigns/trikkerecorder/protocol/ProtocolContractTest.kt b/android/app/src/test/kotlin/com/jsjdesigns/trikkerecorder/protocol/ProtocolContractTest.kt
new file mode 100644
index 0000000..f5ae2af
--- /dev/null
+++ b/android/app/src/test/kotlin/com/jsjdesigns/trikkerecorder/protocol/ProtocolContractTest.kt
@@ -0,0 +1,137 @@
+package com.jsjdesigns.trikkerecorder.protocol
+
+import com.jsjdesigns.trikkerecorder.storage.CommitResult
+import com.jsjdesigns.trikkerecorder.storage.SessionRecorder
+import java.nio.file.Files
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ProtocolContractTest {
+ private val fixture: ByteArray by lazy {
+ checkNotNull(javaClass.classLoader?.getResourceAsStream("ble_mtu_race_4bf00eb.trk")) {
+ "Hardware fixture was not packaged as a test resource"
+ }.use { it.readBytes() }
+ }
+
+ @Test
+ fun hardwareFixtureMatchesAndroidParserContract() {
+ val frames = splitFrames(fixture)
+ assertTrue(frames.size > 100)
+ assertTrue(frames.all { TrkProtocol.parse(it) != null })
+ assertTrue(frames.any { TrkProtocol.parse(it)?.packetType == TrkProtocol.PACKET_METADATA })
+ assertTrue(frames.any { TrkProtocol.parse(it)?.packetType == TrkProtocol.PACKET_SAMPLES })
+ assertTrue(frames.any { TrkProtocol.parse(it)?.packetType == TrkProtocol.PACKET_STATUS })
+ }
+
+ @Test
+ fun crcDamageIsRejected() {
+ val raw = splitFrames(fixture).first().copyOf()
+ raw[raw.lastIndex] = (raw.last().toInt() xor 0x80).toByte()
+ assertNull(TrkProtocol.parse(raw))
+ }
+
+ @Test
+ fun fragmentReplayRestartsAndCompletesExactFrame() {
+ val raw = splitFrames(fixture).first()
+ val sequence = raw.u32(12)
+ val reassembler = BleFrameReassembler()
+
+ assertNull(reassembler.feed(fragment(raw, sequence, 0, 20)))
+ assertNull(reassembler.feed(fragment(raw, sequence, 0, 40)))
+ val completed = reassembler.feed(fragment(raw, sequence, 40, raw.size - 40))
+
+ assertArrayEquals(raw, completed)
+ assertEquals(0, reassembler.rejectedFragmentCount)
+ assertArrayEquals(
+ byteArrayOf('A'.code.toByte(), 'C'.code.toByte(), 'K'.code.toByte(), '1'.code.toByte()) +
+ raw.copyOfRange(12, 16),
+ BleFrameReassembler.encodeAck(sequence),
+ )
+ }
+
+ @Test
+ fun missingOrOutOfOrderFragmentIsRejected() {
+ val raw = splitFrames(fixture).first()
+ val sequence = raw.u32(12)
+ val reassembler = BleFrameReassembler()
+ assertNull(reassembler.feed(fragment(raw, sequence, 20, 20)))
+ assertEquals(1, reassembler.rejectedFragmentCount)
+ }
+
+ @Test
+ fun persistencePrecedesDedupeAndSummaryClosure() {
+ val parsed = checkNotNull(TrkProtocol.parse(splitFrames(fixture).first()))
+ val directory = Files.createTempDirectory("trikke-recorder-test").toFile()
+ try {
+ val recorder = SessionRecorder(directory, "ride_test", 1_000, 2_000, 0x0102030405060708)
+ assertFalse(recorder.summaryFile.readText().contains("\"complete\": true"))
+ assertEquals(CommitResult.PERSISTED, recorder.commit(parsed, 3_000, 4_000))
+ assertEquals(CommitResult.DUPLICATE, recorder.commit(parsed, 5_000, 6_000))
+ assertArrayEquals(parsed.raw, recorder.captureFile.readBytes())
+
+ val conflicting = parsed.copy(raw = parsed.raw.copyOf().also { it[it.lastIndex]++ })
+ assertEquals(CommitResult.CONFLICT, recorder.commit(conflicting, 7_000, 8_000))
+ assertArrayEquals(parsed.raw, recorder.captureFile.readBytes())
+
+ recorder.close(9_000, 10_000)
+ val summary = recorder.summaryFile.readText()
+ assertTrue(summary.contains("\"complete\": true"))
+ assertTrue(summary.contains("\"sessionTokenHex\": \"0102030405060708\""))
+ assertTrue(summary.contains("\"frames\": 1"))
+ assertTrue(summary.contains("\"duplicateReplays\": 1"))
+ } finally {
+ directory.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun unsignedSequenceClassificationHandlesWrapAndReset() {
+ assertEquals(0L to 0L, IntegrityTracker.classify(0xffff_ffffL, 0))
+ assertEquals(2L to 0L, IntegrityTracker.classify(0xffff_fffeL, 1))
+ assertEquals(0L to 1L, IntegrityTracker.classify(1_000, 0))
+ }
+
+ @Test
+ fun beginSessionCommandCarriesStableUnsignedToken() {
+ assertArrayEquals(
+ byteArrayOf(
+ 'B'.code.toByte(), 'G'.code.toByte(), 'N'.code.toByte(), '1'.code.toByte(),
+ 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01,
+ ),
+ BleFrameReassembler.encodeBeginSession(0x0102030405060708),
+ )
+ }
+
+ private fun splitFrames(stream: ByteArray): List {
+ val frames = mutableListOf()
+ var offset = 0
+ while (offset < stream.size) {
+ assertTrue("truncated header at $offset", offset + TrkProtocol.HEADER_SIZE <= stream.size)
+ val payloadSize = stream.u16(offset + 10)
+ val size = TrkProtocol.HEADER_SIZE + payloadSize
+ assertTrue("truncated frame at $offset", offset + size <= stream.size)
+ frames += stream.copyOfRange(offset, offset + size)
+ offset += size
+ }
+ assertEquals(stream.size, offset)
+ return frames
+ }
+
+ private fun fragment(raw: ByteArray, sequence: Long, offset: Int, size: Int): ByteArray {
+ val envelope = ByteArray(BleFrameReassembler.HEADER_SIZE)
+ envelope[0] = sequence.toByte()
+ envelope[1] = (sequence ushr 8).toByte()
+ envelope[2] = (sequence ushr 16).toByte()
+ envelope[3] = (sequence ushr 24).toByte()
+ envelope[4] = offset.toByte()
+ envelope[5] = (offset ushr 8).toByte()
+ envelope[6] = raw.size.toByte()
+ envelope[7] = (raw.size ushr 8).toByte()
+ return envelope + raw.copyOfRange(offset, offset + size)
+ }
+}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..fd4b881
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ id("com.android.application") version "9.2.1" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..e696167
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..fa4ed51
--- /dev/null
+++ b/android/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,12 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
+toolchainVersion=25
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..d997cfc
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..c61a118
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..739907d
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..c4bdd3a
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,93 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..cf4ad7e
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "TrikkeRecorder"
+include(":app")
diff --git a/docs/binary-record-v1.md b/docs/binary-record-v1.md
index 49bc2d1..a6d5943 100644
--- a/docs/binary-record-v1.md
+++ b/docs/binary-record-v1.md
@@ -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.
diff --git a/docs/ble-transport-v1.md b/docs/ble-transport-v1.md
deleted file mode 100644
index e17e926..0000000
--- a/docs/ble-transport-v1.md
+++ /dev/null
@@ -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.
diff --git a/docs/ble-transport-v2.md b/docs/ble-transport-v2.md
new file mode 100644
index 0000000..ebaaa6d
--- /dev/null
+++ b/docs/ble-transport-v2.md
@@ -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.
diff --git a/main/trikke_ble_protocol.c b/main/trikke_ble_protocol.c
index 72a66a9..a7da91a 100644
--- a/main/trikke_ble_protocol.c
+++ b/main/trikke_ble_protocol.c
@@ -15,6 +15,12 @@ static uint32_t get_u32_le(const uint8_t *input)
((uint32_t)input[2] << 16) | ((uint32_t)input[3] << 24);
}
+static uint64_t get_u64_le(const uint8_t *input)
+{
+ return (uint64_t)get_u32_le(input) |
+ ((uint64_t)get_u32_le(input + 4) << 32);
+}
+
static void put_u16_le(uint8_t *output, uint16_t value)
{
output[0] = (uint8_t)value;
@@ -89,3 +95,31 @@ bool trikke_ble_decode_ack(
*packet_sequence = get_u32_le(ack + 4);
return true;
}
+
+bool trikke_ble_decode_begin_session(
+ const uint8_t *command,
+ size_t command_size,
+ uint64_t *session_token)
+{
+ if (command == NULL || session_token == NULL ||
+ command_size != TRIKKE_BLE_BEGIN_SESSION_SIZE ||
+ memcmp(command, "BGN1", 4) != 0) {
+ return false;
+ }
+ *session_token = get_u64_le(command + 4);
+ return true;
+}
+
+bool trikke_ble_update_subscription(
+ bool notify_enabled,
+ bool session_connection_ready,
+ bool *subscribed)
+{
+ if (subscribed == NULL) {
+ return false;
+ }
+ const bool effective = notify_enabled && session_connection_ready;
+ const bool changed = effective != *subscribed;
+ *subscribed = effective;
+ return changed;
+}
diff --git a/main/trikke_ble_protocol.h b/main/trikke_ble_protocol.h
index ae01823..ec2d0fa 100644
--- a/main/trikke_ble_protocol.h
+++ b/main/trikke_ble_protocol.h
@@ -10,6 +10,7 @@ extern "C" {
#define TRIKKE_BLE_FRAGMENT_HEADER_SIZE 8
#define TRIKKE_BLE_ACK_SIZE 8
+#define TRIKKE_BLE_BEGIN_SESSION_SIZE 12
// BLE data notifications carry a little-endian packet sequence, byte offset,
// total TRK1 frame size, then the frame bytes at that offset. The unchanged
@@ -29,6 +30,20 @@ bool trikke_ble_decode_ack(
size_t ack_size,
uint32_t *packet_sequence);
+// A session begin is ASCII "BGN1" followed by a receiver-generated uint64
+// token. Repeating the same token is idempotent across BLE reconnects.
+bool trikke_ble_decode_begin_session(
+ const uint8_t *command,
+ size_t command_size,
+ uint64_t *session_token);
+
+// Notification delivery is active only after both the CCCD and session
+// authorization are ready. Returns true when the effective state changes.
+bool trikke_ble_update_subscription(
+ bool notify_enabled,
+ bool session_connection_ready,
+ bool *subscribed);
+
#ifdef __cplusplus
}
#endif
diff --git a/main/trikke_ble_transport.c b/main/trikke_ble_transport.c
index d1764bb..6a691a2 100644
--- a/main/trikke_ble_transport.c
+++ b/main/trikke_ble_transport.c
@@ -8,6 +8,8 @@
#include "host/ble_hs.h"
#include "host/ble_uuid.h"
#include "host/util/util.h"
+#include "esp_attr.h"
+#include "esp_system.h"
#include "esp_timer.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
@@ -22,11 +24,24 @@
#define TRIKKE_BLE_MAX_ATT_PAYLOAD 253
#define TRIKKE_BLE_FRAGMENTS_PER_POLL 8
#define TRIKKE_BLE_ACK_TIMEOUT_US 1000000
+#define TRIKKE_BLE_SESSION_RTC_MAGIC UINT32_C(0x54524b53)
+#define TRIKKE_BLE_SESSION_RTC_XOR UINT32_C(0xa93cf17e)
+#define TRIKKE_BLE_SESSION_RESET_DELAY_MS 50
static trikke_ble_transport_t *s_ble;
static uint8_t s_own_address_type;
static uint16_t s_data_value_handle;
static uint16_t s_ack_value_handle;
+static uint16_t s_control_value_handle;
+
+typedef struct {
+ uint32_t token_low;
+ uint32_t token_high;
+ uint32_t checksum;
+ uint32_t magic;
+} trikke_ble_rtc_session_t;
+
+RTC_NOINIT_ATTR static trikke_ble_rtc_session_t s_rtc_session;
// 7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10 and adjacent characteristic UUIDs.
static const ble_uuid128_t TRIKKE_SERVICE_UUID =
@@ -38,6 +53,9 @@ static const ble_uuid128_t TRIKKE_DATA_UUID =
static const ble_uuid128_t TRIKKE_ACK_UUID =
BLE_UUID128_INIT(0x12, 0x9c, 0x1e, 0x2a, 0x4c, 0x3d, 0xbe, 0x8f,
0x9b, 0x4a, 0x5b, 0xf7, 0x00, 0xa0, 0x2e, 0x7d);
+static const ble_uuid128_t TRIKKE_CONTROL_UUID =
+ BLE_UUID128_INIT(0x13, 0x9c, 0x1e, 0x2a, 0x4c, 0x3d, 0xbe, 0x8f,
+ 0x9b, 0x4a, 0x5b, 0xf7, 0x00, 0xa0, 0x2e, 0x7d);
static uint32_t get_u32_le(const uint8_t *input)
{
@@ -45,6 +63,63 @@ static uint32_t get_u32_le(const uint8_t *input)
((uint32_t)input[2] << 16) | ((uint32_t)input[3] << 24);
}
+// Caller must hold ble->lock. Keep the raw CCCD state independent from session
+// authorization so either legal write order converges on the same effective
+// subscription state.
+static void update_subscribed_locked(trikke_ble_transport_t *ble)
+{
+ if (trikke_ble_update_subscription(
+ ble->notify_enabled,
+ ble->session_connection_ready,
+ &ble->subscribed)) {
+ ++ble->delivery_epoch;
+ }
+}
+
+static uint32_t rtc_session_checksum(uint32_t low, uint32_t high)
+{
+ return TRIKKE_BLE_SESSION_RTC_MAGIC ^ low ^ high ^
+ TRIKKE_BLE_SESSION_RTC_XOR;
+}
+
+static void rtc_session_clear(void)
+{
+ s_rtc_session.magic = 0;
+ s_rtc_session.token_low = 0;
+ s_rtc_session.token_high = 0;
+ s_rtc_session.checksum = 0;
+}
+
+static void rtc_session_store(uint64_t token)
+{
+ const uint32_t low = (uint32_t)token;
+ const uint32_t high = (uint32_t)(token >> 32);
+ s_rtc_session.magic = 0;
+ s_rtc_session.token_low = low;
+ s_rtc_session.token_high = high;
+ s_rtc_session.checksum = rtc_session_checksum(low, high);
+ s_rtc_session.magic = TRIKKE_BLE_SESSION_RTC_MAGIC;
+}
+
+static bool rtc_session_load(uint64_t *token)
+{
+ if (token == NULL || s_rtc_session.magic != TRIKKE_BLE_SESSION_RTC_MAGIC ||
+ s_rtc_session.checksum != rtc_session_checksum(
+ s_rtc_session.token_low, s_rtc_session.token_high)) {
+ return false;
+ }
+ *token = (uint64_t)s_rtc_session.token_low |
+ ((uint64_t)s_rtc_session.token_high << 32);
+ return true;
+}
+
+static void session_restart_task(void *argument)
+{
+ (void)argument;
+ vTaskDelay(pdMS_TO_TICKS(TRIKKE_BLE_SESSION_RESET_DELAY_MS));
+ esp_restart();
+}
+
static int data_access(
uint16_t connection_handle,
uint16_t attribute_handle,
@@ -102,6 +177,69 @@ static int ack_access(
return accepted ? 0 : BLE_ATT_ERR_UNLIKELY;
}
+static int session_access(
+ uint16_t connection_handle,
+ uint16_t attribute_handle,
+ struct ble_gatt_access_ctxt *context,
+ void *argument)
+{
+ (void)attribute_handle;
+ (void)argument;
+ trikke_ble_transport_t *ble = s_ble;
+ if (ble == NULL || context->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
+ return BLE_ATT_ERR_UNLIKELY;
+ }
+
+ uint8_t command[TRIKKE_BLE_BEGIN_SESSION_SIZE] = {0};
+ uint16_t command_size = 0;
+ uint64_t requested_token = 0;
+ if (OS_MBUF_PKTLEN(context->om) != sizeof(command) ||
+ ble_hs_mbuf_to_flat(
+ context->om, command, sizeof(command), &command_size) != 0 ||
+ !trikke_ble_decode_begin_session(
+ command, command_size, &requested_token)) {
+ return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
+ }
+
+ bool notify_session_ready = false;
+ bool restart = false;
+ portENTER_CRITICAL(&ble->lock);
+ if (!ble->connected || ble->connection_handle != connection_handle ||
+ ble->session_restart_pending) {
+ portEXIT_CRITICAL(&ble->lock);
+ return BLE_ATT_ERR_UNLIKELY;
+ }
+ if (ble->session_token_valid && ble->session_token == requested_token) {
+ ble->session_connection_ready = true;
+ update_subscribed_locked(ble);
+ if (!ble->session_started) {
+ ble->session_started = true;
+ notify_session_ready = true;
+ }
+ } else {
+ ble->session_connection_ready = false;
+ update_subscribed_locked(ble);
+ ble->session_restart_pending = true;
+ rtc_session_store(requested_token);
+ restart = true;
+ }
+ portEXIT_CRITICAL(&ble->lock);
+
+ if (notify_session_ready && ble->session_ready != NULL) {
+ ble->session_ready(ble->session_ready_context);
+ }
+ if (restart && xTaskCreate(
+ session_restart_task, "trikke_session_reset", 2048, NULL,
+ configMAX_PRIORITIES - 1, NULL) != pdPASS) {
+ rtc_session_clear();
+ portENTER_CRITICAL(&ble->lock);
+ ble->session_restart_pending = false;
+ portEXIT_CRITICAL(&ble->lock);
+ return BLE_ATT_ERR_UNLIKELY;
+ }
+ return 0;
+}
+
static const struct ble_gatt_svc_def TRIKKE_GATT_SERVICES[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
@@ -119,6 +257,12 @@ static const struct ble_gatt_svc_def TRIKKE_GATT_SERVICES[] = {
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP,
.val_handle = &s_ack_value_handle,
},
+ {
+ .uuid = &TRIKKE_CONTROL_UUID.u,
+ .access_cb = session_access,
+ .flags = BLE_GATT_CHR_F_WRITE,
+ .val_handle = &s_control_value_handle,
+ },
{0},
},
},
@@ -169,7 +313,9 @@ static void on_reset(int reason)
++ble->counters.disconnect_count;
}
ble->connected = false;
+ ble->notify_enabled = false;
ble->subscribed = false;
+ ble->session_connection_ready = false;
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
++ble->delivery_epoch;
portEXIT_CRITICAL(&ble->lock);
@@ -197,7 +343,9 @@ static int gap_event(struct ble_gap_event *event, void *argument)
if (event->connect.status == 0) {
portENTER_CRITICAL(&ble->lock);
ble->connected = true;
+ ble->notify_enabled = false;
ble->subscribed = false;
+ ble->session_connection_ready = false;
ble->connection_handle = event->connect.conn_handle;
++ble->delivery_epoch;
portEXIT_CRITICAL(&ble->lock);
@@ -212,7 +360,9 @@ static int gap_event(struct ble_gap_event *event, void *argument)
++ble->counters.disconnect_count;
}
ble->connected = false;
+ ble->notify_enabled = false;
ble->subscribed = false;
+ ble->session_connection_ready = false;
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
++ble->delivery_epoch;
portEXIT_CRITICAL(&ble->lock);
@@ -222,11 +372,8 @@ static int gap_event(struct ble_gap_event *event, void *argument)
case BLE_GAP_EVENT_SUBSCRIBE:
if (event->subscribe.attr_handle == s_data_value_handle) {
portENTER_CRITICAL(&ble->lock);
- const bool subscribed = event->subscribe.cur_notify != 0;
- if (subscribed != ble->subscribed) {
- ble->subscribed = subscribed;
- ++ble->delivery_epoch;
- }
+ ble->notify_enabled = event->subscribe.cur_notify != 0;
+ update_subscribed_locked(ble);
portEXIT_CRITICAL(&ble->lock);
}
return 0;
@@ -431,7 +578,9 @@ static trikke_transport_status_t ble_poll_packet(void *context)
esp_err_t trikke_ble_transport_init(
trikke_ble_transport_t *ble,
- trikke_transport_t *transport)
+ trikke_transport_t *transport,
+ trikke_ble_session_ready_fn session_ready,
+ void *session_ready_context)
{
if (ble == NULL || transport == NULL) {
return ESP_ERR_INVALID_ARG;
@@ -443,6 +592,14 @@ esp_err_t trikke_ble_transport_init(
memset(ble, 0, sizeof(*ble));
ble->lock = (portMUX_TYPE)portMUX_INITIALIZER_UNLOCKED;
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
+ ble->session_ready = session_ready;
+ ble->session_ready_context = session_ready_context;
+ if (esp_reset_reason() == ESP_RST_SW &&
+ rtc_session_load(&ble->session_token)) {
+ ble->session_token_valid = true;
+ } else {
+ rtc_session_clear();
+ }
s_ble = ble;
esp_err_t error = nvs_flash_init();
diff --git a/main/trikke_ble_transport.h b/main/trikke_ble_transport.h
index 7b49ce0..0d52d4e 100644
--- a/main/trikke_ble_transport.h
+++ b/main/trikke_ble_transport.h
@@ -19,15 +19,23 @@ typedef struct {
uint32_t invalid_ack_count;
} trikke_ble_transport_counters_t;
+typedef void (*trikke_ble_session_ready_fn)(void *context);
+
typedef struct {
portMUX_TYPE lock;
bool initialized;
bool connected;
+ bool notify_enabled;
bool subscribed;
bool frame_active;
bool frame_fully_sent_once;
bool ack_received;
+ bool session_token_valid;
+ bool session_connection_ready;
+ bool session_started;
+ bool session_restart_pending;
uint16_t connection_handle;
+ uint64_t session_token;
uint32_t delivery_epoch;
uint32_t frame_epoch;
uint32_t frame_sequence;
@@ -35,12 +43,16 @@ typedef struct {
size_t frame_size;
size_t next_offset;
int64_t ack_deadline_us;
+ trikke_ble_session_ready_fn session_ready;
+ void *session_ready_context;
trikke_ble_transport_counters_t counters;
} trikke_ble_transport_t;
esp_err_t trikke_ble_transport_init(
trikke_ble_transport_t *ble,
- trikke_transport_t *transport);
+ trikke_transport_t *transport,
+ trikke_ble_session_ready_fn session_ready,
+ void *session_ready_context);
void trikke_ble_transport_get_counters(
trikke_ble_transport_t *ble,
diff --git a/main/trikke_sensor_main.c b/main/trikke_sensor_main.c
index a6ae728..678d31a 100644
--- a/main/trikke_sensor_main.c
+++ b/main/trikke_sensor_main.c
@@ -28,7 +28,7 @@
#define TRIKKE_I2C_FREQ_HZ 400000
#define TRIKKE_SAMPLE_RATE_HZ 100
#define TRIKKE_SAMPLE_TICKS pdMS_TO_TICKS(1000 / TRIKKE_SAMPLE_RATE_HZ)
-#define TRIKKE_SAMPLE_QUEUE_DEPTH 1024
+#define TRIKKE_SAMPLE_QUEUE_DEPTH 3072
#define TRIKKE_METADATA_INTERVAL_PACKETS 64
#define TRIKKE_STATUS_INTERVAL_PACKETS 64
#define TRIKKE_TRANSPORT_RETRY_DELAY_MS 10
@@ -59,9 +59,14 @@ typedef struct {
adxl345_t accelerometer;
l3g4200d_t gyroscope;
QueueHandle_t sample_queue;
+ StaticQueue_t sample_queue_control;
+ uint8_t sample_queue_storage[
+ TRIKKE_SAMPLE_QUEUE_DEPTH * sizeof(trikke_wire_sample_t)];
atomic_uint_least32_t sensor_read_failure_count;
atomic_uint_least32_t queue_overflow_count;
atomic_uint_least32_t loop_overrun_count;
+ TaskHandle_t output_task_handle;
+ TaskHandle_t acquisition_task_handle;
trikke_transport_t transport;
#if CONFIG_TRIKKE_TRANSPORT_BLE
trikke_ble_transport_t ble_transport;
@@ -147,6 +152,18 @@ static trikke_wire_status_t status_snapshot(
return status;
}
+#if CONFIG_TRIKKE_TRANSPORT_BLE
+static void session_ready(void *argument)
+{
+ trikke_context_t *context = argument;
+ if (context == NULL) {
+ return;
+ }
+ xTaskNotifyGive(context->output_task_handle);
+ xTaskNotifyGive(context->acquisition_task_handle);
+}
+#endif
+
static void acquisition_task(void *argument)
{
trikke_context_t *context = argument;
@@ -407,8 +424,11 @@ void app_main(void)
usb_serial_jtag_vfs_set_tx_line_endings(ESP_LINE_ENDINGS_LF);
#endif
- s_context.sample_queue =
- xQueueCreate(TRIKKE_SAMPLE_QUEUE_DEPTH, sizeof(trikke_wire_sample_t));
+ s_context.sample_queue = xQueueCreateStatic(
+ TRIKKE_SAMPLE_QUEUE_DEPTH,
+ sizeof(trikke_wire_sample_t),
+ s_context.sample_queue_storage,
+ &s_context.sample_queue_control);
if (s_context.sample_queue == NULL) {
ESP_LOGE(TAG, "sample queue allocation failed");
l3g4200d_deinit(&s_context.gyroscope);
@@ -417,17 +437,15 @@ void app_main(void)
return;
}
- TaskHandle_t output_task_handle = NULL;
- TaskHandle_t acquisition_task_handle = NULL;
if (xTaskCreate(output_task, "trikke_output", 4096, &s_context, 5,
- &output_task_handle) != pdPASS ||
+ &s_context.output_task_handle) != pdPASS ||
xTaskCreate(acquisition_task, "trikke_acquire", 4096, &s_context, 10,
- &acquisition_task_handle) != pdPASS) {
- if (output_task_handle != NULL) {
- vTaskDelete(output_task_handle);
+ &s_context.acquisition_task_handle) != pdPASS) {
+ if (s_context.output_task_handle != NULL) {
+ vTaskDelete(s_context.output_task_handle);
}
- if (acquisition_task_handle != NULL) {
- vTaskDelete(acquisition_task_handle);
+ if (s_context.acquisition_task_handle != NULL) {
+ vTaskDelete(s_context.acquisition_task_handle);
}
vQueueDelete(s_context.sample_queue);
ESP_LOGE(TAG, "telemetry task creation failed");
@@ -439,14 +457,15 @@ void app_main(void)
#if CONFIG_TRIKKE_TRANSPORT_BLE
err = trikke_ble_transport_init(
- &s_context.ble_transport, &s_context.transport);
+ &s_context.ble_transport, &s_context.transport,
+ session_ready, &s_context);
#else
err = trikke_usb_transport_init(
&s_context.usb_transport, &s_context.transport);
#endif
if (err != ESP_OK) {
- vTaskDelete(output_task_handle);
- vTaskDelete(acquisition_task_handle);
+ vTaskDelete(s_context.output_task_handle);
+ vTaskDelete(s_context.acquisition_task_handle);
vQueueDelete(s_context.sample_queue);
#if CONFIG_TRIKKE_TRANSPORT_BLE
ESP_LOGE(TAG, "BLE transport initialization failed: %s",
@@ -463,6 +482,8 @@ void app_main(void)
// No text may share the byte stream once framed binary output begins.
esp_log_level_set("*", ESP_LOG_NONE);
- xTaskNotifyGive(output_task_handle);
- xTaskNotifyGive(acquisition_task_handle);
+#if !CONFIG_TRIKKE_TRANSPORT_BLE
+ xTaskNotifyGive(s_context.output_task_handle);
+ xTaskNotifyGive(s_context.acquisition_task_handle);
+#endif
}
diff --git a/tests/ble_protocol_fixture.c b/tests/ble_protocol_fixture.c
index 45e8e54..d546beb 100644
--- a/tests/ble_protocol_fixture.c
+++ b/tests/ble_protocol_fixture.c
@@ -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;
}
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
index 8ec6bb3..39d4cd9 100644
--- a/tests/fixtures/README.md
+++ b/tests/fixtures/README.md
@@ -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
diff --git a/tests/fixtures/ride_20260820_090607.session.json b/tests/fixtures/ride_20260820_090607.session.json
new file mode 100644
index 0000000..f1db9b1
--- /dev/null
+++ b/tests/fixtures/ride_20260820_090607.session.json
@@ -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
+}
diff --git a/tests/fixtures/ride_20260820_090607.trk b/tests/fixtures/ride_20260820_090607.trk
new file mode 100644
index 0000000..8f65b11
Binary files /dev/null and b/tests/fixtures/ride_20260820_090607.trk differ
diff --git a/tests/test_trikke_protocol.py b/tests/test_trikke_protocol.py
index d27cd6e..ef12ea6 100644
--- a/tests/test_trikke_protocol.py
+++ b/tests/test_trikke_protocol.py
@@ -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": {
diff --git a/tools/capture_ble.py b/tools/capture_ble.py
index 9fe122c..c13a402 100644
--- a/tools/capture_ble.py
+++ b/tools/capture_ble.py
@@ -7,12 +7,13 @@ import argparse
import asyncio
import csv
import os
+import secrets
import signal
from contextlib import ExitStack
from datetime import datetime
from pathlib import Path
-from trikke_ble import BleFrameReassembler, encode_ack
+from trikke_ble import BleFrameReassembler, encode_ack, encode_begin_session
from trikke_protocol import (
CSV_COLUMNS,
PACKET_TYPE_METADATA,
@@ -27,6 +28,7 @@ DEVICE_NAME = "TrikkeSensor"
SERVICE_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10"
DATA_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11"
ACK_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12"
+CONTROL_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c13"
def parse_args() -> argparse.Namespace:
@@ -86,6 +88,7 @@ async def capture(args: argparse.Namespace) -> int:
last_persisted_raw: bytes | None = None
sample_count = 0
frame_count = 0
+ session_token = secrets.randbits(64)
with ExitStack() as stack:
raw_capture = stack.enter_context(output.open("wb"))
@@ -110,6 +113,11 @@ async def capture(args: argparse.Namespace) -> int:
reassembler.reset()
while not fragments.empty():
fragments.get_nowait()
+ await client.write_gatt_char(
+ CONTROL_UUID,
+ encode_begin_session(session_token),
+ response=True,
+ )
await client.start_notify(DATA_UUID, on_fragment)
print("BLE connected and subscribed")
while not stop.is_set() and client.is_connected:
diff --git a/tools/trikke_ble.py b/tools/trikke_ble.py
index de7c114..b583472 100644
--- a/tools/trikke_ble.py
+++ b/tools/trikke_ble.py
@@ -6,6 +6,7 @@ import struct
BLE_FRAGMENT_HEADER = struct.Struct(" bytes:
return BLE_ACK.pack(b"ACK1", packet_sequence & 0xFFFFFFFF)
+
+
+def encode_begin_session(session_token: int) -> bytes:
+ return BLE_BEGIN_SESSION.pack(b"BGN1", session_token & 0xFFFFFFFFFFFFFFFF)