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