Add reliable Android BLE ride recorder
This commit is contained in:
@@ -6,3 +6,6 @@ sdkconfig.old
|
||||
managed_components/
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
android/app/build/
|
||||
|
||||
@@ -12,9 +12,10 @@ This milestone does four things:
|
||||
4. Maps both sensors into a shared enclosure frame and carries the metadata
|
||||
needed to derive calibrated readings without replacing raw data.
|
||||
|
||||
The wired sensor path is proven. This milestone adds the first reliable BLE
|
||||
transport and a macOS-compatible reference capture client; phone-side storage
|
||||
remains the next consumer implementation.
|
||||
The wired sensor path and reliable BLE transport are proven. The repository now
|
||||
also contains an Android 12+ prototype recorder that stores the authoritative
|
||||
binary stream durably before acknowledging each frame. The macOS reference
|
||||
client remains available for transport diagnosis.
|
||||
|
||||
## Wiring
|
||||
|
||||
@@ -70,33 +71,59 @@ Future fusion, integration, and filtering must derive each `dt` from those
|
||||
timestamps rather than assume a uniform 10 ms interval. Sensor DRDY/FIFO
|
||||
acquisition remains the later refinement for reducing the jitter itself.
|
||||
|
||||
The ESP32-C3 schedules nominal 100 Hz polling in a dedicated acquisition task,
|
||||
but each
|
||||
sensor has an independent internal
|
||||
The 200-second Android acceptance capture provides the current loaded-system
|
||||
characterization. Of 19,983 contiguous intervals, 8,745 (43.8%) were off-grid.
|
||||
Across all intervals, absolute deviation was 0 us median, 40 us p95, 240 us p99,
|
||||
and 1,630 us maximum; among off-grid intervals it was 10 us median, 60 us p95,
|
||||
and 750 us p99. Cumulative error was -726 us. These timestamp statistics are a
|
||||
lower bound on acquisition disturbance because firmware timestamps the poll
|
||||
before performing both I2C reads. Six ADXL345 overrun flags show that preemption
|
||||
between the timestamp and the physical read occasionally crossed a sensor sample
|
||||
boundary even though the recorded timestamp delta did not expose the full delay.
|
||||
The loss is rare (0.03%), explicitly flagged, and reinforces the planned
|
||||
data-ready/FIFO acquisition refinement.
|
||||
|
||||
After the BLE session handshake, the ESP32-C3 schedules nominal 100 Hz polling
|
||||
in a dedicated acquisition task, but each sensor has an independent internal
|
||||
sample clock. The status registers are read immediately before each XYZ read so a
|
||||
consumer can distinguish a fresh sample from a repeated poll and identify gyro
|
||||
overruns. Hardware data-ready interrupts and FIFO acquisition are deferred to the
|
||||
later sensor-side acquisition refinement.
|
||||
|
||||
Completed samples enter a 1024-record RAM queue, providing 10.24 seconds of
|
||||
transport-outage tolerance at 100 Hz when the transport reports backpressure or
|
||||
failure accurately. A failed write retains and retries its packet while this
|
||||
queue accumulates the backlog. A lower-priority output task batches up to eight
|
||||
records into versioned `TRK1` frames, isolating acquisition from brief transport
|
||||
stalls. CRC, packet and sample sequences, timestamps, and cumulative
|
||||
loss/overrun counters make permanent loss detectable by the receiver.
|
||||
Completed samples enter a statically reserved 3072-record RAM queue, providing
|
||||
30.72 seconds of transport-outage tolerance at 100 Hz when the transport reports
|
||||
backpressure or failure accurately. A failed write retains and retries its
|
||||
packet while this queue accumulates the backlog. A lower-priority output task
|
||||
batches up to eight records into versioned `TRK1` frames, isolating acquisition
|
||||
from brief transport stalls. CRC, packet and sample sequences, timestamps, and
|
||||
cumulative loss/overrun counters make permanent loss detectable by the receiver.
|
||||
|
||||
The 30.72-second depth is based on end-to-end phone behavior rather than the
|
||||
visible Bluetooth-off interval alone. A five-second Android Bluetooth outage
|
||||
produced roughly 12.5 seconds of effective transport interruption and overflowed
|
||||
the prior 10.24-second queue by 223 samples. Repeating the same test with the
|
||||
3,072-record queue delivered all 19,984 samples contiguously; one replay was
|
||||
durably deduplicated and all loss counters remained zero. The capture and its
|
||||
completed Android sidecar are preserved under `tests/fixtures/`.
|
||||
|
||||
The static queue occupies 96 KiB. The BLE build leaves 132,389 bytes of DRAM for
|
||||
task stacks, NimBLE runtime allocation, and future features; the 200-second
|
||||
acceptance capture proves the present configuration, but another large buffer or
|
||||
memory-heavy feature requires a fresh runtime and build-time memory review.
|
||||
|
||||
The default build exposes a custom NimBLE GATT service named `TrikkeSensor`.
|
||||
Each unchanged `TRK1` frame is fragmented as needed, persisted by the receiver,
|
||||
and then acknowledged by exact packet sequence. A missing ACK causes a replay;
|
||||
disconnect or subscription loss retains the same frame and restarts it from byte
|
||||
zero after reconnection. The receiver deduplicates these deliberate replays.
|
||||
Acquisition remains stopped until a valid session-token handshake establishes a
|
||||
clean capture boundary. Each unchanged `TRK1` frame is then fragmented as needed,
|
||||
persisted by the receiver, and acknowledged by exact packet sequence. A missing
|
||||
ACK causes a replay; disconnect or subscription loss retains the same frame and
|
||||
restarts it from byte zero after a same-token reconnection. The receiver
|
||||
deduplicates these deliberate replays.
|
||||
|
||||
The preserved USB telemetry option uses ESP-IDF's interrupt-driven USB
|
||||
Serial/JTAG driver behind
|
||||
a transport-neutral state machine. A complete frame is submitted atomically to
|
||||
the driver ring and remains pending across bounded drain timeouts; firmware does
|
||||
not resubmit it ambiguously or dequeue another frame. The 1024-sample queue
|
||||
not resubmit it ambiguously or dequeue another frame. The 3072-sample queue
|
||||
therefore also protects a connected endpoint that temporarily stops draining.
|
||||
|
||||
USB drain confirms that bytes left the device endpoint, not that the capture
|
||||
@@ -141,7 +168,29 @@ python3 tools/capture_ble.py
|
||||
|
||||
The client scans for `TrikkeSensor`, stores only complete CRC-valid `TRK1`
|
||||
frames, flushes the binary and CSV outputs, and only then writes the application
|
||||
ACK. See [the BLE transport specification](docs/ble-transport-v1.md).
|
||||
ACK. See [the BLE transport specification](docs/ble-transport-v2.md).
|
||||
|
||||
## Android recorder
|
||||
|
||||
The `android/` project is the prototype ride recorder. Each new recording sends
|
||||
an idempotent session token that gives the C3 a repeatable software session
|
||||
boundary with empty queues, reset counters, and acquisition starting only after
|
||||
the phone is ready. Reconnects reuse that token and preserve the outage backlog.
|
||||
The app reassembles and validates complete frames, appends each new frame to
|
||||
app-private storage, calls `fsync`, and only then sends `ACK1`. Exact replays are
|
||||
acknowledged without being appended twice. Recording runs in a connected-device
|
||||
foreground service with a partial wake lock so it can continue while the screen
|
||||
is off.
|
||||
|
||||
Build its debug APK with:
|
||||
|
||||
```sh
|
||||
cd android
|
||||
./gradlew testDebugUnitTest lintDebug assembleDebug
|
||||
```
|
||||
|
||||
See [the Android recorder guide](android/README.md) for installation, capture,
|
||||
export, and the first coordinated phone test.
|
||||
|
||||
## TRK1 output and USB validation
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Trikke Recorder for Android
|
||||
|
||||
Prototype v0.2 records the existing reliable BLE transport to an authoritative
|
||||
`.trk` file on an Android phone. It deliberately does not filter, fuse, convert,
|
||||
or upload telemetry.
|
||||
|
||||
The permanent Android application ID and Kotlin namespace are
|
||||
`com.jsjdesigns.trikkerecorder`.
|
||||
|
||||
## Platform and behavior
|
||||
|
||||
- Android 12 (API 31) or newer
|
||||
- BLE central connection to the `TrikkeSensor` service
|
||||
- User-started connected-device foreground service
|
||||
- Partial wake lock during an active recording
|
||||
- App-private capture storage with explicit export through Android's document UI
|
||||
- A `.session.json` sidecar containing phone wall/monotonic clock anchors and
|
||||
final integrity counters
|
||||
|
||||
Every notification is reassembled using its packet sequence, offset, and total
|
||||
size. A complete frame must also pass the TRK1 shape and CRC checks. For a new
|
||||
frame the recorder then performs this ordering:
|
||||
|
||||
1. Append the unchanged frame to the `.trk` file.
|
||||
2. Flush the stream and synchronize its file descriptor.
|
||||
3. Update in-memory integrity state.
|
||||
4. Write the exact `ACK1` packet sequence to the C3.
|
||||
|
||||
If the ACK is lost, the firmware replays the frame. An exact replay of the last
|
||||
persisted packet is not appended again, but it is acknowledged again. The app
|
||||
refuses to acknowledge a CRC-invalid frame or the same sequence carrying
|
||||
different bytes.
|
||||
|
||||
At Start, the app generates a random session token and sends it to the Control
|
||||
characteristic before subscribing. A new token makes the C3 perform one
|
||||
controlled software restart. The app reconnects with the same token, after which
|
||||
the C3 starts acquisition with empty queues and zeroed counters. Temporary BLE
|
||||
reconnects during that recording reuse the token and therefore preserve queued
|
||||
samples instead of resetting the session.
|
||||
|
||||
The validated phone/C3 pair takes about 10.8 seconds from tapping Start to the
|
||||
first persisted frame because a new session deliberately includes a controlled
|
||||
C3 restart and two connection passes. A panic, watchdog, brownout, or power-on
|
||||
reset during recording invalidates the retained token; recovery adds another
|
||||
controlled restart, and the resulting packet/sample sequence reset remains
|
||||
visible in the sidecar integrity counters.
|
||||
|
||||
## Build and install
|
||||
|
||||
Android Studio is the easiest route: open the `android/` directory, allow the
|
||||
Gradle sync, select the phone, and run the `app` configuration.
|
||||
|
||||
The command-line equivalent on this Mac is:
|
||||
|
||||
```sh
|
||||
cd android
|
||||
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
export ANDROID_HOME="$HOME/Library/Android/sdk"
|
||||
./gradlew testDebugUnitTest lintDebug assembleDebug
|
||||
"$ANDROID_HOME/platform-tools/adb" install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
The tests exercise the Android parser against the real
|
||||
`ble_mtu_race_4bf00eb.trk` hardware fixture as well as fragment replay, malformed
|
||||
ordering, CRC rejection, unsigned sequence wrap, durable append, and replay
|
||||
deduplication.
|
||||
|
||||
## Record and export
|
||||
|
||||
1. Power the sensor and open **Trikke Recorder**.
|
||||
2. Tap **Start recording** and grant Nearby Devices and notification permission.
|
||||
3. Wait for `Recording: Connected and subscribed` and confirm that the sample
|
||||
count is increasing.
|
||||
4. The activity may be left, the screen may be locked, and the phone may be put
|
||||
in a pocket. Keep the persistent recording notification active.
|
||||
5. Reopen the app and tap **Stop and close safely**.
|
||||
6. After the status reaches `Stopped`, tap **Export last .trk** and select a
|
||||
destination.
|
||||
|
||||
The app-private `.session.json` starts with `complete: false`. A normal Stop
|
||||
closes and synchronizes the binary file, then rewrites the sidecar with
|
||||
`complete: true`, final counts, and first/last phone-to-device clock anchors. If
|
||||
Android or the user force-stops the process, every previously acknowledged frame
|
||||
remains in the `.trk` file, while the incomplete sidecar makes the abnormal end
|
||||
visible.
|
||||
|
||||
Phone receipt time is only an alignment anchor; BLE delivery latency means it is
|
||||
not the sensor's physical sample time. Analysis must continue to use the device
|
||||
timestamps carried by each TRK1 frame.
|
||||
|
||||
## First coordinated acceptance test
|
||||
|
||||
Do this before a ride:
|
||||
|
||||
1. Record for two minutes with the enclosure flat and still.
|
||||
2. Lock the phone for at least one minute and verify the sample counter resumes
|
||||
visibly when the app is reopened.
|
||||
3. Cause a roughly three-second outage by switching Bluetooth off, switch it
|
||||
back on, and wait for `Recording` again.
|
||||
4. Stop and export the `.trk` file.
|
||||
5. Decode it from the repository root:
|
||||
|
||||
```sh
|
||||
python3 tools/decode_binary.py ride_YYYYMMDD_HHMMSS.trk ride.csv
|
||||
```
|
||||
|
||||
Acceptance requires a valid complete decode, no unexplained packet or sample
|
||||
gaps, and no queue overflow for the short interruption. Duplicate replays and
|
||||
the firmware disconnect/replay counters may increase and are expected.
|
||||
|
||||
After that passes, repeat for 15–30 minutes with the screen locked and include
|
||||
several short real-world range/interference interruptions.
|
||||
|
||||
## Prototype limitations
|
||||
|
||||
- The BLE service is still unauthenticated and single-connection, as documented
|
||||
in `docs/ble-transport-v2.md`.
|
||||
- The recorder supports one active session and one known sensor.
|
||||
- Only the binary capture is exported from the UI in this pass. The sidecar is
|
||||
retained app-private for diagnosis.
|
||||
- A phone force-stop cannot run cleanup code. The persisted binary prefix remains
|
||||
useful, but the session is intentionally marked incomplete.
|
||||
- No GPS, Samsung Health import, CSV rendering, or live motion analysis is in
|
||||
this milestone.
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# Prototype v0 keeps release builds unobfuscated for diagnosability.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+113
@@ -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>
|
||||
+137
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
}
|
||||
@@ -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
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
+248
@@ -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" "$@"
|
||||
Vendored
+93
@@ -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
|
||||
@@ -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")
|
||||
@@ -106,15 +106,17 @@ does not by itself prove corruption or a hostile receiver.
|
||||
|
||||
## Buffering
|
||||
|
||||
Acquisition runs in a dedicated higher-priority task and writes complete samples
|
||||
to a 1024-entry RAM queue. The lower-priority output task batches up to eight
|
||||
records per frame. At 100 Hz this queue represents about 10.24 seconds of
|
||||
Under BLE, acquisition does not start until the version-2 session handshake has
|
||||
established a clean capture boundary. Once started, the dedicated
|
||||
higher-priority acquisition task writes complete samples to a statically
|
||||
reserved 3072-entry RAM queue. The lower-priority output task batches up to eight
|
||||
records per frame. At 100 Hz this queue represents about 30.72 seconds of
|
||||
decoupling when the transport reports backpressure or failure accurately. A
|
||||
failed write retains and retries the same encoded packet rather than dequeuing
|
||||
more samples, so the queue accumulates the outage backlog. After reconnection,
|
||||
the oldest retained data is sent first. If the queue fills, acquisition drops
|
||||
new samples rather than overwriting older ones; sequence gaps and the cumulative
|
||||
lost-sample counter expose that permanent loss.
|
||||
more samples, so the queue accumulates the outage backlog. After a same-token
|
||||
reconnection, the oldest retained data is sent first. If the queue fills,
|
||||
acquisition drops new samples rather than overwriting older ones; sequence gaps
|
||||
and the cumulative lost-sample counter expose that permanent loss.
|
||||
|
||||
The shared transport state machine distinguishes three nonfatal states. `RETRY`
|
||||
is valid only from initial submission: it means zero bytes were accepted and the
|
||||
@@ -136,7 +138,8 @@ the endpoint accepts them. CRC and sequence checks make resulting loss visible,
|
||||
but an application acknowledgement and replay window are still required to
|
||||
guarantee receipt. Accordingly, USB `COMPLETE` means endpoint drain, while
|
||||
reliable BLE reserves `COMPLETE` for an application ACK of the exact frame. See
|
||||
`ble-transport-v1.md` for fragmentation, replay, and UUIDs.
|
||||
`ble-transport-v2.md` for session establishment, fragmentation, replay, and
|
||||
UUIDs.
|
||||
|
||||
Receivers report bytes left in an incomplete trailing frame when capture ends.
|
||||
Those bytes cannot pass CRC validation and are not silently admitted as samples.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -15,6 +15,12 @@ static uint32_t get_u32_le(const uint8_t *input)
|
||||
((uint32_t)input[2] << 16) | ((uint32_t)input[3] << 24);
|
||||
}
|
||||
|
||||
static uint64_t get_u64_le(const uint8_t *input)
|
||||
{
|
||||
return (uint64_t)get_u32_le(input) |
|
||||
((uint64_t)get_u32_le(input + 4) << 32);
|
||||
}
|
||||
|
||||
static void put_u16_le(uint8_t *output, uint16_t value)
|
||||
{
|
||||
output[0] = (uint8_t)value;
|
||||
@@ -89,3 +95,31 @@ bool trikke_ble_decode_ack(
|
||||
*packet_sequence = get_u32_le(ack + 4);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool trikke_ble_decode_begin_session(
|
||||
const uint8_t *command,
|
||||
size_t command_size,
|
||||
uint64_t *session_token)
|
||||
{
|
||||
if (command == NULL || session_token == NULL ||
|
||||
command_size != TRIKKE_BLE_BEGIN_SESSION_SIZE ||
|
||||
memcmp(command, "BGN1", 4) != 0) {
|
||||
return false;
|
||||
}
|
||||
*session_token = get_u64_le(command + 4);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool trikke_ble_update_subscription(
|
||||
bool notify_enabled,
|
||||
bool session_connection_ready,
|
||||
bool *subscribed)
|
||||
{
|
||||
if (subscribed == NULL) {
|
||||
return false;
|
||||
}
|
||||
const bool effective = notify_enabled && session_connection_ready;
|
||||
const bool changed = effective != *subscribed;
|
||||
*subscribed = effective;
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ extern "C" {
|
||||
|
||||
#define TRIKKE_BLE_FRAGMENT_HEADER_SIZE 8
|
||||
#define TRIKKE_BLE_ACK_SIZE 8
|
||||
#define TRIKKE_BLE_BEGIN_SESSION_SIZE 12
|
||||
|
||||
// BLE data notifications carry a little-endian packet sequence, byte offset,
|
||||
// total TRK1 frame size, then the frame bytes at that offset. The unchanged
|
||||
@@ -29,6 +30,20 @@ bool trikke_ble_decode_ack(
|
||||
size_t ack_size,
|
||||
uint32_t *packet_sequence);
|
||||
|
||||
// A session begin is ASCII "BGN1" followed by a receiver-generated uint64
|
||||
// token. Repeating the same token is idempotent across BLE reconnects.
|
||||
bool trikke_ble_decode_begin_session(
|
||||
const uint8_t *command,
|
||||
size_t command_size,
|
||||
uint64_t *session_token);
|
||||
|
||||
// Notification delivery is active only after both the CCCD and session
|
||||
// authorization are ready. Returns true when the effective state changes.
|
||||
bool trikke_ble_update_subscription(
|
||||
bool notify_enabled,
|
||||
bool session_connection_ready,
|
||||
bool *subscribed);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
+163
-6
@@ -8,6 +8,8 @@
|
||||
#include "host/ble_hs.h"
|
||||
#include "host/ble_uuid.h"
|
||||
#include "host/util/util.h"
|
||||
#include "esp_attr.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "nimble/nimble_port.h"
|
||||
#include "nimble/nimble_port_freertos.h"
|
||||
@@ -22,11 +24,24 @@
|
||||
#define TRIKKE_BLE_MAX_ATT_PAYLOAD 253
|
||||
#define TRIKKE_BLE_FRAGMENTS_PER_POLL 8
|
||||
#define TRIKKE_BLE_ACK_TIMEOUT_US 1000000
|
||||
#define TRIKKE_BLE_SESSION_RTC_MAGIC UINT32_C(0x54524b53)
|
||||
#define TRIKKE_BLE_SESSION_RTC_XOR UINT32_C(0xa93cf17e)
|
||||
#define TRIKKE_BLE_SESSION_RESET_DELAY_MS 50
|
||||
|
||||
static trikke_ble_transport_t *s_ble;
|
||||
static uint8_t s_own_address_type;
|
||||
static uint16_t s_data_value_handle;
|
||||
static uint16_t s_ack_value_handle;
|
||||
static uint16_t s_control_value_handle;
|
||||
|
||||
typedef struct {
|
||||
uint32_t token_low;
|
||||
uint32_t token_high;
|
||||
uint32_t checksum;
|
||||
uint32_t magic;
|
||||
} trikke_ble_rtc_session_t;
|
||||
|
||||
RTC_NOINIT_ATTR static trikke_ble_rtc_session_t s_rtc_session;
|
||||
|
||||
// 7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10 and adjacent characteristic UUIDs.
|
||||
static const ble_uuid128_t TRIKKE_SERVICE_UUID =
|
||||
@@ -38,6 +53,9 @@ static const ble_uuid128_t TRIKKE_DATA_UUID =
|
||||
static const ble_uuid128_t TRIKKE_ACK_UUID =
|
||||
BLE_UUID128_INIT(0x12, 0x9c, 0x1e, 0x2a, 0x4c, 0x3d, 0xbe, 0x8f,
|
||||
0x9b, 0x4a, 0x5b, 0xf7, 0x00, 0xa0, 0x2e, 0x7d);
|
||||
static const ble_uuid128_t TRIKKE_CONTROL_UUID =
|
||||
BLE_UUID128_INIT(0x13, 0x9c, 0x1e, 0x2a, 0x4c, 0x3d, 0xbe, 0x8f,
|
||||
0x9b, 0x4a, 0x5b, 0xf7, 0x00, 0xa0, 0x2e, 0x7d);
|
||||
|
||||
static uint32_t get_u32_le(const uint8_t *input)
|
||||
{
|
||||
@@ -45,6 +63,63 @@ static uint32_t get_u32_le(const uint8_t *input)
|
||||
((uint32_t)input[2] << 16) | ((uint32_t)input[3] << 24);
|
||||
}
|
||||
|
||||
// Caller must hold ble->lock. Keep the raw CCCD state independent from session
|
||||
// authorization so either legal write order converges on the same effective
|
||||
// subscription state.
|
||||
static void update_subscribed_locked(trikke_ble_transport_t *ble)
|
||||
{
|
||||
if (trikke_ble_update_subscription(
|
||||
ble->notify_enabled,
|
||||
ble->session_connection_ready,
|
||||
&ble->subscribed)) {
|
||||
++ble->delivery_epoch;
|
||||
}
|
||||
}
|
||||
|
||||
static uint32_t rtc_session_checksum(uint32_t low, uint32_t high)
|
||||
{
|
||||
return TRIKKE_BLE_SESSION_RTC_MAGIC ^ low ^ high ^
|
||||
TRIKKE_BLE_SESSION_RTC_XOR;
|
||||
}
|
||||
|
||||
static void rtc_session_clear(void)
|
||||
{
|
||||
s_rtc_session.magic = 0;
|
||||
s_rtc_session.token_low = 0;
|
||||
s_rtc_session.token_high = 0;
|
||||
s_rtc_session.checksum = 0;
|
||||
}
|
||||
|
||||
static void rtc_session_store(uint64_t token)
|
||||
{
|
||||
const uint32_t low = (uint32_t)token;
|
||||
const uint32_t high = (uint32_t)(token >> 32);
|
||||
s_rtc_session.magic = 0;
|
||||
s_rtc_session.token_low = low;
|
||||
s_rtc_session.token_high = high;
|
||||
s_rtc_session.checksum = rtc_session_checksum(low, high);
|
||||
s_rtc_session.magic = TRIKKE_BLE_SESSION_RTC_MAGIC;
|
||||
}
|
||||
|
||||
static bool rtc_session_load(uint64_t *token)
|
||||
{
|
||||
if (token == NULL || s_rtc_session.magic != TRIKKE_BLE_SESSION_RTC_MAGIC ||
|
||||
s_rtc_session.checksum != rtc_session_checksum(
|
||||
s_rtc_session.token_low, s_rtc_session.token_high)) {
|
||||
return false;
|
||||
}
|
||||
*token = (uint64_t)s_rtc_session.token_low |
|
||||
((uint64_t)s_rtc_session.token_high << 32);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void session_restart_task(void *argument)
|
||||
{
|
||||
(void)argument;
|
||||
vTaskDelay(pdMS_TO_TICKS(TRIKKE_BLE_SESSION_RESET_DELAY_MS));
|
||||
esp_restart();
|
||||
}
|
||||
|
||||
static int data_access(
|
||||
uint16_t connection_handle,
|
||||
uint16_t attribute_handle,
|
||||
@@ -102,6 +177,69 @@ static int ack_access(
|
||||
return accepted ? 0 : BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
static int session_access(
|
||||
uint16_t connection_handle,
|
||||
uint16_t attribute_handle,
|
||||
struct ble_gatt_access_ctxt *context,
|
||||
void *argument)
|
||||
{
|
||||
(void)attribute_handle;
|
||||
(void)argument;
|
||||
trikke_ble_transport_t *ble = s_ble;
|
||||
if (ble == NULL || context->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
|
||||
uint8_t command[TRIKKE_BLE_BEGIN_SESSION_SIZE] = {0};
|
||||
uint16_t command_size = 0;
|
||||
uint64_t requested_token = 0;
|
||||
if (OS_MBUF_PKTLEN(context->om) != sizeof(command) ||
|
||||
ble_hs_mbuf_to_flat(
|
||||
context->om, command, sizeof(command), &command_size) != 0 ||
|
||||
!trikke_ble_decode_begin_session(
|
||||
command, command_size, &requested_token)) {
|
||||
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
|
||||
}
|
||||
|
||||
bool notify_session_ready = false;
|
||||
bool restart = false;
|
||||
portENTER_CRITICAL(&ble->lock);
|
||||
if (!ble->connected || ble->connection_handle != connection_handle ||
|
||||
ble->session_restart_pending) {
|
||||
portEXIT_CRITICAL(&ble->lock);
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
if (ble->session_token_valid && ble->session_token == requested_token) {
|
||||
ble->session_connection_ready = true;
|
||||
update_subscribed_locked(ble);
|
||||
if (!ble->session_started) {
|
||||
ble->session_started = true;
|
||||
notify_session_ready = true;
|
||||
}
|
||||
} else {
|
||||
ble->session_connection_ready = false;
|
||||
update_subscribed_locked(ble);
|
||||
ble->session_restart_pending = true;
|
||||
rtc_session_store(requested_token);
|
||||
restart = true;
|
||||
}
|
||||
portEXIT_CRITICAL(&ble->lock);
|
||||
|
||||
if (notify_session_ready && ble->session_ready != NULL) {
|
||||
ble->session_ready(ble->session_ready_context);
|
||||
}
|
||||
if (restart && xTaskCreate(
|
||||
session_restart_task, "trikke_session_reset", 2048, NULL,
|
||||
configMAX_PRIORITIES - 1, NULL) != pdPASS) {
|
||||
rtc_session_clear();
|
||||
portENTER_CRITICAL(&ble->lock);
|
||||
ble->session_restart_pending = false;
|
||||
portEXIT_CRITICAL(&ble->lock);
|
||||
return BLE_ATT_ERR_UNLIKELY;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const struct ble_gatt_svc_def TRIKKE_GATT_SERVICES[] = {
|
||||
{
|
||||
.type = BLE_GATT_SVC_TYPE_PRIMARY,
|
||||
@@ -119,6 +257,12 @@ static const struct ble_gatt_svc_def TRIKKE_GATT_SERVICES[] = {
|
||||
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP,
|
||||
.val_handle = &s_ack_value_handle,
|
||||
},
|
||||
{
|
||||
.uuid = &TRIKKE_CONTROL_UUID.u,
|
||||
.access_cb = session_access,
|
||||
.flags = BLE_GATT_CHR_F_WRITE,
|
||||
.val_handle = &s_control_value_handle,
|
||||
},
|
||||
{0},
|
||||
},
|
||||
},
|
||||
@@ -169,7 +313,9 @@ static void on_reset(int reason)
|
||||
++ble->counters.disconnect_count;
|
||||
}
|
||||
ble->connected = false;
|
||||
ble->notify_enabled = false;
|
||||
ble->subscribed = false;
|
||||
ble->session_connection_ready = false;
|
||||
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
|
||||
++ble->delivery_epoch;
|
||||
portEXIT_CRITICAL(&ble->lock);
|
||||
@@ -197,7 +343,9 @@ static int gap_event(struct ble_gap_event *event, void *argument)
|
||||
if (event->connect.status == 0) {
|
||||
portENTER_CRITICAL(&ble->lock);
|
||||
ble->connected = true;
|
||||
ble->notify_enabled = false;
|
||||
ble->subscribed = false;
|
||||
ble->session_connection_ready = false;
|
||||
ble->connection_handle = event->connect.conn_handle;
|
||||
++ble->delivery_epoch;
|
||||
portEXIT_CRITICAL(&ble->lock);
|
||||
@@ -212,7 +360,9 @@ static int gap_event(struct ble_gap_event *event, void *argument)
|
||||
++ble->counters.disconnect_count;
|
||||
}
|
||||
ble->connected = false;
|
||||
ble->notify_enabled = false;
|
||||
ble->subscribed = false;
|
||||
ble->session_connection_ready = false;
|
||||
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
|
||||
++ble->delivery_epoch;
|
||||
portEXIT_CRITICAL(&ble->lock);
|
||||
@@ -222,11 +372,8 @@ static int gap_event(struct ble_gap_event *event, void *argument)
|
||||
case BLE_GAP_EVENT_SUBSCRIBE:
|
||||
if (event->subscribe.attr_handle == s_data_value_handle) {
|
||||
portENTER_CRITICAL(&ble->lock);
|
||||
const bool subscribed = event->subscribe.cur_notify != 0;
|
||||
if (subscribed != ble->subscribed) {
|
||||
ble->subscribed = subscribed;
|
||||
++ble->delivery_epoch;
|
||||
}
|
||||
ble->notify_enabled = event->subscribe.cur_notify != 0;
|
||||
update_subscribed_locked(ble);
|
||||
portEXIT_CRITICAL(&ble->lock);
|
||||
}
|
||||
return 0;
|
||||
@@ -431,7 +578,9 @@ static trikke_transport_status_t ble_poll_packet(void *context)
|
||||
|
||||
esp_err_t trikke_ble_transport_init(
|
||||
trikke_ble_transport_t *ble,
|
||||
trikke_transport_t *transport)
|
||||
trikke_transport_t *transport,
|
||||
trikke_ble_session_ready_fn session_ready,
|
||||
void *session_ready_context)
|
||||
{
|
||||
if (ble == NULL || transport == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
@@ -443,6 +592,14 @@ esp_err_t trikke_ble_transport_init(
|
||||
memset(ble, 0, sizeof(*ble));
|
||||
ble->lock = (portMUX_TYPE)portMUX_INITIALIZER_UNLOCKED;
|
||||
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
|
||||
ble->session_ready = session_ready;
|
||||
ble->session_ready_context = session_ready_context;
|
||||
if (esp_reset_reason() == ESP_RST_SW &&
|
||||
rtc_session_load(&ble->session_token)) {
|
||||
ble->session_token_valid = true;
|
||||
} else {
|
||||
rtc_session_clear();
|
||||
}
|
||||
s_ble = ble;
|
||||
|
||||
esp_err_t error = nvs_flash_init();
|
||||
|
||||
@@ -19,15 +19,23 @@ typedef struct {
|
||||
uint32_t invalid_ack_count;
|
||||
} trikke_ble_transport_counters_t;
|
||||
|
||||
typedef void (*trikke_ble_session_ready_fn)(void *context);
|
||||
|
||||
typedef struct {
|
||||
portMUX_TYPE lock;
|
||||
bool initialized;
|
||||
bool connected;
|
||||
bool notify_enabled;
|
||||
bool subscribed;
|
||||
bool frame_active;
|
||||
bool frame_fully_sent_once;
|
||||
bool ack_received;
|
||||
bool session_token_valid;
|
||||
bool session_connection_ready;
|
||||
bool session_started;
|
||||
bool session_restart_pending;
|
||||
uint16_t connection_handle;
|
||||
uint64_t session_token;
|
||||
uint32_t delivery_epoch;
|
||||
uint32_t frame_epoch;
|
||||
uint32_t frame_sequence;
|
||||
@@ -35,12 +43,16 @@ typedef struct {
|
||||
size_t frame_size;
|
||||
size_t next_offset;
|
||||
int64_t ack_deadline_us;
|
||||
trikke_ble_session_ready_fn session_ready;
|
||||
void *session_ready_context;
|
||||
trikke_ble_transport_counters_t counters;
|
||||
} trikke_ble_transport_t;
|
||||
|
||||
esp_err_t trikke_ble_transport_init(
|
||||
trikke_ble_transport_t *ble,
|
||||
trikke_transport_t *transport);
|
||||
trikke_transport_t *transport,
|
||||
trikke_ble_session_ready_fn session_ready,
|
||||
void *session_ready_context);
|
||||
|
||||
void trikke_ble_transport_get_counters(
|
||||
trikke_ble_transport_t *ble,
|
||||
|
||||
+37
-16
@@ -28,7 +28,7 @@
|
||||
#define TRIKKE_I2C_FREQ_HZ 400000
|
||||
#define TRIKKE_SAMPLE_RATE_HZ 100
|
||||
#define TRIKKE_SAMPLE_TICKS pdMS_TO_TICKS(1000 / TRIKKE_SAMPLE_RATE_HZ)
|
||||
#define TRIKKE_SAMPLE_QUEUE_DEPTH 1024
|
||||
#define TRIKKE_SAMPLE_QUEUE_DEPTH 3072
|
||||
#define TRIKKE_METADATA_INTERVAL_PACKETS 64
|
||||
#define TRIKKE_STATUS_INTERVAL_PACKETS 64
|
||||
#define TRIKKE_TRANSPORT_RETRY_DELAY_MS 10
|
||||
@@ -59,9 +59,14 @@ typedef struct {
|
||||
adxl345_t accelerometer;
|
||||
l3g4200d_t gyroscope;
|
||||
QueueHandle_t sample_queue;
|
||||
StaticQueue_t sample_queue_control;
|
||||
uint8_t sample_queue_storage[
|
||||
TRIKKE_SAMPLE_QUEUE_DEPTH * sizeof(trikke_wire_sample_t)];
|
||||
atomic_uint_least32_t sensor_read_failure_count;
|
||||
atomic_uint_least32_t queue_overflow_count;
|
||||
atomic_uint_least32_t loop_overrun_count;
|
||||
TaskHandle_t output_task_handle;
|
||||
TaskHandle_t acquisition_task_handle;
|
||||
trikke_transport_t transport;
|
||||
#if CONFIG_TRIKKE_TRANSPORT_BLE
|
||||
trikke_ble_transport_t ble_transport;
|
||||
@@ -147,6 +152,18 @@ static trikke_wire_status_t status_snapshot(
|
||||
return status;
|
||||
}
|
||||
|
||||
#if CONFIG_TRIKKE_TRANSPORT_BLE
|
||||
static void session_ready(void *argument)
|
||||
{
|
||||
trikke_context_t *context = argument;
|
||||
if (context == NULL) {
|
||||
return;
|
||||
}
|
||||
xTaskNotifyGive(context->output_task_handle);
|
||||
xTaskNotifyGive(context->acquisition_task_handle);
|
||||
}
|
||||
#endif
|
||||
|
||||
static void acquisition_task(void *argument)
|
||||
{
|
||||
trikke_context_t *context = argument;
|
||||
@@ -407,8 +424,11 @@ void app_main(void)
|
||||
usb_serial_jtag_vfs_set_tx_line_endings(ESP_LINE_ENDINGS_LF);
|
||||
#endif
|
||||
|
||||
s_context.sample_queue =
|
||||
xQueueCreate(TRIKKE_SAMPLE_QUEUE_DEPTH, sizeof(trikke_wire_sample_t));
|
||||
s_context.sample_queue = xQueueCreateStatic(
|
||||
TRIKKE_SAMPLE_QUEUE_DEPTH,
|
||||
sizeof(trikke_wire_sample_t),
|
||||
s_context.sample_queue_storage,
|
||||
&s_context.sample_queue_control);
|
||||
if (s_context.sample_queue == NULL) {
|
||||
ESP_LOGE(TAG, "sample queue allocation failed");
|
||||
l3g4200d_deinit(&s_context.gyroscope);
|
||||
@@ -417,17 +437,15 @@ void app_main(void)
|
||||
return;
|
||||
}
|
||||
|
||||
TaskHandle_t output_task_handle = NULL;
|
||||
TaskHandle_t acquisition_task_handle = NULL;
|
||||
if (xTaskCreate(output_task, "trikke_output", 4096, &s_context, 5,
|
||||
&output_task_handle) != pdPASS ||
|
||||
&s_context.output_task_handle) != pdPASS ||
|
||||
xTaskCreate(acquisition_task, "trikke_acquire", 4096, &s_context, 10,
|
||||
&acquisition_task_handle) != pdPASS) {
|
||||
if (output_task_handle != NULL) {
|
||||
vTaskDelete(output_task_handle);
|
||||
&s_context.acquisition_task_handle) != pdPASS) {
|
||||
if (s_context.output_task_handle != NULL) {
|
||||
vTaskDelete(s_context.output_task_handle);
|
||||
}
|
||||
if (acquisition_task_handle != NULL) {
|
||||
vTaskDelete(acquisition_task_handle);
|
||||
if (s_context.acquisition_task_handle != NULL) {
|
||||
vTaskDelete(s_context.acquisition_task_handle);
|
||||
}
|
||||
vQueueDelete(s_context.sample_queue);
|
||||
ESP_LOGE(TAG, "telemetry task creation failed");
|
||||
@@ -439,14 +457,15 @@ void app_main(void)
|
||||
|
||||
#if CONFIG_TRIKKE_TRANSPORT_BLE
|
||||
err = trikke_ble_transport_init(
|
||||
&s_context.ble_transport, &s_context.transport);
|
||||
&s_context.ble_transport, &s_context.transport,
|
||||
session_ready, &s_context);
|
||||
#else
|
||||
err = trikke_usb_transport_init(
|
||||
&s_context.usb_transport, &s_context.transport);
|
||||
#endif
|
||||
if (err != ESP_OK) {
|
||||
vTaskDelete(output_task_handle);
|
||||
vTaskDelete(acquisition_task_handle);
|
||||
vTaskDelete(s_context.output_task_handle);
|
||||
vTaskDelete(s_context.acquisition_task_handle);
|
||||
vQueueDelete(s_context.sample_queue);
|
||||
#if CONFIG_TRIKKE_TRANSPORT_BLE
|
||||
ESP_LOGE(TAG, "BLE transport initialization failed: %s",
|
||||
@@ -463,6 +482,8 @@ void app_main(void)
|
||||
|
||||
// No text may share the byte stream once framed binary output begins.
|
||||
esp_log_level_set("*", ESP_LOG_NONE);
|
||||
xTaskNotifyGive(output_task_handle);
|
||||
xTaskNotifyGive(acquisition_task_handle);
|
||||
#if !CONFIG_TRIKKE_TRANSPORT_BLE
|
||||
xTaskNotifyGive(s_context.output_task_handle);
|
||||
xTaskNotifyGive(s_context.acquisition_task_handle);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -66,5 +66,42 @@ int main(void)
|
||||
sizeof(packet), 0, 8) != 0) {
|
||||
return fail(4, "invalid input rejection");
|
||||
}
|
||||
|
||||
const uint8_t begin[TRIKKE_BLE_BEGIN_SESSION_SIZE] = {
|
||||
'B', 'G', 'N', '1', 0x08, 0x07, 0x06, 0x05,
|
||||
0x04, 0x03, 0x02, 0x01,
|
||||
};
|
||||
uint64_t session_token = 0;
|
||||
if (!trikke_ble_decode_begin_session(
|
||||
begin, sizeof(begin), &session_token) ||
|
||||
session_token != UINT64_C(0x0102030405060708)) {
|
||||
return fail(5, "begin-session decoding");
|
||||
}
|
||||
uint8_t invalid_begin[TRIKKE_BLE_BEGIN_SESSION_SIZE] = {0};
|
||||
memcpy(invalid_begin, begin, sizeof(begin));
|
||||
invalid_begin[3] = '2';
|
||||
if (trikke_ble_decode_begin_session(
|
||||
invalid_begin, sizeof(invalid_begin), &session_token) ||
|
||||
trikke_ble_decode_begin_session(
|
||||
begin, sizeof(begin) - 1, &session_token)) {
|
||||
return fail(6, "invalid begin-session rejection");
|
||||
}
|
||||
|
||||
bool subscribed = false;
|
||||
if (trikke_ble_update_subscription(true, false, &subscribed) ||
|
||||
subscribed ||
|
||||
!trikke_ble_update_subscription(true, true, &subscribed) ||
|
||||
!subscribed) {
|
||||
return fail(7, "CCCD-before-control subscription ordering");
|
||||
}
|
||||
subscribed = false;
|
||||
if (trikke_ble_update_subscription(false, true, &subscribed) ||
|
||||
subscribed ||
|
||||
!trikke_ble_update_subscription(true, true, &subscribed) ||
|
||||
!subscribed ||
|
||||
!trikke_ble_update_subscription(false, true, &subscribed) ||
|
||||
subscribed) {
|
||||
return fail(8, "control-before-CCCD subscription ordering");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Vendored
+29
@@ -78,6 +78,35 @@ removed before production firmware was built and flashed.
|
||||
beyond the 10.24-second queue window, so sequences 1,024 through 4,021 were
|
||||
intentionally lost; the single 2,998-sample gap, matching queue-overflow total,
|
||||
demonstrates bounded buffer exhaustion rather than a suspended output task.
|
||||
- `ride_20260820_090607.trk` — SHA-256
|
||||
`c938934c0905748d2f8d8be61cff1ff446d8415b34850628654967398fd7d91f`.
|
||||
This is the Android recorder acceptance capture after increasing the firmware
|
||||
queue to 3,072 samples (30.72 seconds). Bluetooth was deliberately disabled
|
||||
from the phone for five seconds and then restored. The completed capture
|
||||
contains 2,578 consecutive packets and 19,984 consecutive samples, both
|
||||
starting at sequence zero, with zero CRC/header errors, packet/sample gaps,
|
||||
sensor failures, queue overflows, transport send failures, invalid ACKs,
|
||||
loop overruns, or trailing bytes. Firmware status records one disconnect and
|
||||
one replay. The Android recorder durably deduplicated that exact replay, as
|
||||
recorded in `ride_20260820_090607.session.json` (SHA-256
|
||||
`b16a8f0c213a14fd2760b6efba1c6288f3631e57a331c553893e281dce047888`),
|
||||
and closed the session with `complete=true` and no error.
|
||||
|
||||
All 19,983 sample intervals are contiguous. Of those, 8,745 differ from
|
||||
exactly 10 ms. Across all intervals, absolute deviation is 0 us median, 40 us
|
||||
p95, 240 us p99, and 1,630 us maximum; among only the off-grid intervals it is
|
||||
10 us median, 60 us p95, and 750 us p99. Cumulative error is -726 us over
|
||||
199.83 seconds. The capture also has six explicitly flagged ADXL345 overruns;
|
||||
timestamps are taken before both I2C reads and therefore understate delays
|
||||
caused by preemption between the timestamp and physical sensor access.
|
||||
|
||||
The post-rename phone smoke test for application ID
|
||||
`com.jsjdesigns.trikkerecorder` was verified live but not retained as a fixture.
|
||||
It closed cleanly with 366 consecutive frames and 2,832 consecutive samples,
|
||||
zero CRC/header errors, packet/sample gaps, drops, queue overflows, loop
|
||||
overruns, or trailing bytes. The package-only rename did not change the Android
|
||||
protocol or storage implementation, and the retained outage fixture above
|
||||
remains the authoritative end-to-end acceptance artifact.
|
||||
|
||||
`tests/test_trikke_protocol.py` verifies the hashes, parses the captures in
|
||||
fragmented chunks, and asserts these signatures so the hardware evidence remains
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"captureFile": "ride_20260820_090607.trk",
|
||||
"sessionTokenHex": "77f213814e3c41da",
|
||||
"complete": true,
|
||||
"startWallClockMs": 1787231167523,
|
||||
"startElapsedRealtimeNs": 2415494841543927,
|
||||
"endWallClockMs": 1787231378168,
|
||||
"endElapsedRealtimeNs": 2415705485735774,
|
||||
"firstFrame": {"packetSequence":0,"deviceBaseTimestampUs":7652604,"phoneElapsedRealtimeNs":2415505638698090,"phoneWallClockMs":1787231178321},
|
||||
"lastFrame": {"packetSequence":2577,"deviceBaseTimestampUs":207411553,"phoneElapsedRealtimeNs":2415705468358690,"phoneWallClockMs":1787231378150},
|
||||
"frames": 2578,
|
||||
"samples": 19984,
|
||||
"duplicateReplays": 1,
|
||||
"packetGaps": 0,
|
||||
"packetResets": 0,
|
||||
"sampleGaps": 0,
|
||||
"sampleResets": 0,
|
||||
"droppedSamples": 0,
|
||||
"loopOverruns": 0,
|
||||
"queueOverflows": 0,
|
||||
"transportDisconnects": 1,
|
||||
"transportReplays": 1,
|
||||
"error": null
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -18,7 +19,7 @@ from trikke_protocol import ( # noqa: E402
|
||||
StreamParser,
|
||||
sample_to_csv_row,
|
||||
)
|
||||
from trikke_ble import BleFrameReassembler, encode_ack # noqa: E402
|
||||
from trikke_ble import BleFrameReassembler, encode_ack, encode_begin_session # noqa: E402
|
||||
|
||||
|
||||
class ProtocolContractTest(unittest.TestCase):
|
||||
@@ -197,6 +198,10 @@ class ProtocolContractTest(unittest.TestCase):
|
||||
self.assertIsNone(reassembler.feed(fragment(0, 40)))
|
||||
self.assertEqual(frame, reassembler.feed(fragment(40, len(frame) - 40)))
|
||||
self.assertEqual(b"ACK1" + sequence.to_bytes(4, "little"), encode_ack(sequence))
|
||||
self.assertEqual(
|
||||
b"BGN1\x08\x07\x06\x05\x04\x03\x02\x01",
|
||||
encode_begin_session(0x0102030405060708),
|
||||
)
|
||||
self.assertEqual(0, reassembler.rejected_fragment_count)
|
||||
|
||||
self.assertIsNone(reassembler.feed(fragment(20, 20)))
|
||||
@@ -327,6 +332,25 @@ class ProtocolContractTest(unittest.TestCase):
|
||||
"transport_invalid_ack_count": 0,
|
||||
},
|
||||
},
|
||||
"ride_20260820_090607.trk": {
|
||||
"sha256": "c938934c0905748d2f8d8be61cff1ff446d8415b34850628654967398fd7d91f",
|
||||
"sample_count": 19984,
|
||||
"first_sequence": 0,
|
||||
"last_sequence": 19983,
|
||||
"max_dropped": 0,
|
||||
"timing_anomalies": 8745,
|
||||
"accel_overruns": 6,
|
||||
"gaps": [],
|
||||
"final_status": {
|
||||
"sensor_read_failure_count": 0,
|
||||
"queue_overflow_count": 0,
|
||||
"transport_begin_retry_count": 2,
|
||||
"transport_disconnect_count": 1,
|
||||
"transport_send_failure_count": 0,
|
||||
"transport_replay_count": 1,
|
||||
"transport_invalid_ack_count": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, contract in expected.items():
|
||||
@@ -379,6 +403,11 @@ class ProtocolContractTest(unittest.TestCase):
|
||||
contract["timing_anomalies"],
|
||||
integrity.timing_anomaly_count,
|
||||
)
|
||||
if "accel_overruns" in contract:
|
||||
self.assertEqual(
|
||||
contract["accel_overruns"],
|
||||
integrity.accel_overrun_count,
|
||||
)
|
||||
self.assertEqual(
|
||||
contract["max_dropped"],
|
||||
integrity.final_dropped_sample_count,
|
||||
@@ -403,6 +432,31 @@ class ProtocolContractTest(unittest.TestCase):
|
||||
]
|
||||
self.assertEqual(contract["gaps"], gaps)
|
||||
|
||||
def test_android_reconnect_session_sidecar(self) -> None:
|
||||
data = (
|
||||
ROOT
|
||||
/ "tests"
|
||||
/ "fixtures"
|
||||
/ "ride_20260820_090607.session.json"
|
||||
).read_bytes()
|
||||
self.assertEqual(
|
||||
"b16a8f0c213a14fd2760b6efba1c6288f3631e57a331c553893e281dce047888",
|
||||
hashlib.sha256(data).hexdigest(),
|
||||
)
|
||||
summary = json.loads(data)
|
||||
self.assertTrue(summary["complete"])
|
||||
self.assertIsNone(summary["error"])
|
||||
self.assertEqual("ride_20260820_090607.trk", summary["captureFile"])
|
||||
self.assertEqual(2578, summary["frames"])
|
||||
self.assertEqual(19984, summary["samples"])
|
||||
self.assertEqual(1, summary["duplicateReplays"])
|
||||
self.assertEqual(0, summary["packetGaps"])
|
||||
self.assertEqual(0, summary["sampleGaps"])
|
||||
self.assertEqual(0, summary["droppedSamples"])
|
||||
self.assertEqual(0, summary["queueOverflows"])
|
||||
self.assertEqual(1, summary["transportDisconnects"])
|
||||
self.assertEqual(1, summary["transportReplays"])
|
||||
|
||||
def test_exact_usb_raw_wire_evidence(self) -> None:
|
||||
expected = {
|
||||
"direct_usb_3c95f3d": {
|
||||
|
||||
@@ -7,12 +7,13 @@ import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from trikke_ble import BleFrameReassembler, encode_ack
|
||||
from trikke_ble import BleFrameReassembler, encode_ack, encode_begin_session
|
||||
from trikke_protocol import (
|
||||
CSV_COLUMNS,
|
||||
PACKET_TYPE_METADATA,
|
||||
@@ -27,6 +28,7 @@ DEVICE_NAME = "TrikkeSensor"
|
||||
SERVICE_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10"
|
||||
DATA_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11"
|
||||
ACK_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12"
|
||||
CONTROL_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c13"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
@@ -86,6 +88,7 @@ async def capture(args: argparse.Namespace) -> int:
|
||||
last_persisted_raw: bytes | None = None
|
||||
sample_count = 0
|
||||
frame_count = 0
|
||||
session_token = secrets.randbits(64)
|
||||
|
||||
with ExitStack() as stack:
|
||||
raw_capture = stack.enter_context(output.open("wb"))
|
||||
@@ -110,6 +113,11 @@ async def capture(args: argparse.Namespace) -> int:
|
||||
reassembler.reset()
|
||||
while not fragments.empty():
|
||||
fragments.get_nowait()
|
||||
await client.write_gatt_char(
|
||||
CONTROL_UUID,
|
||||
encode_begin_session(session_token),
|
||||
response=True,
|
||||
)
|
||||
await client.start_notify(DATA_UUID, on_fragment)
|
||||
print("BLE connected and subscribed")
|
||||
while not stop.is_set() and client.is_connected:
|
||||
|
||||
@@ -6,6 +6,7 @@ import struct
|
||||
|
||||
BLE_FRAGMENT_HEADER = struct.Struct("<IHH")
|
||||
BLE_ACK = struct.Struct("<4sI")
|
||||
BLE_BEGIN_SESSION = struct.Struct("<4sQ")
|
||||
BLE_MIN_FRAME_SIZE = 36
|
||||
BLE_MAX_FRAME_SIZE = 196
|
||||
|
||||
@@ -64,3 +65,7 @@ class BleFrameReassembler:
|
||||
|
||||
def encode_ack(packet_sequence: int) -> bytes:
|
||||
return BLE_ACK.pack(b"ACK1", packet_sequence & 0xFFFFFFFF)
|
||||
|
||||
|
||||
def encode_begin_session(session_token: int) -> bytes:
|
||||
return BLE_BEGIN_SESSION.pack(b"BGN1", session_token & 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
Reference in New Issue
Block a user