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)