Add reliable Android BLE ride recorder

This commit is contained in:
Jay
2026-08-20 12:40:39 -04:00
parent 3bad2275cc
commit 6e408a3048
40 changed files with 2781 additions and 120 deletions
+3
View File
@@ -6,3 +6,6 @@ sdkconfig.old
managed_components/ managed_components/
__pycache__/ __pycache__/
.DS_Store .DS_Store
android/.gradle/
android/local.properties
android/app/build/
+68 -19
View File
@@ -12,9 +12,10 @@ This milestone does four things:
4. Maps both sensors into a shared enclosure frame and carries the metadata 4. Maps both sensors into a shared enclosure frame and carries the metadata
needed to derive calibrated readings without replacing raw data. needed to derive calibrated readings without replacing raw data.
The wired sensor path is proven. This milestone adds the first reliable BLE The wired sensor path and reliable BLE transport are proven. The repository now
transport and a macOS-compatible reference capture client; phone-side storage also contains an Android 12+ prototype recorder that stores the authoritative
remains the next consumer implementation. binary stream durably before acknowledging each frame. The macOS reference
client remains available for transport diagnosis.
## Wiring ## 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 timestamps rather than assume a uniform 10 ms interval. Sensor DRDY/FIFO
acquisition remains the later refinement for reducing the jitter itself. acquisition remains the later refinement for reducing the jitter itself.
The ESP32-C3 schedules nominal 100 Hz polling in a dedicated acquisition task, The 200-second Android acceptance capture provides the current loaded-system
but each characterization. Of 19,983 contiguous intervals, 8,745 (43.8%) were off-grid.
sensor has an independent internal 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 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 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 overruns. Hardware data-ready interrupts and FIFO acquisition are deferred to the
later sensor-side acquisition refinement. later sensor-side acquisition refinement.
Completed samples enter a 1024-record RAM queue, providing 10.24 seconds of Completed samples enter a statically reserved 3072-record RAM queue, providing
transport-outage tolerance at 100 Hz when the transport reports backpressure or 30.72 seconds of transport-outage tolerance at 100 Hz when the transport reports
failure accurately. A failed write retains and retries its packet while this backpressure or failure accurately. A failed write retains and retries its
queue accumulates the backlog. A lower-priority output task batches up to eight packet while this queue accumulates the backlog. A lower-priority output task
records into versioned `TRK1` frames, isolating acquisition from brief transport batches up to eight records into versioned `TRK1` frames, isolating acquisition
stalls. CRC, packet and sample sequences, timestamps, and cumulative from brief transport stalls. CRC, packet and sample sequences, timestamps, and
loss/overrun counters make permanent loss detectable by the receiver. 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`. The default build exposes a custom NimBLE GATT service named `TrikkeSensor`.
Each unchanged `TRK1` frame is fragmented as needed, persisted by the receiver, Acquisition remains stopped until a valid session-token handshake establishes a
and then acknowledged by exact packet sequence. A missing ACK causes a replay; clean capture boundary. Each unchanged `TRK1` frame is then fragmented as needed,
disconnect or subscription loss retains the same frame and restarts it from byte persisted by the receiver, and acknowledged by exact packet sequence. A missing
zero after reconnection. The receiver deduplicates these deliberate replays. 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 The preserved USB telemetry option uses ESP-IDF's interrupt-driven USB
Serial/JTAG driver behind Serial/JTAG driver behind
a transport-neutral state machine. A complete frame is submitted atomically to a transport-neutral state machine. A complete frame is submitted atomically to
the driver ring and remains pending across bounded drain timeouts; firmware does 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. therefore also protects a connected endpoint that temporarily stops draining.
USB drain confirms that bytes left the device endpoint, not that the capture 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` The client scans for `TrikkeSensor`, stores only complete CRC-valid `TRK1`
frames, flushes the binary and CSV outputs, and only then writes the application 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 ## TRK1 output and USB validation
+124
View File
@@ -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 1530 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.
+49
View File
@@ -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")
}
+1
View File
@@ -0,0 +1 @@
# Prototype v0 keeps release builds unobfuscated for diagnosability.
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.TrikkeRecorder">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".TelemetryService"
android:exported="false"
android:foregroundServiceType="connectedDevice"
android:stopWithTask="false" />
</application>
</manifest>
@@ -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
}
}
@@ -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<out String>,
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
}
}
@@ -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
}
}
@@ -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
}
@@ -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<Long, Long> {
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
}
}
}
}
@@ -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}"
}
}
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#00695C"
android:pathData="M4,4h40v40h-40z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M10,12h28v6h-11v20h-6v-20h-11z" />
</vector>
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">Trikke Recorder</string>
<string name="notification_channel_name">Ride recording</string>
<string name="export_capture">Export last .trk</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<style name="Theme.TrikkeRecorder" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#00695C</item>
<item name="android:navigationBarColor">#10201D</item>
<item name="android:statusBarColor">#004D40</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
</device-transfer>
</data-extraction-rules>
@@ -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<ByteArray> {
val frames = mutableListOf<ByteArray>()
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)
}
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.2.1" apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
+93
View File
@@ -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
+18
View File
@@ -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")
+11 -8
View File
@@ -106,15 +106,17 @@ does not by itself prove corruption or a hostile receiver.
## Buffering ## Buffering
Acquisition runs in a dedicated higher-priority task and writes complete samples Under BLE, acquisition does not start until the version-2 session handshake has
to a 1024-entry RAM queue. The lower-priority output task batches up to eight established a clean capture boundary. Once started, the dedicated
records per frame. At 100 Hz this queue represents about 10.24 seconds of 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 decoupling when the transport reports backpressure or failure accurately. A
failed write retains and retries the same encoded packet rather than dequeuing failed write retains and retries the same encoded packet rather than dequeuing
more samples, so the queue accumulates the outage backlog. After reconnection, more samples, so the queue accumulates the outage backlog. After a same-token
the oldest retained data is sent first. If the queue fills, acquisition drops reconnection, the oldest retained data is sent first. If the queue fills,
new samples rather than overwriting older ones; sequence gaps and the cumulative acquisition drops new samples rather than overwriting older ones; sequence gaps
lost-sample counter expose that permanent loss. and the cumulative lost-sample counter expose that permanent loss.
The shared transport state machine distinguishes three nonfatal states. `RETRY` The shared transport state machine distinguishes three nonfatal states. `RETRY`
is valid only from initial submission: it means zero bytes were accepted and the 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 but an application acknowledgement and replay window are still required to
guarantee receipt. Accordingly, USB `COMPLETE` means endpoint drain, while guarantee receipt. Accordingly, USB `COMPLETE` means endpoint drain, while
reliable BLE reserves `COMPLETE` for an application ACK of the exact frame. See 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. 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. Those bytes cannot pass CRC validation and are not silently admitted as samples.
-68
View File
@@ -1,68 +0,0 @@
# Reliable BLE Transport — Version 1
BLE carries the unchanged, CRC-protected `TRK1` frames defined in
`binary-record-v1.md`. The default peripheral name is `TrikkeSensor`.
## GATT service
| Purpose | UUID | Properties |
| --- | --- | --- |
| Service | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10` | Primary service |
| Data | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11` | Notify |
| ACK | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12` | Write, write without response |
The firmware prefers a 256-byte ATT MTU, allowing the largest 196-byte `TRK1`
frame and its eight-byte BLE envelope to fit in one notification. Smaller MTUs
remain protocol-compatible; firmware sends at most eight fragments per bounded
poll, although sustained 100 Hz delivery still depends on the negotiated link.
## Data notification envelope
Every notification starts with an eight-byte little-endian envelope:
| Offset | Size | Field |
| ---: | ---: | --- |
| 0 | 4 | `TRK1` packet sequence |
| 4 | 2 | Byte offset within the complete `TRK1` frame |
| 6 | 2 | Complete `TRK1` frame size |
| 8 | remaining | Consecutive frame bytes at that offset |
Offset zero starts or restarts a frame. A receiver appends only consecutive
offsets for the same sequence and total size, then validates the complete
`TRK1` header and CRC. A malformed or missing fragment is not acknowledged.
## Application ACK and replay
After validating and persisting a frame, the receiver writes exactly eight bytes
to the ACK characteristic: ASCII `ACK1`, then the acknowledged packet sequence
as little-endian uint32. Firmware accepts an ACK only for the frame it currently
owns and only from the active subscribed connection.
`COMPLETE` is not reported to the output task until that ACK arrives. Until then:
- a one-second ACK timeout replays the frame from offset zero;
- disconnect or notification unsubscription preserves the frame;
- the next subscription replays it from offset zero;
- BLE polling returns `PENDING`, never `RETRY`, after ownership begins.
The ACK itself can be lost after the receiver persisted the frame. Receivers
therefore compare the sequence and raw bytes with their last persisted frame,
avoid writing a duplicate, and ACK the replay again. The reference
`tools/capture_ble.py` implements this ordering.
The rejected-ACK counter is diagnostic, not a pure corruption count. A valid
duplicate ACK can arrive after the output task has already completed that frame
and begun the next one; firmware then rejects and counts the now-stale write.
BLE notification success only means the fragment entered the stack. The `ACK1`
write is the end-to-end boundary. It deliberately confirms application
persistence rather than radio or ATT delivery alone.
Version 1 is an unauthenticated, single-connection prototype service. It does
not yet provide pairing, authorization, or confidentiality against a nearby
peer; those are separate from the loss/replay guarantees above. A nearby peer
can also deny availability by subscribing and never acknowledging: firmware
correctly retains and replays the owned frame, but the RAM queue eventually
fills while the legitimate receiver remains excluded. Pairing and connection
authorization are required before treating this as a hostile-environment
logger.
+119
View File
@@ -0,0 +1,119 @@
# Reliable BLE Transport — Version 2
BLE carries the unchanged, CRC-protected `TRK1` frames defined in
`binary-record-v1.md`. The default peripheral name is `TrikkeSensor`. Version 2
adds a mandatory, idempotent recording-session handshake; the data envelope and
application ACK remain unchanged from version 1.
## GATT service
| Purpose | UUID | Properties |
| --- | --- | --- |
| Service | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10` | Primary service |
| Data | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11` | Notify |
| ACK | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12` | Write, write without response |
| Control | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c13` | Write |
The firmware prefers a 256-byte ATT MTU, allowing the largest 196-byte `TRK1`
frame and its eight-byte BLE envelope to fit in one notification. Smaller MTUs
remain protocol-compatible; firmware sends at most eight fragments per bounded
poll, although sustained 100 Hz delivery still depends on the negotiated link.
## Recording-session handshake
Every new capture generates a random uint64 session token. The reference
receivers first write exactly 12 bytes to Control—ASCII `BGN1`, then that token
as little-endian uint64—and enable data notifications after that write succeeds.
Firmware also accepts the reverse order: it tracks the raw CCCD state separately
from session authorization and treats the connection as subscribed as soon as
both conditions are true. Notifications never flow before authorization.
When the token differs from the C3's active token, firmware stores it in
RTC-retained memory and performs a controlled software restart. Acquisition and
output tasks remain stopped after boot. The receiver reconnects and repeats the
same `BGN1` write; firmware recognizes the retained token, authorizes that BLE
connection, starts acquisition with empty queues and zeroed volatile counters,
and accepts the subsequent notification subscription.
The Android acceptance capture measured 10.797 seconds from the user's Start
action to the first durably persisted frame. That includes the initial control
write, controlled restart, advertising and scan latency, GATT reconnection,
same-token authorization, notification subscription, and first frame delivery.
This startup interval is expected and is not part of the recorded sensor stream.
The same token must be written on every reconnect during one recording. That
write is idempotent: it authorizes the new connection without restarting or
discarding the in-flight frame and sample backlog. A different token is an
explicit new-session boundary and deliberately discards all prior volatile
state through the controlled restart.
Firmware does not start acquisition after boot and does not honor a notification
subscription until a valid control write authorizes the connection. This avoids
pre-session queue overflow and prevents an older client from bypassing the
session boundary. The token provides idempotence, not authentication or secrecy.
RTC token recovery is intentionally accepted only after `ESP_RST_SW`. A panic,
watchdog, brownout, or power-on reset discards the token and all volatile stream
state. When the still-recording receiver reconnects and rewrites its unchanged
token, firmware treats it as a new token, performs one additional controlled
restart, and authorizes the following same-token reconnect. Packet and sample
sequences restart at zero inside the receiver's existing file, where the reset is
observable through integrity tracking. This favors a known clean state over
silently treating an uncontrolled reset as continuation of the old session.
## Data notification envelope
Every notification starts with an eight-byte little-endian envelope:
| Offset | Size | Field |
| ---: | ---: | --- |
| 0 | 4 | `TRK1` packet sequence |
| 4 | 2 | Byte offset within the complete `TRK1` frame |
| 6 | 2 | Complete `TRK1` frame size |
| 8 | remaining | Consecutive frame bytes at that offset |
Offset zero starts or restarts a frame. A receiver appends only consecutive
offsets for the same sequence and total size, then validates the complete
`TRK1` header and CRC. A malformed or missing fragment is not acknowledged.
## Application ACK and replay
After validating and persisting a frame, the receiver writes exactly eight bytes
to the ACK characteristic: ASCII `ACK1`, then the acknowledged packet sequence
as little-endian uint32. Firmware accepts an ACK only for the frame it currently
owns and only from the active subscribed connection.
`COMPLETE` is not reported to the output task until that ACK arrives. Until then:
- a one-second ACK timeout replays the frame from offset zero;
- disconnect or notification unsubscription preserves the frame;
- the next subscription, after the same-token control write, replays it from
offset zero;
- BLE polling returns `PENDING`, never `RETRY`, after ownership begins.
The ACK itself can be lost after the receiver persisted the frame. Receivers
therefore compare the sequence and raw bytes with their last persisted frame,
avoid writing a duplicate, and ACK the replay again. Both reference receivers
implement this ordering.
The rejected-ACK counter is diagnostic, not a pure corruption count. A valid
duplicate ACK can arrive after the output task has already completed that frame
and begun the next one; firmware then rejects and counts the now-stale write.
BLE notification success only means the fragment entered the stack. The `ACK1`
write is the end-to-end boundary. It deliberately confirms application
persistence rather than radio or ATT delivery alone.
The current firmware reserves a 3072-sample acquisition queue, providing 30.72
seconds of transport-outage tolerance at the nominal 100 Hz rate. This interval
includes link-loss detection, scanning, GATT reconnection, session
reauthorization, notification subscription, and replay—not merely the time a
phone's Bluetooth control is visibly off. Longer interruptions remain bounded
and detectable through sample-sequence gaps and the queue-overflow counter.
Version 2 remains an unauthenticated, single-connection prototype service. It
does not yet provide pairing, authorization, or confidentiality against a nearby
peer; those are separate from the session and replay guarantees above. A nearby
peer can also deny availability by connecting and withholding the control write,
or by subscribing and never acknowledging. Pairing and connection authorization
are required before treating this as a hostile-environment logger.
+34
View File
@@ -15,6 +15,12 @@ static uint32_t get_u32_le(const uint8_t *input)
((uint32_t)input[2] << 16) | ((uint32_t)input[3] << 24); ((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) static void put_u16_le(uint8_t *output, uint16_t value)
{ {
output[0] = (uint8_t)value; output[0] = (uint8_t)value;
@@ -89,3 +95,31 @@ bool trikke_ble_decode_ack(
*packet_sequence = get_u32_le(ack + 4); *packet_sequence = get_u32_le(ack + 4);
return true; 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;
}
+15
View File
@@ -10,6 +10,7 @@ extern "C" {
#define TRIKKE_BLE_FRAGMENT_HEADER_SIZE 8 #define TRIKKE_BLE_FRAGMENT_HEADER_SIZE 8
#define TRIKKE_BLE_ACK_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, // BLE data notifications carry a little-endian packet sequence, byte offset,
// total TRK1 frame size, then the frame bytes at that offset. The unchanged // 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, size_t ack_size,
uint32_t *packet_sequence); 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 #ifdef __cplusplus
} }
#endif #endif
+163 -6
View File
@@ -8,6 +8,8 @@
#include "host/ble_hs.h" #include "host/ble_hs.h"
#include "host/ble_uuid.h" #include "host/ble_uuid.h"
#include "host/util/util.h" #include "host/util/util.h"
#include "esp_attr.h"
#include "esp_system.h"
#include "esp_timer.h" #include "esp_timer.h"
#include "nimble/nimble_port.h" #include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h" #include "nimble/nimble_port_freertos.h"
@@ -22,11 +24,24 @@
#define TRIKKE_BLE_MAX_ATT_PAYLOAD 253 #define TRIKKE_BLE_MAX_ATT_PAYLOAD 253
#define TRIKKE_BLE_FRAGMENTS_PER_POLL 8 #define TRIKKE_BLE_FRAGMENTS_PER_POLL 8
#define TRIKKE_BLE_ACK_TIMEOUT_US 1000000 #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 trikke_ble_transport_t *s_ble;
static uint8_t s_own_address_type; static uint8_t s_own_address_type;
static uint16_t s_data_value_handle; static uint16_t s_data_value_handle;
static uint16_t s_ack_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. // 7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10 and adjacent characteristic UUIDs.
static const ble_uuid128_t TRIKKE_SERVICE_UUID = 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 = static const ble_uuid128_t TRIKKE_ACK_UUID =
BLE_UUID128_INIT(0x12, 0x9c, 0x1e, 0x2a, 0x4c, 0x3d, 0xbe, 0x8f, BLE_UUID128_INIT(0x12, 0x9c, 0x1e, 0x2a, 0x4c, 0x3d, 0xbe, 0x8f,
0x9b, 0x4a, 0x5b, 0xf7, 0x00, 0xa0, 0x2e, 0x7d); 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) 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); ((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( static int data_access(
uint16_t connection_handle, uint16_t connection_handle,
uint16_t attribute_handle, uint16_t attribute_handle,
@@ -102,6 +177,69 @@ static int ack_access(
return accepted ? 0 : BLE_ATT_ERR_UNLIKELY; 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[] = { static const struct ble_gatt_svc_def TRIKKE_GATT_SERVICES[] = {
{ {
.type = BLE_GATT_SVC_TYPE_PRIMARY, .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, .flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP,
.val_handle = &s_ack_value_handle, .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}, {0},
}, },
}, },
@@ -169,7 +313,9 @@ static void on_reset(int reason)
++ble->counters.disconnect_count; ++ble->counters.disconnect_count;
} }
ble->connected = false; ble->connected = false;
ble->notify_enabled = false;
ble->subscribed = false; ble->subscribed = false;
ble->session_connection_ready = false;
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE; ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
++ble->delivery_epoch; ++ble->delivery_epoch;
portEXIT_CRITICAL(&ble->lock); portEXIT_CRITICAL(&ble->lock);
@@ -197,7 +343,9 @@ static int gap_event(struct ble_gap_event *event, void *argument)
if (event->connect.status == 0) { if (event->connect.status == 0) {
portENTER_CRITICAL(&ble->lock); portENTER_CRITICAL(&ble->lock);
ble->connected = true; ble->connected = true;
ble->notify_enabled = false;
ble->subscribed = false; ble->subscribed = false;
ble->session_connection_ready = false;
ble->connection_handle = event->connect.conn_handle; ble->connection_handle = event->connect.conn_handle;
++ble->delivery_epoch; ++ble->delivery_epoch;
portEXIT_CRITICAL(&ble->lock); portEXIT_CRITICAL(&ble->lock);
@@ -212,7 +360,9 @@ static int gap_event(struct ble_gap_event *event, void *argument)
++ble->counters.disconnect_count; ++ble->counters.disconnect_count;
} }
ble->connected = false; ble->connected = false;
ble->notify_enabled = false;
ble->subscribed = false; ble->subscribed = false;
ble->session_connection_ready = false;
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE; ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
++ble->delivery_epoch; ++ble->delivery_epoch;
portEXIT_CRITICAL(&ble->lock); portEXIT_CRITICAL(&ble->lock);
@@ -222,11 +372,8 @@ static int gap_event(struct ble_gap_event *event, void *argument)
case BLE_GAP_EVENT_SUBSCRIBE: case BLE_GAP_EVENT_SUBSCRIBE:
if (event->subscribe.attr_handle == s_data_value_handle) { if (event->subscribe.attr_handle == s_data_value_handle) {
portENTER_CRITICAL(&ble->lock); portENTER_CRITICAL(&ble->lock);
const bool subscribed = event->subscribe.cur_notify != 0; ble->notify_enabled = event->subscribe.cur_notify != 0;
if (subscribed != ble->subscribed) { update_subscribed_locked(ble);
ble->subscribed = subscribed;
++ble->delivery_epoch;
}
portEXIT_CRITICAL(&ble->lock); portEXIT_CRITICAL(&ble->lock);
} }
return 0; return 0;
@@ -431,7 +578,9 @@ static trikke_transport_status_t ble_poll_packet(void *context)
esp_err_t trikke_ble_transport_init( esp_err_t trikke_ble_transport_init(
trikke_ble_transport_t *ble, 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) { if (ble == NULL || transport == NULL) {
return ESP_ERR_INVALID_ARG; return ESP_ERR_INVALID_ARG;
@@ -443,6 +592,14 @@ esp_err_t trikke_ble_transport_init(
memset(ble, 0, sizeof(*ble)); memset(ble, 0, sizeof(*ble));
ble->lock = (portMUX_TYPE)portMUX_INITIALIZER_UNLOCKED; ble->lock = (portMUX_TYPE)portMUX_INITIALIZER_UNLOCKED;
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE; 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; s_ble = ble;
esp_err_t error = nvs_flash_init(); esp_err_t error = nvs_flash_init();
+13 -1
View File
@@ -19,15 +19,23 @@ typedef struct {
uint32_t invalid_ack_count; uint32_t invalid_ack_count;
} trikke_ble_transport_counters_t; } trikke_ble_transport_counters_t;
typedef void (*trikke_ble_session_ready_fn)(void *context);
typedef struct { typedef struct {
portMUX_TYPE lock; portMUX_TYPE lock;
bool initialized; bool initialized;
bool connected; bool connected;
bool notify_enabled;
bool subscribed; bool subscribed;
bool frame_active; bool frame_active;
bool frame_fully_sent_once; bool frame_fully_sent_once;
bool ack_received; bool ack_received;
bool session_token_valid;
bool session_connection_ready;
bool session_started;
bool session_restart_pending;
uint16_t connection_handle; uint16_t connection_handle;
uint64_t session_token;
uint32_t delivery_epoch; uint32_t delivery_epoch;
uint32_t frame_epoch; uint32_t frame_epoch;
uint32_t frame_sequence; uint32_t frame_sequence;
@@ -35,12 +43,16 @@ typedef struct {
size_t frame_size; size_t frame_size;
size_t next_offset; size_t next_offset;
int64_t ack_deadline_us; 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_counters_t counters;
} trikke_ble_transport_t; } trikke_ble_transport_t;
esp_err_t trikke_ble_transport_init( esp_err_t trikke_ble_transport_init(
trikke_ble_transport_t *ble, 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( void trikke_ble_transport_get_counters(
trikke_ble_transport_t *ble, trikke_ble_transport_t *ble,
+37 -16
View File
@@ -28,7 +28,7 @@
#define TRIKKE_I2C_FREQ_HZ 400000 #define TRIKKE_I2C_FREQ_HZ 400000
#define TRIKKE_SAMPLE_RATE_HZ 100 #define TRIKKE_SAMPLE_RATE_HZ 100
#define TRIKKE_SAMPLE_TICKS pdMS_TO_TICKS(1000 / TRIKKE_SAMPLE_RATE_HZ) #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_METADATA_INTERVAL_PACKETS 64
#define TRIKKE_STATUS_INTERVAL_PACKETS 64 #define TRIKKE_STATUS_INTERVAL_PACKETS 64
#define TRIKKE_TRANSPORT_RETRY_DELAY_MS 10 #define TRIKKE_TRANSPORT_RETRY_DELAY_MS 10
@@ -59,9 +59,14 @@ typedef struct {
adxl345_t accelerometer; adxl345_t accelerometer;
l3g4200d_t gyroscope; l3g4200d_t gyroscope;
QueueHandle_t sample_queue; 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 sensor_read_failure_count;
atomic_uint_least32_t queue_overflow_count; atomic_uint_least32_t queue_overflow_count;
atomic_uint_least32_t loop_overrun_count; atomic_uint_least32_t loop_overrun_count;
TaskHandle_t output_task_handle;
TaskHandle_t acquisition_task_handle;
trikke_transport_t transport; trikke_transport_t transport;
#if CONFIG_TRIKKE_TRANSPORT_BLE #if CONFIG_TRIKKE_TRANSPORT_BLE
trikke_ble_transport_t ble_transport; trikke_ble_transport_t ble_transport;
@@ -147,6 +152,18 @@ static trikke_wire_status_t status_snapshot(
return status; 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) static void acquisition_task(void *argument)
{ {
trikke_context_t *context = 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); usb_serial_jtag_vfs_set_tx_line_endings(ESP_LINE_ENDINGS_LF);
#endif #endif
s_context.sample_queue = s_context.sample_queue = xQueueCreateStatic(
xQueueCreate(TRIKKE_SAMPLE_QUEUE_DEPTH, sizeof(trikke_wire_sample_t)); 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) { if (s_context.sample_queue == NULL) {
ESP_LOGE(TAG, "sample queue allocation failed"); ESP_LOGE(TAG, "sample queue allocation failed");
l3g4200d_deinit(&s_context.gyroscope); l3g4200d_deinit(&s_context.gyroscope);
@@ -417,17 +437,15 @@ void app_main(void)
return; return;
} }
TaskHandle_t output_task_handle = NULL;
TaskHandle_t acquisition_task_handle = NULL;
if (xTaskCreate(output_task, "trikke_output", 4096, &s_context, 5, 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, xTaskCreate(acquisition_task, "trikke_acquire", 4096, &s_context, 10,
&acquisition_task_handle) != pdPASS) { &s_context.acquisition_task_handle) != pdPASS) {
if (output_task_handle != NULL) { if (s_context.output_task_handle != NULL) {
vTaskDelete(output_task_handle); vTaskDelete(s_context.output_task_handle);
} }
if (acquisition_task_handle != NULL) { if (s_context.acquisition_task_handle != NULL) {
vTaskDelete(acquisition_task_handle); vTaskDelete(s_context.acquisition_task_handle);
} }
vQueueDelete(s_context.sample_queue); vQueueDelete(s_context.sample_queue);
ESP_LOGE(TAG, "telemetry task creation failed"); ESP_LOGE(TAG, "telemetry task creation failed");
@@ -439,14 +457,15 @@ void app_main(void)
#if CONFIG_TRIKKE_TRANSPORT_BLE #if CONFIG_TRIKKE_TRANSPORT_BLE
err = trikke_ble_transport_init( err = trikke_ble_transport_init(
&s_context.ble_transport, &s_context.transport); &s_context.ble_transport, &s_context.transport,
session_ready, &s_context);
#else #else
err = trikke_usb_transport_init( err = trikke_usb_transport_init(
&s_context.usb_transport, &s_context.transport); &s_context.usb_transport, &s_context.transport);
#endif #endif
if (err != ESP_OK) { if (err != ESP_OK) {
vTaskDelete(output_task_handle); vTaskDelete(s_context.output_task_handle);
vTaskDelete(acquisition_task_handle); vTaskDelete(s_context.acquisition_task_handle);
vQueueDelete(s_context.sample_queue); vQueueDelete(s_context.sample_queue);
#if CONFIG_TRIKKE_TRANSPORT_BLE #if CONFIG_TRIKKE_TRANSPORT_BLE
ESP_LOGE(TAG, "BLE transport initialization failed: %s", 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. // No text may share the byte stream once framed binary output begins.
esp_log_level_set("*", ESP_LOG_NONE); esp_log_level_set("*", ESP_LOG_NONE);
xTaskNotifyGive(output_task_handle); #if !CONFIG_TRIKKE_TRANSPORT_BLE
xTaskNotifyGive(acquisition_task_handle); xTaskNotifyGive(s_context.output_task_handle);
xTaskNotifyGive(s_context.acquisition_task_handle);
#endif
} }
+37
View File
@@ -66,5 +66,42 @@ int main(void)
sizeof(packet), 0, 8) != 0) { sizeof(packet), 0, 8) != 0) {
return fail(4, "invalid input rejection"); 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; return 0;
} }
+29
View File
@@ -78,6 +78,35 @@ removed before production firmware was built and flashed.
beyond the 10.24-second queue window, so sequences 1,024 through 4,021 were 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, intentionally lost; the single 2,998-sample gap, matching queue-overflow total,
demonstrates bounded buffer exhaustion rather than a suspended output task. 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 `tests/test_trikke_protocol.py` verifies the hashes, parses the captures in
fragmented chunks, and asserts these signatures so the hardware evidence remains fragmented chunks, and asserts these signatures so the hardware evidence remains
+25
View File
@@ -0,0 +1,25 @@
{
"schema": 1,
"captureFile": "ride_20260820_090607.trk",
"sessionTokenHex": "77f213814e3c41da",
"complete": true,
"startWallClockMs": 1787231167523,
"startElapsedRealtimeNs": 2415494841543927,
"endWallClockMs": 1787231378168,
"endElapsedRealtimeNs": 2415705485735774,
"firstFrame": {"packetSequence":0,"deviceBaseTimestampUs":7652604,"phoneElapsedRealtimeNs":2415505638698090,"phoneWallClockMs":1787231178321},
"lastFrame": {"packetSequence":2577,"deviceBaseTimestampUs":207411553,"phoneElapsedRealtimeNs":2415705468358690,"phoneWallClockMs":1787231378150},
"frames": 2578,
"samples": 19984,
"duplicateReplays": 1,
"packetGaps": 0,
"packetResets": 0,
"sampleGaps": 0,
"sampleResets": 0,
"droppedSamples": 0,
"loopOverruns": 0,
"queueOverflows": 0,
"transportDisconnects": 1,
"transportReplays": 1,
"error": null
}
Binary file not shown.
+55 -1
View File
@@ -1,4 +1,5 @@
import hashlib import hashlib
import json
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -18,7 +19,7 @@ from trikke_protocol import ( # noqa: E402
StreamParser, StreamParser,
sample_to_csv_row, 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): class ProtocolContractTest(unittest.TestCase):
@@ -197,6 +198,10 @@ class ProtocolContractTest(unittest.TestCase):
self.assertIsNone(reassembler.feed(fragment(0, 40))) self.assertIsNone(reassembler.feed(fragment(0, 40)))
self.assertEqual(frame, reassembler.feed(fragment(40, len(frame) - 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"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.assertEqual(0, reassembler.rejected_fragment_count)
self.assertIsNone(reassembler.feed(fragment(20, 20))) self.assertIsNone(reassembler.feed(fragment(20, 20)))
@@ -327,6 +332,25 @@ class ProtocolContractTest(unittest.TestCase):
"transport_invalid_ack_count": 0, "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(): for name, contract in expected.items():
@@ -379,6 +403,11 @@ class ProtocolContractTest(unittest.TestCase):
contract["timing_anomalies"], contract["timing_anomalies"],
integrity.timing_anomaly_count, integrity.timing_anomaly_count,
) )
if "accel_overruns" in contract:
self.assertEqual(
contract["accel_overruns"],
integrity.accel_overrun_count,
)
self.assertEqual( self.assertEqual(
contract["max_dropped"], contract["max_dropped"],
integrity.final_dropped_sample_count, integrity.final_dropped_sample_count,
@@ -403,6 +432,31 @@ class ProtocolContractTest(unittest.TestCase):
] ]
self.assertEqual(contract["gaps"], gaps) 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: def test_exact_usb_raw_wire_evidence(self) -> None:
expected = { expected = {
"direct_usb_3c95f3d": { "direct_usb_3c95f3d": {
+9 -1
View File
@@ -7,12 +7,13 @@ import argparse
import asyncio import asyncio
import csv import csv
import os import os
import secrets
import signal import signal
from contextlib import ExitStack from contextlib import ExitStack
from datetime import datetime from datetime import datetime
from pathlib import Path 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 ( from trikke_protocol import (
CSV_COLUMNS, CSV_COLUMNS,
PACKET_TYPE_METADATA, PACKET_TYPE_METADATA,
@@ -27,6 +28,7 @@ DEVICE_NAME = "TrikkeSensor"
SERVICE_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10" SERVICE_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10"
DATA_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11" DATA_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11"
ACK_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12" ACK_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12"
CONTROL_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c13"
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
@@ -86,6 +88,7 @@ async def capture(args: argparse.Namespace) -> int:
last_persisted_raw: bytes | None = None last_persisted_raw: bytes | None = None
sample_count = 0 sample_count = 0
frame_count = 0 frame_count = 0
session_token = secrets.randbits(64)
with ExitStack() as stack: with ExitStack() as stack:
raw_capture = stack.enter_context(output.open("wb")) raw_capture = stack.enter_context(output.open("wb"))
@@ -110,6 +113,11 @@ async def capture(args: argparse.Namespace) -> int:
reassembler.reset() reassembler.reset()
while not fragments.empty(): while not fragments.empty():
fragments.get_nowait() 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) await client.start_notify(DATA_UUID, on_fragment)
print("BLE connected and subscribed") print("BLE connected and subscribed")
while not stop.is_set() and client.is_connected: while not stop.is_set() and client.is_connected:
+5
View File
@@ -6,6 +6,7 @@ import struct
BLE_FRAGMENT_HEADER = struct.Struct("<IHH") BLE_FRAGMENT_HEADER = struct.Struct("<IHH")
BLE_ACK = struct.Struct("<4sI") BLE_ACK = struct.Struct("<4sI")
BLE_BEGIN_SESSION = struct.Struct("<4sQ")
BLE_MIN_FRAME_SIZE = 36 BLE_MIN_FRAME_SIZE = 36
BLE_MAX_FRAME_SIZE = 196 BLE_MAX_FRAME_SIZE = 196
@@ -64,3 +65,7 @@ class BleFrameReassembler:
def encode_ack(packet_sequence: int) -> bytes: def encode_ack(packet_sequence: int) -> bytes:
return BLE_ACK.pack(b"ACK1", packet_sequence & 0xFFFFFFFF) 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)