Compare commits

...

10 Commits

64 changed files with 5184 additions and 169 deletions
+4
View File
@@ -1,7 +1,11 @@
build/
build-usb/
sdkconfig
captures/
sdkconfig.old
managed_components/
__pycache__/
.DS_Store
android/.gradle/
android/local.properties
android/app/build/
+123 -21
View File
@@ -7,11 +7,15 @@ This milestone does four things:
1. Detects and verifies both sensors by their identification registers.
2. Configures each sensor for a nominal 100 Hz raw output rate.
3. Emits framed, timestamped binary readings over the XIAO USB connection.
3. Emits framed, timestamped binary readings over reliable BLE, with the
audited direct-USB path retained as a build option.
4. Maps both sensors into a shared enclosure frame and carries the metadata
needed to derive calibrated readings without replacing raw data.
BLE transport and phone-side storage come after the wired sensor path is proven.
The wired sensor path and reliable BLE transport are proven. The repository now
also contains an Android 12+ prototype recorder that stores the authoritative
binary stream durably before acknowledging each frame. The macOS reference
client remains available for transport diagnosis.
## Wiring
@@ -28,7 +32,7 @@ Both breakouts share SDA, SCL, 3V3, and GND. The firmware checks both possible
- ADXL345: `0x53` or `0x1D`; expected `DEVID` is `0xE5`.
- L3G4200D: `0x69` or `0x68`; expected `WHO_AM_I` is `0xD3`.
The XIAO ESP32-C3 external antenna is installed for the upcoming BLE transport.
The XIAO ESP32-C3 external antenna is installed for the BLE transport.
## Sensor configuration
@@ -58,27 +62,74 @@ remains the nominal datasheet value. Mapped raw counts are stored directly, and
sensor-native counts are reconstructed losslessly from the documented mapping.
No software filtering or sensor fusion is performed yet.
The ESP32-C3 polls at exactly 100 Hz in a dedicated acquisition task, but each
sensor has an independent internal
The BLE reconnect fixture measured scheduling jitter without drift: among 2,575
contiguous sample intervals, 1,293 differed from exactly 10 ms; absolute timing
deviation had a 4 us median, 160 us p95, 170 us p99, and 780 us maximum, while
cumulative error was only 152 us over 25.75 seconds. Bluetooth tasks can preempt
the polling task, but every record carries its actual acquisition timestamp.
Future fusion, integration, and filtering must derive each `dt` from those
timestamps rather than assume a uniform 10 ms interval. Sensor DRDY/FIFO
acquisition remains the later refinement for reducing the jitter itself.
The 200-second Android acceptance capture provides the current loaded-system
characterization. Of 19,983 contiguous intervals, 8,745 (43.8%) were off-grid.
Across all intervals, absolute deviation was 0 us median, 40 us p95, 240 us p99,
and 1,630 us maximum; among off-grid intervals it was 10 us median, 60 us p95,
and 750 us p99. Cumulative error was -726 us. These timestamp statistics are a
lower bound on acquisition disturbance because firmware timestamps the poll
before performing both I2C reads. Six ADXL345 overrun flags show that preemption
between the timestamp and the physical read occasionally crossed a sensor sample
boundary even though the recorded timestamp delta did not expose the full delay.
The loss is rare (0.03%), explicitly flagged, and reinforces the planned
data-ready/FIFO acquisition refinement.
After the BLE session handshake, the ESP32-C3 schedules nominal 100 Hz polling
in a dedicated acquisition task, but each sensor has an independent internal
sample clock. The status registers are read immediately before each XYZ read so a
consumer can distinguish a fresh sample from a repeated poll and identify gyro
overruns. Hardware data-ready interrupts and FIFO acquisition are deferred to the
later sensor-side acquisition refinement.
Completed samples enter a 512-record RAM queue, providing 5.12 seconds of
transport-outage tolerance at 100 Hz when the transport reports backpressure or
failure accurately. A failed write retains and retries its packet while this
queue accumulates the backlog. A lower-priority output task batches up to eight
records into versioned `TRK1` frames, isolating acquisition from brief transport
stalls. CRC, packet and sample sequences, timestamps, and cumulative
loss/overrun counters make permanent loss detectable by the receiver.
Completed samples enter a statically reserved 3072-record RAM queue, providing
30.72 seconds of transport-outage tolerance at 100 Hz when the transport reports
backpressure or failure accurately. A failed write retains and retries its
packet while this queue accumulates the backlog. A lower-priority output task
batches up to eight records into versioned `TRK1` frames, isolating acquisition
from brief transport stalls. CRC, packet and sample sequences, timestamps, and
cumulative loss/overrun counters make permanent loss detectable by the receiver.
The current ESP-IDF USB VFS path reports physical disconnects, but a connected
host that stops draining can time out below stdio and still appear successful to
firmware. The host can detect resulting loss from packet/sample sequences and
CRC framing, but the device cannot count that case. A direct USB driver with
bounded drain waits, and ultimately receiver acknowledgements with replay, are
deferred to the common USB/BLE transport layer.
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`.
Acquisition remains stopped until a valid session-token handshake establishes a
clean capture boundary. Each unchanged `TRK1` frame is then fragmented as needed,
persisted by the receiver, and acknowledged by exact packet sequence. A missing
ACK causes a replay; disconnect or subscription loss retains the same frame and
restarts it from byte zero after a same-token reconnection. The receiver
deduplicates these deliberate replays.
The preserved USB telemetry option uses ESP-IDF's interrupt-driven USB
Serial/JTAG driver behind
a transport-neutral state machine. A complete frame is submitted atomically to
the driver ring and remains pending across bounded drain timeouts; firmware does
not resubmit it ambiguously or dequeue another frame. The 3072-sample queue
therefore also protects a connected endpoint that temporarily stops draining.
USB drain confirms that bytes left the device endpoint, not that the capture
application persisted them. Packet/sample sequences and CRC expose loss after
the fact. BLE `COMPLETE` instead means the receiver acknowledged the exact frame
after persistence.
Measured end-to-end framing overhead is about 2.47 kB/s at 100 Hz, or 8.47
MiB/hour before BLE link overhead.
@@ -99,7 +150,49 @@ idf.py build
idf.py -p /dev/cu.usbmodem1134101 flash
```
## USB output
BLE is the default. `idf.py menuconfig` -> `Telemetry transport` can select the
preserved direct USB transport for wired regression work. A separate build tree
can verify that selection without disturbing the normal BLE configuration:
```sh
idf.py -B build-usb -D SDKCONFIG=build-usb/sdkconfig \
-D 'SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.usb.defaults' build
```
Install the reference host dependencies and capture BLE telemetry with:
```sh
python3 -m pip install -r requirements.txt
python3 tools/capture_ble.py
```
The client scans for `TrikkeSensor`, stores only complete CRC-valid `TRK1`
frames, flushes the binary and CSV outputs, and only then writes the application
ACK. See [the BLE transport specification](docs/ble-transport-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
After readable startup metadata, the device emits framed binary. Each sample is a
20-byte record containing mapped raw sensor counts, timing, sequence, and the two
@@ -132,17 +225,26 @@ Status bits:
The binary capture tool auto-detects a single `/dev/cu.usbmodem*` device, stores
only CRC-valid frames, renders CSV, and reports packet, sample, timing, status,
drop, overrun, timestamp-saturation, and trailing-partial-byte totals:
drop, overrun, timestamp-saturation, and trailing-partial-byte totals. An
optional `--wire` path preserves every received byte, including startup text and
damaged or partial frames, for forensic comparison:
```sh
python tools/capture_binary.py
python tools/capture_binary.py --reset --wire captures/session.wire
```
`--reset` normalizes the USB DTR/RTS state, clears bytes from the prior session,
and resets the C3 while the new capture is already open. Omit it when attaching
to an intentionally uninterrupted stream.
An existing `.trk` stream can be decoded again without hardware:
```sh
python tools/decode_binary.py captures/session.trk captures/session.csv
```
Live capture and offline decoding use the same integrity tracker, including
wrap-aware packet/sample gap classification.
`tools/capture_serial.py` remains available only for decoding captures from the
older CSV-v3 firmware snapshots.
+124
View File
@@ -0,0 +1,124 @@
# Trikke Recorder for Android
Prototype v0.2 records the existing reliable BLE transport to an authoritative
`.trk` file on an Android phone. It deliberately does not filter, fuse, convert,
or upload telemetry.
The permanent Android application ID and Kotlin namespace are
`com.jsjdesigns.trikkerecorder`.
## Platform and behavior
- Android 12 (API 31) or newer
- BLE central connection to the `TrikkeSensor` service
- User-started connected-device foreground service
- Partial wake lock during an active recording
- App-private capture storage with explicit export through Android's document UI
- A `.session.json` sidecar containing phone wall/monotonic clock anchors and
final integrity counters
Every notification is reassembled using its packet sequence, offset, and total
size. A complete frame must also pass the TRK1 shape and CRC checks. For a new
frame the recorder then performs this ordering:
1. Append the unchanged frame to the `.trk` file.
2. Flush the stream and synchronize its file descriptor.
3. Update in-memory integrity state.
4. Write the exact `ACK1` packet sequence to the C3.
If the ACK is lost, the firmware replays the frame. An exact replay of the last
persisted packet is not appended again, but it is acknowledged again. The app
refuses to acknowledge a CRC-invalid frame or the same sequence carrying
different bytes.
At Start, the app generates a random session token and sends it to the Control
characteristic before subscribing. A new token makes the C3 perform one
controlled software restart. The app reconnects with the same token, after which
the C3 starts acquisition with empty queues and zeroed counters. Temporary BLE
reconnects during that recording reuse the token and therefore preserve queued
samples instead of resetting the session.
The validated phone/C3 pair takes about 10.8 seconds from tapping Start to the
first persisted frame because a new session deliberately includes a controlled
C3 restart and two connection passes. A panic, watchdog, brownout, or power-on
reset during recording invalidates the retained token; recovery adds another
controlled restart, and the resulting packet/sample sequence reset remains
visible in the sidecar integrity counters.
## Build and install
Android Studio is the easiest route: open the `android/` directory, allow the
Gradle sync, select the phone, and run the `app` configuration.
The command-line equivalent on this Mac is:
```sh
cd android
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
export ANDROID_HOME="$HOME/Library/Android/sdk"
./gradlew testDebugUnitTest lintDebug assembleDebug
"$ANDROID_HOME/platform-tools/adb" install -r app/build/outputs/apk/debug/app-debug.apk
```
The tests exercise the Android parser against the real
`ble_mtu_race_4bf00eb.trk` hardware fixture as well as fragment replay, malformed
ordering, CRC rejection, unsigned sequence wrap, durable append, and replay
deduplication.
## Record and export
1. Power the sensor and open **Trikke Recorder**.
2. Tap **Start recording** and grant Nearby Devices and notification permission.
3. Wait for `Recording: Connected and subscribed` and confirm that the sample
count is increasing.
4. The activity may be left, the screen may be locked, and the phone may be put
in a pocket. Keep the persistent recording notification active.
5. Reopen the app and tap **Stop and close safely**.
6. After the status reaches `Stopped`, tap **Export last .trk** and select a
destination.
The app-private `.session.json` starts with `complete: false`. A normal Stop
closes and synchronizes the binary file, then rewrites the sidecar with
`complete: true`, final counts, and first/last phone-to-device clock anchors. If
Android or the user force-stops the process, every previously acknowledged frame
remains in the `.trk` file, while the incomplete sidecar makes the abnormal end
visible.
Phone receipt time is only an alignment anchor; BLE delivery latency means it is
not the sensor's physical sample time. Analysis must continue to use the device
timestamps carried by each TRK1 frame.
## First coordinated acceptance test
Do this before a ride:
1. Record for two minutes with the enclosure flat and still.
2. Lock the phone for at least one minute and verify the sample counter resumes
visibly when the app is reopened.
3. Cause a roughly three-second outage by switching Bluetooth off, switch it
back on, and wait for `Recording` again.
4. Stop and export the `.trk` file.
5. Decode it from the repository root:
```sh
python3 tools/decode_binary.py ride_YYYYMMDD_HHMMSS.trk ride.csv
```
Acceptance requires a valid complete decode, no unexplained packet or sample
gaps, and no queue overflow for the short interruption. Duplicate replays and
the firmware disconnect/replay counters may increase and are expected.
After that passes, repeat for 1530 minutes with the screen locked and include
several short real-world range/interference interruptions.
## Prototype limitations
- The BLE service is still unauthenticated and single-connection, as documented
in `docs/ble-transport-v2.md`.
- The recorder supports one active session and one known sensor.
- Only the binary capture is exported from the UI in this pass. The sidecar is
retained app-private for diagnosis.
- A phone force-stop cannot run cleanup code. The persisted binary prefix remains
useful, but the session is intentionally marked incomplete.
- No GPS, Samsung Health import, CSV rendering, or live motion analysis is in
this milestone.
+49
View File
@@ -0,0 +1,49 @@
plugins {
id("com.android.application")
}
android {
namespace = "com.jsjdesigns.trikkerecorder"
compileSdk = 36
defaultConfig {
applicationId = "com.jsjdesigns.trikkerecorder"
minSdk = 31
targetSdk = 36
versionCode = 2
versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests.all {
it.useJUnit()
}
}
sourceSets {
getByName("test").resources.directories.add(
"../../tests/fixtures",
)
}
}
dependencies {
testImplementation("junit:junit:4.13.2")
}
+1
View File
@@ -0,0 +1 @@
# Prototype v0 keeps release builds unobfuscated for diagnosability.
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.TrikkeRecorder">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".TelemetryService"
android:exported="false"
android:foregroundServiceType="connectedDevice"
android:stopWithTask="false" />
</application>
</manifest>
@@ -0,0 +1,354 @@
package com.jsjdesigns.trikkerecorder
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import android.os.SystemClock
import com.jsjdesigns.trikkerecorder.protocol.BleFrameReassembler
import java.util.UUID
@SuppressLint("MissingPermission")
class BleTransport(
context: Context,
private val listener: Listener,
) {
interface Listener {
fun onTransportState(state: String, detail: String)
fun onFragment(fragment: ByteArray, connectionEpoch: Long, elapsedNs: Long, wallMs: Long)
}
private val appContext = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
private val adapter: BluetoothAdapter? =
appContext.getSystemService(BluetoothManager::class.java)?.adapter
private var scannerCallback: ScanCallback? = null
private var gatt: BluetoothGatt? = null
private var ackCharacteristic: BluetoothGattCharacteristic? = null
private var controlCharacteristic: BluetoothGattCharacteristic? = null
private var running = false
private var sessionToken = 0L
private var connectionEpoch = 0L
private var readyEpoch = -1L
private val scanTimeout = Runnable {
stopScan()
if (running) {
listener.onTransportState("Scanning", "TrikkeSensor not seen; scanning again")
handler.postDelayed(::beginScan, SCAN_PAUSE_MS)
}
}
fun start(sessionToken: Long) {
if (running) return
this.sessionToken = sessionToken
running = true
beginScan()
}
fun stop() {
running = false
handler.removeCallbacksAndMessages(null)
stopScan()
readyEpoch = -1L
ackCharacteristic = null
controlCharacteristic = null
gatt?.disconnect()
gatt?.close()
gatt = null
}
fun acknowledge(packetSequence: Long, epoch: Long) {
handler.post {
val activeGatt = gatt
val characteristic = ackCharacteristic
if (!running || epoch != readyEpoch || activeGatt == null || characteristic == null) {
return@post
}
val ack = BleFrameReassembler.encodeAck(packetSequence)
val accepted = if (Build.VERSION.SDK_INT >= 33) {
activeGatt.writeCharacteristic(
characteristic,
ack,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
characteristic.setValue(ack)
@Suppress("DEPRECATION")
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
activeGatt.writeCharacteristic(characteristic)
}
if (!accepted) {
listener.onTransportState("Recording", "ACK enqueue delayed; awaiting replay")
}
}
}
private fun beginScan() {
if (!running || scannerCallback != null || gatt != null) return
val scanner = adapter?.bluetoothLeScanner
if (adapter?.isEnabled != true || scanner == null) {
listener.onTransportState("Waiting", "Bluetooth is off")
handler.postDelayed(::beginScan, RETRY_MS)
return
}
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (!running || scannerCallback !== this) return
stopScan()
connect(result)
}
override fun onScanFailed(errorCode: Int) {
if (scannerCallback === this) scannerCallback = null
handler.removeCallbacks(scanTimeout)
listener.onTransportState("Waiting", "BLE scan failed ($errorCode); retrying")
if (running) handler.postDelayed(::beginScan, RETRY_MS)
}
}
scannerCallback = callback
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
try {
scanner.startScan(listOf(filter), settings, callback)
listener.onTransportState("Scanning", "Looking for TrikkeSensor")
handler.postDelayed(scanTimeout, SCAN_WINDOW_MS)
} catch (error: RuntimeException) {
scannerCallback = null
listener.onTransportState("Waiting", "BLE scan unavailable: ${error.message}")
if (running) handler.postDelayed(::beginScan, RETRY_MS)
}
}
private fun stopScan() {
handler.removeCallbacks(scanTimeout)
val callback = scannerCallback ?: return
scannerCallback = null
try {
adapter?.bluetoothLeScanner?.stopScan(callback)
} catch (_: RuntimeException) {
// Bluetooth may have been switched off while the scan was active.
}
}
private fun connect(result: ScanResult) {
if (!running) return
connectionEpoch++
val epoch = connectionEpoch
listener.onTransportState("Connecting", result.device.address)
gatt = result.device.connectGatt(
appContext,
false,
callback(epoch),
BluetoothDevice.TRANSPORT_LE,
)
if (gatt == null) restart("Connection could not be started")
}
private fun callback(epoch: Long) = object : BluetoothGattCallback() {
override fun onConnectionStateChange(callbackGatt: BluetoothGatt, status: Int, newState: Int) {
handler.post {
if (callbackGatt !== gatt || epoch != connectionEpoch) {
callbackGatt.close()
return@post
}
if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) {
listener.onTransportState("Connecting", "Discovering telemetry service")
callbackGatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
if (!callbackGatt.discoverServices()) restart("Service discovery did not start")
} else if (newState == BluetoothProfile.STATE_DISCONNECTED || status != BluetoothGatt.GATT_SUCCESS) {
restart("Disconnected (GATT $status)")
}
}
}
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
handler.post {
if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
restart("Service discovery failed ($status)")
return@post
}
val service = callbackGatt.getService(SERVICE_UUID)
val data = service?.getCharacteristic(DATA_UUID)
ackCharacteristic = service?.getCharacteristic(ACK_UUID)
controlCharacteristic = service?.getCharacteristic(CONTROL_UUID)
if (data == null || ackCharacteristic == null || controlCharacteristic == null) {
restart("Telemetry characteristics are missing")
return@post
}
if (!callbackGatt.requestMtu(PREFERRED_MTU)) beginSession(callbackGatt, epoch)
}
}
override fun onMtuChanged(callbackGatt: BluetoothGatt, mtu: Int, status: Int) {
handler.post {
if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
beginSession(callbackGatt, epoch)
}
}
@Deprecated("Used through Android 12")
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
@Suppress("DEPRECATION")
deliver(callbackGatt, characteristic, characteristic.value?.copyOf() ?: return, epoch)
}
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
deliver(callbackGatt, characteristic, value.copyOf(), epoch)
}
override fun onDescriptorWrite(callbackGatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
handler.post {
if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
if (descriptor.uuid != CCCD_UUID || status != BluetoothGatt.GATT_SUCCESS) {
restart("Notification subscription failed ($status)")
return@post
}
readyEpoch = epoch
listener.onTransportState("Recording", "Connected and subscribed")
}
}
override fun onCharacteristicWrite(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
handler.post {
if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
if (characteristic.uuid == CONTROL_UUID) {
if (status != BluetoothGatt.GATT_SUCCESS) {
restart("Session preparation failed ($status)")
return@post
}
val data = callbackGatt.getService(SERVICE_UUID)?.getCharacteristic(DATA_UUID)
if (data == null) restart("Data characteristic vanished")
else enableNotifications(callbackGatt, data, epoch)
} else if (characteristic.uuid == ACK_UUID && status != BluetoothGatt.GATT_SUCCESS) {
listener.onTransportState("Recording", "ACK write failed ($status); awaiting replay")
}
}
}
}
private fun deliver(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
epoch: Long,
) {
val elapsedNs = SystemClock.elapsedRealtimeNanos()
val wallMs = System.currentTimeMillis()
handler.post {
if (callbackGatt !== gatt || epoch != readyEpoch || characteristic.uuid != DATA_UUID) return@post
listener.onFragment(value, epoch, elapsedNs, wallMs)
}
}
private fun enableNotifications(
callbackGatt: BluetoothGatt,
data: BluetoothGattCharacteristic,
epoch: Long,
) {
if (callbackGatt !== gatt || epoch != connectionEpoch) return
if (!callbackGatt.setCharacteristicNotification(data, true)) {
restart("Local notification registration failed")
return
}
val descriptor = data.getDescriptor(CCCD_UUID)
if (descriptor == null) {
restart("Notification descriptor is missing")
return
}
val accepted = if (Build.VERSION.SDK_INT >= 33) {
callbackGatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) ==
BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
@Suppress("DEPRECATION")
callbackGatt.writeDescriptor(descriptor)
}
if (!accepted) restart("Notification subscription did not start")
}
private fun beginSession(callbackGatt: BluetoothGatt, epoch: Long) {
if (callbackGatt !== gatt || epoch != connectionEpoch) return
val control = controlCharacteristic
if (control == null) {
restart("Session control characteristic is missing")
return
}
listener.onTransportState("Preparing", "Establishing a clean recording session")
val command = BleFrameReassembler.encodeBeginSession(sessionToken)
val accepted = if (Build.VERSION.SDK_INT >= 33) {
callbackGatt.writeCharacteristic(
control,
command,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
control.setValue(command)
@Suppress("DEPRECATION")
control.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
callbackGatt.writeCharacteristic(control)
}
if (!accepted) restart("Session preparation did not start")
}
private fun restart(reason: String) {
readyEpoch = -1L
ackCharacteristic = null
controlCharacteristic = null
val oldGatt = gatt
gatt = null
oldGatt?.disconnect()
oldGatt?.close()
if (running) {
listener.onTransportState("Reconnecting", reason)
handler.postDelayed(::beginScan, RETRY_MS)
}
}
companion object {
val SERVICE_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10")
val DATA_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11")
val ACK_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12")
val CONTROL_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c13")
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val PREFERRED_MTU = 256
private const val SCAN_WINDOW_MS = 10_000L
private const val SCAN_PAUSE_MS = 500L
private const val RETRY_MS = 1_000L
}
}
@@ -0,0 +1,256 @@
package com.jsjdesigns.trikkerecorder
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.Gravity
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import java.io.File
import java.util.Locale
class MainActivity : Activity() {
private lateinit var phaseView: TextView
private lateinit var detailView: TextView
private lateinit var countersView: TextView
private lateinit var recordButton: Button
private lateinit var exportButton: Button
private var snapshot = RecorderSnapshot()
private var pendingStart = false
private var exportPath: String? = null
private val stateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != TelemetryService.ACTION_STATE) return
snapshot = RecorderSnapshot(
active = intent.getBooleanExtra(TelemetryService.EXTRA_ACTIVE, false),
phase = intent.getStringExtra(TelemetryService.EXTRA_PHASE) ?: "Idle",
detail = intent.getStringExtra(TelemetryService.EXTRA_DETAIL) ?: "",
capturePath = intent.getStringExtra(TelemetryService.EXTRA_CAPTURE_PATH),
durationSeconds = intent.getLongExtra(TelemetryService.EXTRA_DURATION_SECONDS, 0),
frameCount = intent.getLongExtra(TelemetryService.EXTRA_FRAMES, 0),
sampleCount = intent.getLongExtra(TelemetryService.EXTRA_SAMPLES, 0),
duplicateCount = intent.getLongExtra(TelemetryService.EXTRA_DUPLICATES, 0),
rejectedFragments = intent.getLongExtra(TelemetryService.EXTRA_REJECTED_FRAGMENTS, 0),
invalidFrames = intent.getLongExtra(TelemetryService.EXTRA_INVALID_FRAMES, 0),
packetGaps = intent.getLongExtra(TelemetryService.EXTRA_PACKET_GAPS, 0),
sampleGaps = intent.getLongExtra(TelemetryService.EXTRA_SAMPLE_GAPS, 0),
droppedSamples = intent.getLongExtra(TelemetryService.EXTRA_DROPPED, 0),
queueOverflows = intent.getLongExtra(TelemetryService.EXTRA_QUEUE_OVERFLOWS, -1)
.takeIf { it >= 0 },
)
render()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
buildUi()
snapshot = TelemetryService.currentSnapshot
if (snapshot.capturePath == null) {
val previous = getSharedPreferences(TelemetryService.PREFERENCES, MODE_PRIVATE)
.getString(TelemetryService.LAST_CAPTURE, null)
if (previous != null) snapshot = snapshot.copy(capturePath = previous)
}
render()
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
override fun onStart() {
super.onStart()
val filter = IntentFilter(TelemetryService.ACTION_STATE)
if (Build.VERSION.SDK_INT >= 33) registerReceiver(stateReceiver, filter, RECEIVER_NOT_EXPORTED)
else @Suppress("DEPRECATION") registerReceiver(stateReceiver, filter)
}
override fun onStop() {
unregisterReceiver(stateReceiver)
super.onStop()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_PERMISSIONS && hasBluetoothPermissions()) ensureBluetoothAndStart()
else if (requestCode == REQUEST_PERMISSIONS) showLocalMessage("Nearby devices permission is required")
}
@Deprecated("Used for the platform Bluetooth-enable and document-create flows")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
REQUEST_ENABLE_BLUETOOTH -> {
if (resultCode == RESULT_OK && pendingStart) startRecorder()
else showLocalMessage("Bluetooth must be enabled to record")
pendingStart = false
}
REQUEST_EXPORT -> if (resultCode == RESULT_OK) {
val destination = data?.data ?: return
exportCapture(destination)
}
}
}
private fun buildUi() {
val density = resources.displayMetrics.density
val padding = (24 * density).toInt()
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding(padding, padding, padding, padding)
}
phaseView = TextView(this).apply {
textSize = 30f
setTypeface(typeface, Typeface.BOLD)
}
detailView = TextView(this).apply {
textSize = 17f
gravity = Gravity.CENTER_HORIZONTAL
setPadding(0, padding / 2, 0, padding)
}
countersView = TextView(this).apply {
textSize = 17f
typeface = Typeface.MONOSPACE
setLineSpacing(0f, 1.25f)
}
recordButton = Button(this).apply {
setOnClickListener {
if (snapshot.active) stopRecorder() else requestStart()
}
}
exportButton = Button(this).apply {
text = getString(R.string.export_capture)
setOnClickListener { chooseExportDestination() }
}
content.addView(phaseView, matchWrap())
content.addView(detailView, matchWrap())
content.addView(countersView, matchWrap())
content.addView(recordButton, matchWrap(topMargin = padding))
content.addView(exportButton, matchWrap(topMargin = padding / 2))
setContentView(ScrollView(this).apply { addView(content) })
}
private fun matchWrap(topMargin: Int = 0) = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply { this.topMargin = topMargin }
private fun render() {
phaseView.text = snapshot.phase
detailView.text = snapshot.detail
countersView.text = buildString {
appendLine("duration ${formatDuration(snapshot.durationSeconds)}")
appendLine("frames ${snapshot.frameCount}")
appendLine("samples ${snapshot.sampleCount}")
appendLine("duplicate replays ${snapshot.duplicateCount}")
appendLine("fragment rejects ${snapshot.rejectedFragments}")
appendLine("invalid frames ${snapshot.invalidFrames}")
appendLine("packet gaps ${snapshot.packetGaps}")
appendLine("sample gaps ${snapshot.sampleGaps}")
appendLine("device drops ${snapshot.droppedSamples}")
append("queue overflows ${snapshot.queueOverflows ?: "waiting for status"}")
}
recordButton.text = if (snapshot.active) "Stop and close safely" else "Start recording"
recordButton.isEnabled = snapshot.phase != "Stopping"
exportButton.isEnabled = !snapshot.active && snapshot.capturePath?.let(::File)?.isFile == true
}
private fun requestStart() {
if (!hasBluetoothPermissions()) {
val permissions = mutableListOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
)
if (Build.VERSION.SDK_INT >= 33) permissions += Manifest.permission.POST_NOTIFICATIONS
requestPermissions(permissions.toTypedArray(), REQUEST_PERMISSIONS)
return
}
ensureBluetoothAndStart()
}
private fun hasBluetoothPermissions(): Boolean =
checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
@SuppressLint("MissingPermission")
private fun ensureBluetoothAndStart() {
val adapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter
if (adapter?.isEnabled != true) {
pendingStart = true
@Suppress("DEPRECATION")
startActivityForResult(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), REQUEST_ENABLE_BLUETOOTH)
} else {
startRecorder()
}
}
private fun startRecorder() {
startForegroundService(Intent(this, TelemetryService::class.java).setAction(TelemetryService.ACTION_START))
}
private fun stopRecorder() {
startService(Intent(this, TelemetryService::class.java).setAction(TelemetryService.ACTION_STOP))
}
private fun chooseExportDestination() {
val source = snapshot.capturePath?.let(::File) ?: return
exportPath = source.absolutePath
@Suppress("DEPRECATION")
startActivityForResult(
Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/octet-stream")
.putExtra(Intent.EXTRA_TITLE, source.name),
REQUEST_EXPORT,
)
}
private fun exportCapture(destination: Uri) {
val source = exportPath?.let(::File) ?: return
Thread {
try {
contentResolver.openOutputStream(destination, "wt")!!.use { output ->
source.inputStream().use { input -> input.copyTo(output) }
output.flush()
}
runOnUiThread { showLocalMessage("Exported ${source.name}") }
} catch (error: Exception) {
runOnUiThread { showLocalMessage("Export failed: ${error.message}") }
}
}.start()
}
private fun showLocalMessage(message: String) {
detailView.text = message
}
private fun formatDuration(seconds: Long): String = String.format(
Locale.US,
"%02d:%02d:%02d",
seconds / 3_600,
(seconds / 60) % 60,
seconds % 60,
)
companion object {
private const val REQUEST_PERMISSIONS = 20
private const val REQUEST_ENABLE_BLUETOOTH = 21
private const val REQUEST_EXPORT = 22
}
}
@@ -0,0 +1,317 @@
package com.jsjdesigns.trikkerecorder
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import com.jsjdesigns.trikkerecorder.protocol.BleFrameReassembler
import com.jsjdesigns.trikkerecorder.protocol.TrkProtocol
import com.jsjdesigns.trikkerecorder.storage.CommitResult
import com.jsjdesigns.trikkerecorder.storage.SessionRecorder
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.security.SecureRandom
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
data class RecorderSnapshot(
val active: Boolean = false,
val phase: String = "Idle",
val detail: String = "Ready",
val capturePath: String? = null,
val durationSeconds: Long = 0,
val frameCount: Long = 0,
val sampleCount: Long = 0,
val duplicateCount: Long = 0,
val rejectedFragments: Long = 0,
val invalidFrames: Long = 0,
val packetGaps: Long = 0,
val sampleGaps: Long = 0,
val droppedSamples: Long = 0,
val queueOverflows: Long? = null,
)
class TelemetryService : Service(), BleTransport.Listener {
private val processor = Executors.newSingleThreadExecutor()
private val stopping = AtomicBoolean(false)
private val reassembler = BleFrameReassembler()
private lateinit var transport: BleTransport
private var recorder: SessionRecorder? = null
private var phase = "Idle"
private var detail = "Ready"
private var invalidFrames = 0L
private var lastPublishMs = 0L
private var wakeLock: PowerManager.WakeLock? = null
private var recordingStartElapsedMs = 0L
private var recordingEndElapsedMs = 0L
private var sessionToken = 0L
override fun onCreate() {
super.onCreate()
createNotificationChannel()
transport = BleTransport(this, this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> finishRecording(null)
else -> startRecording()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
transport.stop()
releaseWakeLock()
if (!stopping.get()) {
recorder?.close(System.currentTimeMillis(), SystemClock.elapsedRealtimeNanos(), "Service destroyed")
}
processor.shutdown()
super.onDestroy()
}
override fun onTransportState(state: String, detail: String) {
this.phase = state
this.detail = detail
publish(force = true)
}
override fun onFragment(fragment: ByteArray, connectionEpoch: Long, elapsedNs: Long, wallMs: Long) {
processor.execute {
try {
val raw = reassembler.feed(fragment) ?: run {
publish()
return@execute
}
val frame = TrkProtocol.parse(raw)
if (frame == null) {
invalidFrames++
publish(force = true)
return@execute
}
val activeRecorder = recorder ?: return@execute
when (activeRecorder.commit(frame, elapsedNs, wallMs)) {
CommitResult.PERSISTED,
CommitResult.DUPLICATE,
-> transport.acknowledge(frame.packetSequence, connectionEpoch)
CommitResult.CONFLICT -> fatal("Packet sequence replayed with different bytes")
}
publish()
} catch (error: Exception) {
fatal("Storage failure: ${error.message ?: error.javaClass.simpleName}")
}
}
}
private fun startRecording() {
if (recorder != null || stopping.get()) return
phase = "Starting"
detail = "Opening capture"
recordingStartElapsedMs = SystemClock.elapsedRealtime()
recordingEndElapsedMs = 0L
startForeground(NOTIFICATION_ID, notification(snapshot()))
acquireWakeLock()
val startWallMs = System.currentTimeMillis()
sessionToken = SecureRandom().nextLong()
val stem = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date(startWallMs))
val directory = File(filesDir, "captures")
try {
recorder = SessionRecorder(
directory = directory,
stem = "ride_$stem",
startWallClockMs = startWallMs,
startElapsedRealtimeNs = SystemClock.elapsedRealtimeNanos(),
sessionToken = sessionToken,
)
} catch (error: Exception) {
phase = "Error"
detail = "Cannot open capture: ${error.message}"
releaseWakeLock()
publish(force = true)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return
}
detail = "Capture file opened"
publish(force = true)
transport.start(sessionToken)
}
private fun fatal(message: String) {
mainExecutor.execute { finishRecording(message) }
}
private fun finishRecording(error: String?) {
if (recorder == null || !stopping.compareAndSet(false, true)) return
phase = "Stopping"
detail = error ?: "Closing capture"
recordingEndElapsedMs = SystemClock.elapsedRealtime()
transport.stop()
publish(force = true)
processor.execute {
val activeRecorder = recorder ?: return@execute
try {
activeRecorder.close(
endWallClockMs = System.currentTimeMillis(),
endElapsedRealtimeNs = SystemClock.elapsedRealtimeNanos(),
error = error,
)
getSharedPreferences(PREFERENCES, MODE_PRIVATE).edit()
.putString(LAST_CAPTURE, activeRecorder.captureFile.absolutePath)
.apply()
phase = if (error == null) "Stopped" else "Error"
detail = error ?: "Capture safely closed"
} catch (closeError: Exception) {
phase = "Error"
detail = "Could not close capture: ${closeError.message}"
}
publish(force = true)
mainExecutor.execute {
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
@SuppressLint("WakelockTimeout")
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
wakeLock = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TrikkeRecorder:RideRecording")
.apply {
setReferenceCounted(false)
acquire()
}
}
private fun releaseWakeLock() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
}
private fun snapshot(): RecorderSnapshot {
val activeRecorder = recorder
val stats = activeRecorder?.stats()
val elapsedEnd = recordingEndElapsedMs.takeIf { it != 0L } ?: SystemClock.elapsedRealtime()
val durationSeconds = if (recordingStartElapsedMs == 0L) 0L
else ((elapsedEnd - recordingStartElapsedMs).coerceAtLeast(0L) / 1_000L)
return RecorderSnapshot(
active = activeRecorder != null && !stopping.get(),
phase = phase,
detail = detail,
capturePath = activeRecorder?.captureFile?.absolutePath,
durationSeconds = durationSeconds,
frameCount = stats?.frameCount ?: 0,
sampleCount = stats?.sampleCount ?: 0,
duplicateCount = stats?.duplicateCount ?: 0,
rejectedFragments = reassembler.rejectedFragmentCount,
invalidFrames = invalidFrames,
packetGaps = stats?.integrity?.packetGaps ?: 0,
sampleGaps = stats?.integrity?.sampleGaps ?: 0,
droppedSamples = stats?.integrity?.droppedSamples ?: 0,
queueOverflows = stats?.integrity?.status?.queueOverflows,
)
}
private fun publish(force: Boolean = false) {
val now = SystemClock.elapsedRealtime()
if (!force && now - lastPublishMs < 250) return
lastPublishMs = now
val value = snapshot()
currentSnapshot = value
sendBroadcast(
Intent(ACTION_STATE)
.setPackage(packageName)
.putExtra(EXTRA_ACTIVE, value.active)
.putExtra(EXTRA_PHASE, value.phase)
.putExtra(EXTRA_DETAIL, value.detail)
.putExtra(EXTRA_CAPTURE_PATH, value.capturePath)
.putExtra(EXTRA_DURATION_SECONDS, value.durationSeconds)
.putExtra(EXTRA_FRAMES, value.frameCount)
.putExtra(EXTRA_SAMPLES, value.sampleCount)
.putExtra(EXTRA_DUPLICATES, value.duplicateCount)
.putExtra(EXTRA_REJECTED_FRAGMENTS, value.rejectedFragments)
.putExtra(EXTRA_INVALID_FRAMES, value.invalidFrames)
.putExtra(EXTRA_PACKET_GAPS, value.packetGaps)
.putExtra(EXTRA_SAMPLE_GAPS, value.sampleGaps)
.putExtra(EXTRA_DROPPED, value.droppedSamples)
.putExtra(EXTRA_QUEUE_OVERFLOWS, value.queueOverflows ?: -1L),
)
if (value.active) {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, notification(value))
}
}
private fun notification(value: RecorderSnapshot): Notification {
val openIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val stopIntent = PendingIntent.getService(
this,
1,
Intent(this, TelemetryService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentTitle("Trikke ride recording")
.setContentText("${value.phase}: ${value.sampleCount} samples")
.setContentIntent(openIntent)
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Stop", stopIntent).build())
.build()
}
private fun createNotificationChannel() {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
),
)
}
companion object {
const val ACTION_START = "com.jsjdesigns.trikkerecorder.START"
const val ACTION_STOP = "com.jsjdesigns.trikkerecorder.STOP"
const val ACTION_STATE = "com.jsjdesigns.trikkerecorder.STATE"
const val PREFERENCES = "trikke_recorder"
const val LAST_CAPTURE = "last_capture"
const val EXTRA_ACTIVE = "active"
const val EXTRA_PHASE = "phase"
const val EXTRA_DETAIL = "detail"
const val EXTRA_CAPTURE_PATH = "capture_path"
const val EXTRA_DURATION_SECONDS = "duration_seconds"
const val EXTRA_FRAMES = "frames"
const val EXTRA_SAMPLES = "samples"
const val EXTRA_DUPLICATES = "duplicates"
const val EXTRA_REJECTED_FRAGMENTS = "rejected_fragments"
const val EXTRA_INVALID_FRAMES = "invalid_frames"
const val EXTRA_PACKET_GAPS = "packet_gaps"
const val EXTRA_SAMPLE_GAPS = "sample_gaps"
const val EXTRA_DROPPED = "dropped"
const val EXTRA_QUEUE_OVERFLOWS = "queue_overflows"
private const val CHANNEL_ID = "ride_recording"
private const val NOTIFICATION_ID = 4100
@Volatile
var currentSnapshot = RecorderSnapshot()
private set
}
}
@@ -0,0 +1,113 @@
package com.jsjdesigns.trikkerecorder.protocol
class BleFrameReassembler {
var rejectedFragmentCount: Long = 0
private set
private var sequence: Long? = null
private var totalSize = 0
private var frame = ByteArray(0)
fun reset() {
sequence = null
totalSize = 0
frame = ByteArray(0)
}
fun feed(fragment: ByteArray): ByteArray? {
if (fragment.size <= HEADER_SIZE) {
reject()
return null
}
val fragmentSequence = fragment.u32(0)
val offset = fragment.u16(4)
val declaredSize = fragment.u16(6)
val dataSize = fragment.size - HEADER_SIZE
if (
declaredSize !in MIN_FRAME_SIZE..MAX_FRAME_SIZE ||
offset >= declaredSize ||
offset + dataSize > declaredSize
) {
reject()
return null
}
if (offset == 0) {
sequence = fragmentSequence
totalSize = declaredSize
frame = ByteArray(0)
}
if (
sequence != fragmentSequence ||
totalSize != declaredSize ||
offset != frame.size
) {
reject()
return null
}
frame += fragment.copyOfRange(HEADER_SIZE, fragment.size)
if (frame.size != totalSize) {
return null
}
val completed = frame
reset()
return completed
}
private fun reject() {
rejectedFragmentCount++
reset()
}
companion object {
const val HEADER_SIZE = 8
const val MIN_FRAME_SIZE = 36
const val MAX_FRAME_SIZE = 196
fun encodeAck(packetSequence: Long): ByteArray = byteArrayOf(
'A'.code.toByte(),
'C'.code.toByte(),
'K'.code.toByte(),
'1'.code.toByte(),
packetSequence.toByte(),
(packetSequence ushr 8).toByte(),
(packetSequence ushr 16).toByte(),
(packetSequence ushr 24).toByte(),
)
fun encodeBeginSession(sessionToken: Long): ByteArray = byteArrayOf(
'B'.code.toByte(),
'G'.code.toByte(),
'N'.code.toByte(),
'1'.code.toByte(),
sessionToken.toByte(),
(sessionToken ushr 8).toByte(),
(sessionToken ushr 16).toByte(),
(sessionToken ushr 24).toByte(),
(sessionToken ushr 32).toByte(),
(sessionToken ushr 40).toByte(),
(sessionToken ushr 48).toByte(),
(sessionToken ushr 56).toByte(),
)
}
}
internal fun ByteArray.u16(offset: Int): Int =
(this[offset].toInt() and 0xff) or
((this[offset + 1].toInt() and 0xff) shl 8)
internal fun ByteArray.u32(offset: Int): Long =
(this[offset].toLong() and 0xff) or
((this[offset + 1].toLong() and 0xff) shl 8) or
((this[offset + 2].toLong() and 0xff) shl 16) or
((this[offset + 3].toLong() and 0xff) shl 24)
internal fun ByteArray.u64(offset: Int): Long {
var value = 0L
for (index in 0 until 8) {
value = value or ((this[offset + index].toLong() and 0xff) shl (index * 8))
}
return value
}
@@ -0,0 +1,168 @@
package com.jsjdesigns.trikkerecorder.protocol
import java.util.zip.CRC32
data class TransportStatus(
val sensorReadFailures: Long,
val queueOverflows: Long,
val beginRetries: Long,
val disconnects: Long,
val sendFailures: Long,
val replays: Long,
val invalidAcks: Long,
)
data class TrkFrame(
val raw: ByteArray,
val packetType: Int,
val flags: Int,
val packetSequence: Long,
val baseTimestampUs: Long,
val droppedSampleCount: Long,
val loopOverrunCount: Long,
val sampleSequences: LongArray,
val status: TransportStatus?,
) {
val sampleCount: Int
get() = sampleSequences.size
}
object TrkProtocol {
const val HEADER_SIZE = 36
const val MAX_FRAME_SIZE = 196
const val PACKET_METADATA = 1
const val PACKET_SAMPLES = 2
const val PACKET_STATUS = 3
fun parse(raw: ByteArray): TrkFrame? {
if (raw.size !in HEADER_SIZE..MAX_FRAME_SIZE) return null
if (
raw[0] != 'T'.code.toByte() || raw[1] != 'R'.code.toByte() ||
raw[2] != 'K'.code.toByte() || raw[3] != '1'.code.toByte()
) return null
val version = raw[4].toInt() and 0xff
val packetType = raw[5].toInt() and 0xff
val headerSize = raw[6].toInt() and 0xff
val recordSize = raw[7].toInt() and 0xff
val recordCount = raw[8].toInt() and 0xff
val flags = raw[9].toInt() and 0xff
val payloadSize = raw.u16(10)
if (version != 1 || headerSize != HEADER_SIZE || HEADER_SIZE + payloadSize != raw.size) {
return null
}
val validShape = when (packetType) {
PACKET_METADATA -> recordSize == 0 && recordCount == 0 && payloadSize == 48
PACKET_SAMPLES ->
recordSize == 20 && recordCount in 1..8 && payloadSize == recordSize * recordCount
PACKET_STATUS -> recordSize == 0 && recordCount == 0 && payloadSize == 32
else -> false
}
if (!validShape || raw.u32(32) != calculateCrc(raw)) return null
val status = if (packetType == PACKET_STATUS) {
if (raw.u16(36) != 1 || raw.u16(38) != 32) return null
TransportStatus(
sensorReadFailures = raw.u32(40),
queueOverflows = raw.u32(44),
beginRetries = raw.u32(48),
disconnects = raw.u32(52),
sendFailures = raw.u32(56),
replays = raw.u32(60),
invalidAcks = raw.u32(64),
)
} else {
null
}
val sampleSequences = if (packetType == PACKET_SAMPLES) {
LongArray(recordCount) { index -> raw.u32(HEADER_SIZE + index * recordSize) }
} else {
LongArray(0)
}
return TrkFrame(
raw = raw,
packetType = packetType,
flags = flags,
packetSequence = raw.u32(12),
baseTimestampUs = raw.u64(16),
droppedSampleCount = raw.u32(24),
loopOverrunCount = raw.u32(28),
sampleSequences = sampleSequences,
status = status,
)
}
private fun calculateCrc(raw: ByteArray): Long {
val crc = CRC32()
crc.update(raw, 4, 28)
crc.update(raw, HEADER_SIZE, raw.size - HEADER_SIZE)
return crc.value
}
}
data class IntegritySnapshot(
val packetGaps: Long,
val packetResets: Long,
val sampleGaps: Long,
val sampleResets: Long,
val droppedSamples: Long,
val loopOverruns: Long,
val status: TransportStatus?,
)
class IntegrityTracker {
private var previousPacket: Long? = null
private var previousSample: Long? = null
private var packetGaps = 0L
private var packetResets = 0L
private var sampleGaps = 0L
private var sampleResets = 0L
private var droppedSamples = 0L
private var loopOverruns = 0L
private var status: TransportStatus? = null
fun observe(frame: TrkFrame) {
previousPacket?.let { previous ->
val result = classify(previous, frame.packetSequence)
packetGaps += result.first
packetResets += result.second
}
previousPacket = frame.packetSequence
droppedSamples = frame.droppedSampleCount
loopOverruns = frame.loopOverrunCount
frame.status?.let { status = it }
frame.sampleSequences.forEach { sequence ->
previousSample?.let { previous ->
val result = classify(previous, sequence)
sampleGaps += result.first
sampleResets += result.second
}
previousSample = sequence
}
}
fun snapshot() = IntegritySnapshot(
packetGaps = packetGaps,
packetResets = packetResets,
sampleGaps = sampleGaps,
sampleResets = sampleResets,
droppedSamples = droppedSamples,
loopOverruns = loopOverruns,
status = status,
)
companion object {
fun classify(previous: Long, current: Long): Pair<Long, Long> {
val expected = (previous + 1) and 0xffff_ffffL
val forward = (current - expected) and 0xffff_ffffL
return when {
forward == 0L -> 0L to 0L
forward < 0x8000_0000L -> forward to 0L
else -> 0L to 1L
}
}
}
}
@@ -0,0 +1,180 @@
package com.jsjdesigns.trikkerecorder.storage
import com.jsjdesigns.trikkerecorder.protocol.IntegritySnapshot
import com.jsjdesigns.trikkerecorder.protocol.IntegrityTracker
import com.jsjdesigns.trikkerecorder.protocol.TrkFrame
import java.io.File
import java.io.FileOutputStream
enum class CommitResult {
PERSISTED,
DUPLICATE,
CONFLICT,
}
data class RecorderStats(
val frameCount: Long,
val sampleCount: Long,
val duplicateCount: Long,
val integrity: IntegritySnapshot,
)
class SessionRecorder(
directory: File,
stem: String,
private val startWallClockMs: Long,
private val startElapsedRealtimeNs: Long,
private val sessionToken: Long,
) : AutoCloseable {
val captureFile = File(directory, "$stem.trk")
val summaryFile = File(directory, "$stem.session.json")
private val output: FileOutputStream
private val integrity = IntegrityTracker()
private var lastSequence: Long? = null
private var lastRaw: ByteArray? = null
private var frameCount = 0L
private var sampleCount = 0L
private var duplicateCount = 0L
private var firstFrame: FrameClock? = null
private var lastFrame: FrameClock? = null
private var closed = false
init {
check(directory.exists() || directory.mkdirs()) { "Cannot create ${directory.path}" }
output = FileOutputStream(captureFile, false)
writeSummary(complete = false, endWallClockMs = null, endElapsedRealtimeNs = null, error = null)
}
@Synchronized
fun commit(
frame: TrkFrame,
receivedElapsedRealtimeNs: Long,
receivedWallClockMs: Long,
): CommitResult {
check(!closed) { "Recorder is closed" }
if (lastSequence == frame.packetSequence) {
if (lastRaw!!.contentEquals(frame.raw)) {
duplicateCount++
return CommitResult.DUPLICATE
}
return CommitResult.CONFLICT
}
output.write(frame.raw)
output.flush()
output.fd.sync()
integrity.observe(frame)
frameCount++
sampleCount += frame.sampleCount
lastSequence = frame.packetSequence
lastRaw = frame.raw.copyOf()
val clock = FrameClock(
packetSequence = frame.packetSequence,
deviceBaseTimestampUs = frame.baseTimestampUs,
phoneElapsedRealtimeNs = receivedElapsedRealtimeNs,
phoneWallClockMs = receivedWallClockMs,
)
if (firstFrame == null) firstFrame = clock
lastFrame = clock
return CommitResult.PERSISTED
}
@Synchronized
fun stats(): RecorderStats = RecorderStats(
frameCount = frameCount,
sampleCount = sampleCount,
duplicateCount = duplicateCount,
integrity = integrity.snapshot(),
)
@Synchronized
fun close(endWallClockMs: Long, endElapsedRealtimeNs: Long, error: String? = null) {
if (closed) return
closed = true
output.flush()
output.fd.sync()
output.close()
writeSummary(
complete = error == null,
endWallClockMs = endWallClockMs,
endElapsedRealtimeNs = endElapsedRealtimeNs,
error = error,
)
}
override fun close() {
close(System.currentTimeMillis(), System.nanoTime())
}
private fun writeSummary(
complete: Boolean,
endWallClockMs: Long?,
endElapsedRealtimeNs: Long?,
error: String?,
) {
val stats = stats()
val status = stats.integrity.status
val json = buildString {
append("{\n")
append(" \"schema\": 1,\n")
append(" \"captureFile\": \"").append(captureFile.name).append("\",\n")
append(" \"sessionTokenHex\": \"")
.append(java.lang.Long.toUnsignedString(sessionToken, 16).padStart(16, '0'))
.append("\",\n")
append(" \"complete\": ").append(complete).append(",\n")
append(" \"startWallClockMs\": ").append(startWallClockMs).append(",\n")
append(" \"startElapsedRealtimeNs\": ").append(startElapsedRealtimeNs).append(",\n")
append(" \"endWallClockMs\": ").append(endWallClockMs ?: "null").append(",\n")
append(" \"endElapsedRealtimeNs\": ").append(endElapsedRealtimeNs ?: "null").append(",\n")
append(" \"firstFrame\": ").append(firstFrame?.json() ?: "null").append(",\n")
append(" \"lastFrame\": ").append(lastFrame?.json() ?: "null").append(",\n")
append(" \"frames\": ").append(stats.frameCount).append(",\n")
append(" \"samples\": ").append(stats.sampleCount).append(",\n")
append(" \"duplicateReplays\": ").append(stats.duplicateCount).append(",\n")
append(" \"packetGaps\": ").append(stats.integrity.packetGaps).append(",\n")
append(" \"packetResets\": ").append(stats.integrity.packetResets).append(",\n")
append(" \"sampleGaps\": ").append(stats.integrity.sampleGaps).append(",\n")
append(" \"sampleResets\": ").append(stats.integrity.sampleResets).append(",\n")
append(" \"droppedSamples\": ").append(stats.integrity.droppedSamples).append(",\n")
append(" \"loopOverruns\": ").append(stats.integrity.loopOverruns).append(",\n")
append(" \"queueOverflows\": ").append(status?.queueOverflows ?: "null").append(",\n")
append(" \"transportDisconnects\": ").append(status?.disconnects ?: "null").append(",\n")
append(" \"transportReplays\": ").append(status?.replays ?: "null").append(",\n")
append(" \"error\": ").append(error?.let { "\"${escape(it)}\"" } ?: "null").append("\n")
append("}\n")
}
FileOutputStream(summaryFile, false).use { summary ->
summary.write(json.toByteArray(Charsets.UTF_8))
summary.flush()
summary.fd.sync()
}
}
private fun escape(value: String): String = buildString {
value.forEach { character ->
when (character) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(character)
}
}
}
private data class FrameClock(
val packetSequence: Long,
val deviceBaseTimestampUs: Long,
val phoneElapsedRealtimeNs: Long,
val phoneWallClockMs: Long,
) {
fun json(): String =
"{\"packetSequence\":$packetSequence," +
"\"deviceBaseTimestampUs\":$deviceBaseTimestampUs," +
"\"phoneElapsedRealtimeNs\":$phoneElapsedRealtimeNs," +
"\"phoneWallClockMs\":$phoneWallClockMs}"
}
}
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#00695C"
android:pathData="M4,4h40v40h-40z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M10,12h28v6h-11v20h-6v-20h-11z" />
</vector>
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">Trikke Recorder</string>
<string name="notification_channel_name">Ride recording</string>
<string name="export_capture">Export last .trk</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<style name="Theme.TrikkeRecorder" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#00695C</item>
<item name="android:navigationBarColor">#10201D</item>
<item name="android:statusBarColor">#004D40</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,137 @@
package com.jsjdesigns.trikkerecorder.protocol
import com.jsjdesigns.trikkerecorder.storage.CommitResult
import com.jsjdesigns.trikkerecorder.storage.SessionRecorder
import java.nio.file.Files
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ProtocolContractTest {
private val fixture: ByteArray by lazy {
checkNotNull(javaClass.classLoader?.getResourceAsStream("ble_mtu_race_4bf00eb.trk")) {
"Hardware fixture was not packaged as a test resource"
}.use { it.readBytes() }
}
@Test
fun hardwareFixtureMatchesAndroidParserContract() {
val frames = splitFrames(fixture)
assertTrue(frames.size > 100)
assertTrue(frames.all { TrkProtocol.parse(it) != null })
assertTrue(frames.any { TrkProtocol.parse(it)?.packetType == TrkProtocol.PACKET_METADATA })
assertTrue(frames.any { TrkProtocol.parse(it)?.packetType == TrkProtocol.PACKET_SAMPLES })
assertTrue(frames.any { TrkProtocol.parse(it)?.packetType == TrkProtocol.PACKET_STATUS })
}
@Test
fun crcDamageIsRejected() {
val raw = splitFrames(fixture).first().copyOf()
raw[raw.lastIndex] = (raw.last().toInt() xor 0x80).toByte()
assertNull(TrkProtocol.parse(raw))
}
@Test
fun fragmentReplayRestartsAndCompletesExactFrame() {
val raw = splitFrames(fixture).first()
val sequence = raw.u32(12)
val reassembler = BleFrameReassembler()
assertNull(reassembler.feed(fragment(raw, sequence, 0, 20)))
assertNull(reassembler.feed(fragment(raw, sequence, 0, 40)))
val completed = reassembler.feed(fragment(raw, sequence, 40, raw.size - 40))
assertArrayEquals(raw, completed)
assertEquals(0, reassembler.rejectedFragmentCount)
assertArrayEquals(
byteArrayOf('A'.code.toByte(), 'C'.code.toByte(), 'K'.code.toByte(), '1'.code.toByte()) +
raw.copyOfRange(12, 16),
BleFrameReassembler.encodeAck(sequence),
)
}
@Test
fun missingOrOutOfOrderFragmentIsRejected() {
val raw = splitFrames(fixture).first()
val sequence = raw.u32(12)
val reassembler = BleFrameReassembler()
assertNull(reassembler.feed(fragment(raw, sequence, 20, 20)))
assertEquals(1, reassembler.rejectedFragmentCount)
}
@Test
fun persistencePrecedesDedupeAndSummaryClosure() {
val parsed = checkNotNull(TrkProtocol.parse(splitFrames(fixture).first()))
val directory = Files.createTempDirectory("trikke-recorder-test").toFile()
try {
val recorder = SessionRecorder(directory, "ride_test", 1_000, 2_000, 0x0102030405060708)
assertFalse(recorder.summaryFile.readText().contains("\"complete\": true"))
assertEquals(CommitResult.PERSISTED, recorder.commit(parsed, 3_000, 4_000))
assertEquals(CommitResult.DUPLICATE, recorder.commit(parsed, 5_000, 6_000))
assertArrayEquals(parsed.raw, recorder.captureFile.readBytes())
val conflicting = parsed.copy(raw = parsed.raw.copyOf().also { it[it.lastIndex]++ })
assertEquals(CommitResult.CONFLICT, recorder.commit(conflicting, 7_000, 8_000))
assertArrayEquals(parsed.raw, recorder.captureFile.readBytes())
recorder.close(9_000, 10_000)
val summary = recorder.summaryFile.readText()
assertTrue(summary.contains("\"complete\": true"))
assertTrue(summary.contains("\"sessionTokenHex\": \"0102030405060708\""))
assertTrue(summary.contains("\"frames\": 1"))
assertTrue(summary.contains("\"duplicateReplays\": 1"))
} finally {
directory.deleteRecursively()
}
}
@Test
fun unsignedSequenceClassificationHandlesWrapAndReset() {
assertEquals(0L to 0L, IntegrityTracker.classify(0xffff_ffffL, 0))
assertEquals(2L to 0L, IntegrityTracker.classify(0xffff_fffeL, 1))
assertEquals(0L to 1L, IntegrityTracker.classify(1_000, 0))
}
@Test
fun beginSessionCommandCarriesStableUnsignedToken() {
assertArrayEquals(
byteArrayOf(
'B'.code.toByte(), 'G'.code.toByte(), 'N'.code.toByte(), '1'.code.toByte(),
0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01,
),
BleFrameReassembler.encodeBeginSession(0x0102030405060708),
)
}
private fun splitFrames(stream: ByteArray): List<ByteArray> {
val frames = mutableListOf<ByteArray>()
var offset = 0
while (offset < stream.size) {
assertTrue("truncated header at $offset", offset + TrkProtocol.HEADER_SIZE <= stream.size)
val payloadSize = stream.u16(offset + 10)
val size = TrkProtocol.HEADER_SIZE + payloadSize
assertTrue("truncated frame at $offset", offset + size <= stream.size)
frames += stream.copyOfRange(offset, offset + size)
offset += size
}
assertEquals(stream.size, offset)
return frames
}
private fun fragment(raw: ByteArray, sequence: Long, offset: Int, size: Int): ByteArray {
val envelope = ByteArray(BleFrameReassembler.HEADER_SIZE)
envelope[0] = sequence.toByte()
envelope[1] = (sequence ushr 8).toByte()
envelope[2] = (sequence ushr 16).toByte()
envelope[3] = (sequence ushr 24).toByte()
envelope[4] = offset.toByte()
envelope[5] = (offset ushr 8).toByte()
envelope[6] = raw.size.toByte()
envelope[7] = (raw.size ushr 8).toByte()
return envelope + raw.copyOfRange(offset, offset + size)
}
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.2.1" apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
toolchainVersion=25
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TrikkeRecorder"
include(":app")
+61 -18
View File
@@ -11,7 +11,7 @@ combined by an underlying byte transport.
| ---: | ---: | --- |
| 0 | 4 | ASCII magic `TRK1` |
| 4 | 1 | Wire version (`1`) |
| 5 | 1 | Packet type: metadata `1`, samples `2` |
| 5 | 1 | Packet type: metadata `1`, samples `2`, status `3` |
| 6 | 1 | Header size (`36`) |
| 7 | 1 | Record size (`0` or `20`) |
| 8 | 1 | Record count (`0` or 18) |
@@ -51,7 +51,11 @@ timestamp is reconstructed by cumulatively adding its delta. Firmware ends the
current packet before a delta exceeds the representable 655.35 ms range, making
the next sample the exact base timestamp of a new packet. As a defensive encoder
fallback, an unrepresentable delta is stored as `0xFFFF` and sets packet flag bit
0. Sample sequence gaps remain detectable independently.
0. Because intra-packet deltas are rounded to 10 us while each packet base keeps
the exact ESP timer value, an integrity check for an exact 10,000 us interval can
report up to +/-5 us at packet boundaries; changing the transport's packet size
changes how often that harmless quantization boundary appears. Sample sequence
gaps remain detectable independently.
Mapped raw counts are authoritative. The original sensor-native axes can be
reconstructed because the mappings are lossless:
@@ -76,27 +80,66 @@ A byte-stream receiver may begin inside an incomplete frame. It discards bytes
until a magic/header/CRC combination validates. Host tools report any rejection
before that first valid frame separately from CRC failures after synchronization.
## Status payload (32 bytes)
Status frames use packet type `3`, record size/count zero, and payload version
`1`. They are emitted at startup and approximately every five seconds. Offsets
0 and 2 are uint16 payload version and payload size; the remaining fields are
cumulative uint32 counters:
| Offset | Field |
| ---: | --- |
| 4 | Sensor read failures |
| 8 | Sample-queue overflows |
| 12 | Initial transport submissions that accepted zero bytes |
| 16 | BLE disconnects after a connection was established |
| 20 | BLE notification enqueue/send failures |
| 24 | BLE frame replays after disconnect, subscription change, or ACK timeout |
| 28 | Malformed, stale, premature, or wrong-connection ACK writes |
The header's cumulative dropped-sample count remains the sum of sensor read
failures and queue overflows, preserving version-1 receiver compatibility while
the status payload makes the causes independently observable. USB-specific BLE
counters remain zero. The rejected-ACK total can also include a harmless valid
duplicate that arrives after its frame has already completed, so a nonzero value
does not by itself prove corruption or a hostile receiver.
## Buffering
Acquisition runs in a dedicated higher-priority task and writes complete samples
to a 512-entry RAM queue. The lower-priority output task batches up to eight
records per frame. At 100 Hz this queue represents about 5.12 seconds of
Under BLE, acquisition does not start until the version-2 session handshake has
established a clean capture boundary. Once started, the dedicated
higher-priority acquisition task writes complete samples to a statically
reserved 3072-entry RAM queue. The lower-priority output task batches up to eight
records per frame. At 100 Hz this queue represents about 30.72 seconds of
decoupling when the transport reports backpressure or failure accurately. A
failed write retains and retries the same encoded packet rather than dequeuing
more samples, so the queue accumulates the outage backlog. After reconnection,
the oldest retained data is sent first. If the queue fills, acquisition drops
new samples rather than overwriting older ones; sequence gaps and the cumulative
lost-sample counter expose that permanent loss.
more samples, so the queue accumulates the outage backlog. After a same-token
reconnection, the oldest retained data is sent first. If the queue fills,
acquisition drops new samples rather than overwriting older ones; sequence gaps
and the cumulative lost-sample counter expose that permanent loss.
That retry guarantee requires the transport's success result to mean that the
complete frame was accepted for eventual delivery. The current USB Serial/JTAG
VFS/stdio path does not fully satisfy that contract: if the host remains
connected but stops draining, its internal timeout can discard bytes while the
stdio write appears successful. CRC and sequence checks make that loss visible
to a receiver, but it does not increment the device's drop counter. A direct
driver path with bounded transmit-drain waits can report this condition; an
application acknowledgement and replay window is required for end-to-end
delivery confirmation.
The shared transport state machine distinguishes three nonfatal states. `RETRY`
is valid only from initial submission: it means zero bytes were accepted and the
complete frame may be submitted again. `PENDING` means the backend owns an
in-flight frame, so firmware may only poll that transfer. A `RETRY` returned by
polling fails closed as `FATAL`, because generic code cannot prove whole-frame
resubmission is duplicate-safe. `COMPLETE` permits the output task to reuse its
packet buffer and consume more samples. This prevents a timeout after partial
progress from causing an ambiguous whole-frame duplicate.
The direct USB Serial/JTAG backend atomically copies a complete frame into its TX
ring, then polls a bounded transmit-drain wait. A timeout remains `PENDING`; it
does not trigger resubmission. This closes the VFS/stdio path's silent-discard
case for a connected host that stops draining.
USB drain is not end-to-end application delivery confirmation. A host process
may attach after earlier frames have already left the endpoint, or fail after
the endpoint accepts them. CRC and sequence checks make resulting loss visible,
but an application acknowledgement and replay window are still required to
guarantee receipt. Accordingly, USB `COMPLETE` means endpoint drain, while
reliable BLE reserves `COMPLETE` for an application ACK of the exact frame. See
`ble-transport-v2.md` for session establishment, fragmentation, replay, and
UUIDs.
Receivers report bytes left in an incomplete trailing frame when capture ends.
Those bytes cannot pass CRC validation and are not silently admitted as samples.
@@ -39,11 +39,11 @@ bytes even though stdio reports a successful write. Firmware therefore cannot
retain that particular frame or increment its drop counter. The receiver still
detects the loss through CRC resynchronization and packet/sample sequence gaps.
ESP-IDF's direct USB Serial/JTAG driver provides bounded writes and an explicit
transmit-drain wait, allowing a connected stall to become observable to the
transport policy. That is a useful improvement for the common transport layer.
It is not proof of receiver delivery; application acknowledgements and replay
are needed for that stronger guarantee and are planned with BLE integration.
This limitation was subsequently closed at the device/endpoint boundary by the
shared transport state machine and direct USB Serial/JTAG driver described in
`transport-layer-validation-2026-08-17.md`. It is not proof of receiver delivery;
application acknowledgements and replay are still needed for that stronger
guarantee and are planned with BLE integration.
## Final hardware capture
+119
View File
@@ -0,0 +1,119 @@
# Reliable BLE Transport — Version 2
BLE carries the unchanged, CRC-protected `TRK1` frames defined in
`binary-record-v1.md`. The default peripheral name is `TrikkeSensor`. Version 2
adds a mandatory, idempotent recording-session handshake; the data envelope and
application ACK remain unchanged from version 1.
## GATT service
| Purpose | UUID | Properties |
| --- | --- | --- |
| Service | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10` | Primary service |
| Data | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11` | Notify |
| ACK | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12` | Write, write without response |
| Control | `7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c13` | Write |
The firmware prefers a 256-byte ATT MTU, allowing the largest 196-byte `TRK1`
frame and its eight-byte BLE envelope to fit in one notification. Smaller MTUs
remain protocol-compatible; firmware sends at most eight fragments per bounded
poll, although sustained 100 Hz delivery still depends on the negotiated link.
## Recording-session handshake
Every new capture generates a random uint64 session token. The reference
receivers first write exactly 12 bytes to Control—ASCII `BGN1`, then that token
as little-endian uint64—and enable data notifications after that write succeeds.
Firmware also accepts the reverse order: it tracks the raw CCCD state separately
from session authorization and treats the connection as subscribed as soon as
both conditions are true. Notifications never flow before authorization.
When the token differs from the C3's active token, firmware stores it in
RTC-retained memory and performs a controlled software restart. Acquisition and
output tasks remain stopped after boot. The receiver reconnects and repeats the
same `BGN1` write; firmware recognizes the retained token, authorizes that BLE
connection, starts acquisition with empty queues and zeroed volatile counters,
and accepts the subsequent notification subscription.
The Android acceptance capture measured 10.797 seconds from the user's Start
action to the first durably persisted frame. That includes the initial control
write, controlled restart, advertising and scan latency, GATT reconnection,
same-token authorization, notification subscription, and first frame delivery.
This startup interval is expected and is not part of the recorded sensor stream.
The same token must be written on every reconnect during one recording. That
write is idempotent: it authorizes the new connection without restarting or
discarding the in-flight frame and sample backlog. A different token is an
explicit new-session boundary and deliberately discards all prior volatile
state through the controlled restart.
Firmware does not start acquisition after boot and does not honor a notification
subscription until a valid control write authorizes the connection. This avoids
pre-session queue overflow and prevents an older client from bypassing the
session boundary. The token provides idempotence, not authentication or secrecy.
RTC token recovery is intentionally accepted only after `ESP_RST_SW`. A panic,
watchdog, brownout, or power-on reset discards the token and all volatile stream
state. When the still-recording receiver reconnects and rewrites its unchanged
token, firmware treats it as a new token, performs one additional controlled
restart, and authorizes the following same-token reconnect. Packet and sample
sequences restart at zero inside the receiver's existing file, where the reset is
observable through integrity tracking. This favors a known clean state over
silently treating an uncontrolled reset as continuation of the old session.
## Data notification envelope
Every notification starts with an eight-byte little-endian envelope:
| Offset | Size | Field |
| ---: | ---: | --- |
| 0 | 4 | `TRK1` packet sequence |
| 4 | 2 | Byte offset within the complete `TRK1` frame |
| 6 | 2 | Complete `TRK1` frame size |
| 8 | remaining | Consecutive frame bytes at that offset |
Offset zero starts or restarts a frame. A receiver appends only consecutive
offsets for the same sequence and total size, then validates the complete
`TRK1` header and CRC. A malformed or missing fragment is not acknowledged.
## Application ACK and replay
After validating and persisting a frame, the receiver writes exactly eight bytes
to the ACK characteristic: ASCII `ACK1`, then the acknowledged packet sequence
as little-endian uint32. Firmware accepts an ACK only for the frame it currently
owns and only from the active subscribed connection.
`COMPLETE` is not reported to the output task until that ACK arrives. Until then:
- a one-second ACK timeout replays the frame from offset zero;
- disconnect or notification unsubscription preserves the frame;
- the next subscription, after the same-token control write, replays it from
offset zero;
- BLE polling returns `PENDING`, never `RETRY`, after ownership begins.
The ACK itself can be lost after the receiver persisted the frame. Receivers
therefore compare the sequence and raw bytes with their last persisted frame,
avoid writing a duplicate, and ACK the replay again. Both reference receivers
implement this ordering.
The rejected-ACK counter is diagnostic, not a pure corruption count. A valid
duplicate ACK can arrive after the output task has already completed that frame
and begun the next one; firmware then rejects and counts the now-stale write.
BLE notification success only means the fragment entered the stack. The `ACK1`
write is the end-to-end boundary. It deliberately confirms application
persistence rather than radio or ATT delivery alone.
The current firmware reserves a 3072-sample acquisition queue, providing 30.72
seconds of transport-outage tolerance at the nominal 100 Hz rate. This interval
includes link-loss detection, scanning, GATT reconnection, session
reauthorization, notification subscription, and replay—not merely the time a
phone's Bluetooth control is visibly off. Longer interruptions remain bounded
and detectable through sample-sequence gaps and the queue-overflow counter.
Version 2 remains an unauthenticated, single-connection prototype service. It
does not yet provide pairing, authorization, or confidentiality against a nearby
peer; those are separate from the session and replay guarantees above. A nearby
peer can also deny availability by connecting and withholding the control write,
or by subscribing and never acknowledging. Pairing and connection authorization
are required before treating this as a hostile-environment logger.
@@ -0,0 +1,117 @@
# Direct USB Transport Validation — 2026-08-17
This milestone moved framed telemetry off stdio and the USB Serial/JTAG VFS data
path. The output task now targets a transport-neutral state machine backed by
ESP-IDF's interrupt-driven USB Serial/JTAG driver.
## Transaction contract
- `RETRY`: valid only from initial submission; the backend accepted zero bytes,
so whole-frame resubmission is safe.
- `PENDING`: the backend owns an in-flight frame. Only completion polling is
allowed; the caller must retain and not modify the packet buffer.
- `COMPLETE`: the backend's documented completion criterion is satisfied and the
caller may reuse the packet buffer. USB means endpoint drain; reliable BLE
will mean application acknowledgement of the exact frame.
- `FATAL`: a programming or backend invariant failed. The output task stops
consuming the sample queue rather than silently discarding its in-flight data.
`RETRY` from a pending poll is promoted to `FATAL` because generic code cannot
prove resubmission is duplicate-safe.
For USB, submission uses a 512-byte TX ring and a bounded 50 ms write. ESP-IDF's
ring-buffer send is all-or-nothing for each `TRK1` frame. Once accepted, bounded
50 ms `usb_serial_jtag_wait_tx_done()` calls continue returning `PENDING` until
the host drains the endpoint. The first completion poll happens immediately
after acceptance; repeated pending polls are scheduler-paced. A timeout never
resubmits the frame.
The VFS is switched to driver mode after readable startup output so any
unexpected diagnostic output cannot race the driver's ISR by accessing the
hardware FIFO directly. Firmware logs are disabled before binary telemetry
tasks start, as before. A terminal transport invariant re-enables error logging,
emits one final diagnostic, and suspends the output task. Because no later binary
frame can follow, that diagnostic cannot corrupt a recoverable stream.
Queue-allocation and task-creation failure paths switch the VFS back to its
non-driver mode and uninstall the USB driver before emitting their error, so the
only startup diagnostic is not stranded in a TX ring that is immediately freed.
The USB backend's zero-write-to-`RETRY` mapping depends explicitly on its checked
initialized/sole-owner lifecycle; a torn-down backend fails the guard as
`FATAL` instead of masquerading as backpressure.
## Verification
The host C transport fixture compiles the production state machine with
`-Wall -Wextra -Werror` and verifies:
- zero-accept submissions remain retryable;
- accepted transfers become pending;
- repeated pending polls never call submission again;
- completion returns the sender to idle;
- retry returned after acceptance fails closed without resubmission;
- invalid arguments and unknown backend states fail closed.
The assembled ESP32-C3 prototype first produced a normal 1,728-sample
pre-commit direct-driver capture with no packet gaps, sample gaps, resets, CRC
failures, reported drops, loop overruns, trailing partial bytes, or
timestamp-saturation frames.
After commit `3c95f3d` was built and flashed exactly, a second capture contained
1,680 contiguous samples, sequences 0 through 1,679, with the same zero-loss
integrity result. Its optional raw wire file contains all 563 startup bytes and
re-decodes to CSV byte-for-byte identical to the live-rendered CSV. Both sides
of that exact capture are tracked as fixtures:
- `tests/fixtures/direct_usb_3c95f3d.trk`, SHA-256
`f495486f094a758bb785e145026e3934d60b52dd5083aef7f5d896195d967869`
- `tests/fixtures/direct_usb_3c95f3d.wire`, SHA-256
`3bdaeadff7962c6eac48c4ebeda285c8eb359e439d5e1728104add2009122c03`
For the connected-stall case, the USB endpoint was left enumerated without a
serial reader long enough to overflow the 512-sample acquisition queue. When the
reader opened, delivery preserved the oldest block through sequence 511 and
resumed at sequence 1,706. The one observed gap and the cumulative device drop
count both equal 1,194 samples. The timestamp difference across that gap is
11,950,000 us, exactly 1,195 sample intervals. Packet gaps, CRC failures, loop
overruns, trailing partial bytes, and timestamp-saturation flags are zero.
That exact validated stream is tracked as
`tests/fixtures/direct_usb_stall.trk`, SHA-256
`40f874b7eaa7f705524ecdd75f832e8a724252366633116ac015fc75dfd16558`, and
its signature is asserted by the regression suite.
## Remaining delivery boundary
The stall capture begins at sample sequence 8. The flashing process still had
the serial endpoint open long enough to drain sequences 0 through 7 before the
capture application attached. This is not silent device-side loss; it precisely
demonstrates the boundary of USB drain confirmation. Only an application-level
ACK can prove that the intended receiver received and persisted a frame.
The planned BLE backend will use the same `PENDING` ownership rule while waiting
for acknowledgements, retain unacknowledged frames for replay, and expose
per-cause transport/queue counters separately.
## Host evidence handling
Live capture and offline decode now share one integrity tracker for packet and
sample gaps/resets, timestamp anomalies, sensor status, saturation flags, drops,
and acquisition overruns. Sequence classification is wrap-aware. The capture
tool also accepts `--wire PATH` to preserve every serial byte before parsing,
including startup text, corrupt frames, and trailing fragments.
For deterministic fresh-session validation, `capture_binary.py --reset` releases
DTR/RTS, clears the prior input session, then pulses the C3 reset line while the
same reader remains open. This follows ESP-IDF monitor's USB Serial/JTAG reset
ordering and avoids a flash-to-capture port-open race.
Commit `73e5680` was built, flashed, and then captured through this reset path.
The result contains 1,184 contiguous samples, sequences 0 through 1,183, with
zero packet/sample gaps, resets, CRC failures, reported drops, loop overruns,
trailing bytes, or timestamp saturation. Raw-wire offline decoding produced CSV
byte-for-byte identical to live rendering. Both artifacts are tracked:
- `tests/fixtures/direct_usb_73e5680.trk`, SHA-256
`fd34bb3bf8f92a64960024f1287e553c03076ff3714fe629ec629bec81ddf821`
- `tests/fixtures/direct_usb_73e5680.wire`, SHA-256
`82d6d17bbf0729e9bfc53f337ec9adf70bcb5e5898b039685eda5dfa19cf4eea`
+14 -3
View File
@@ -1,6 +1,17 @@
set(trikke_sources
"trikke_sensor_main.c" "trikke_protocol.c" "trikke_transport.c")
set(trikke_requires
adxl345 l3g4200d esp_timer esp_driver_gpio esp_driver_i2c
bt nvs_flash esp_driver_usb_serial_jtag vfs)
if(CONFIG_TRIKKE_TRANSPORT_BLE)
list(APPEND trikke_sources "trikke_ble_protocol.c" "trikke_ble_transport.c")
else()
list(APPEND trikke_sources "trikke_usb_transport.c")
endif()
idf_component_register(
SRCS "trikke_sensor_main.c" "trikke_protocol.c"
SRCS ${trikke_sources}
INCLUDE_DIRS "."
REQUIRES adxl345 l3g4200d esp_timer esp_driver_gpio esp_driver_i2c
esp_driver_usb_serial_jtag vfs
REQUIRES ${trikke_requires}
)
+15
View File
@@ -0,0 +1,15 @@
choice TRIKKE_TRANSPORT
prompt "Telemetry transport"
default TRIKKE_TRANSPORT_BLE
config TRIKKE_TRANSPORT_BLE
bool "Reliable BLE"
help
Stream TRK1 frames over the Trikke GATT service and retain each
frame until the receiver writes its application acknowledgement.
config TRIKKE_TRANSPORT_USB
bool "Direct USB Serial/JTAG"
help
Preserve the audited direct USB transport for wired validation.
endchoice
+125
View File
@@ -0,0 +1,125 @@
#include "trikke_ble_protocol.h"
#include <string.h>
#include "trikke_protocol.h"
static uint16_t get_u16_le(const uint8_t *input)
{
return (uint16_t)input[0] | ((uint16_t)input[1] << 8);
}
static uint32_t get_u32_le(const uint8_t *input)
{
return (uint32_t)input[0] | ((uint32_t)input[1] << 8) |
((uint32_t)input[2] << 16) | ((uint32_t)input[3] << 24);
}
static uint64_t get_u64_le(const uint8_t *input)
{
return (uint64_t)get_u32_le(input) |
((uint64_t)get_u32_le(input + 4) << 32);
}
static void put_u16_le(uint8_t *output, uint16_t value)
{
output[0] = (uint8_t)value;
output[1] = (uint8_t)(value >> 8);
}
static void put_u32_le(uint8_t *output, uint32_t value)
{
output[0] = (uint8_t)value;
output[1] = (uint8_t)(value >> 8);
output[2] = (uint8_t)(value >> 16);
output[3] = (uint8_t)(value >> 24);
}
static bool packet_shape_is_valid(const uint8_t *packet, size_t packet_size)
{
if (packet == NULL || packet_size < TRIKKE_WIRE_HEADER_SIZE ||
packet_size > TRIKKE_WIRE_MAX_PACKET_SIZE ||
memcmp(packet, "TRK1", 4) != 0 ||
packet[4] != TRIKKE_WIRE_VERSION ||
packet[6] != TRIKKE_WIRE_HEADER_SIZE) {
return false;
}
const size_t encoded_size =
TRIKKE_WIRE_HEADER_SIZE + get_u16_le(packet + 10);
return encoded_size == packet_size;
}
size_t trikke_ble_encode_fragment(
uint8_t *output,
size_t output_size,
const uint8_t *packet,
size_t packet_size,
size_t packet_offset,
size_t att_payload_capacity)
{
if (output == NULL || !packet_shape_is_valid(packet, packet_size) ||
packet_offset >= packet_size || packet_size > UINT16_MAX ||
packet_offset > UINT16_MAX ||
att_payload_capacity <= TRIKKE_BLE_FRAGMENT_HEADER_SIZE) {
return 0;
}
size_t data_size =
att_payload_capacity - TRIKKE_BLE_FRAGMENT_HEADER_SIZE;
const size_t remaining = packet_size - packet_offset;
if (data_size > remaining) {
data_size = remaining;
}
const size_t fragment_size = TRIKKE_BLE_FRAGMENT_HEADER_SIZE + data_size;
if (output_size < fragment_size) {
return 0;
}
put_u32_le(output, get_u32_le(packet + 12));
put_u16_le(output + 4, (uint16_t)packet_offset);
put_u16_le(output + 6, (uint16_t)packet_size);
memcpy(output + TRIKKE_BLE_FRAGMENT_HEADER_SIZE,
packet + packet_offset, data_size);
return fragment_size;
}
bool trikke_ble_decode_ack(
const uint8_t *ack,
size_t ack_size,
uint32_t *packet_sequence)
{
if (ack == NULL || packet_sequence == NULL ||
ack_size != TRIKKE_BLE_ACK_SIZE || memcmp(ack, "ACK1", 4) != 0) {
return false;
}
*packet_sequence = get_u32_le(ack + 4);
return true;
}
bool trikke_ble_decode_begin_session(
const uint8_t *command,
size_t command_size,
uint64_t *session_token)
{
if (command == NULL || session_token == NULL ||
command_size != TRIKKE_BLE_BEGIN_SESSION_SIZE ||
memcmp(command, "BGN1", 4) != 0) {
return false;
}
*session_token = get_u64_le(command + 4);
return true;
}
bool trikke_ble_update_subscription(
bool notify_enabled,
bool session_connection_ready,
bool *subscribed)
{
if (subscribed == NULL) {
return false;
}
const bool effective = notify_enabled && session_connection_ready;
const bool changed = effective != *subscribed;
*subscribed = effective;
return changed;
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define TRIKKE_BLE_FRAGMENT_HEADER_SIZE 8
#define TRIKKE_BLE_ACK_SIZE 8
#define TRIKKE_BLE_BEGIN_SESSION_SIZE 12
// BLE data notifications carry a little-endian packet sequence, byte offset,
// total TRK1 frame size, then the frame bytes at that offset. The unchanged
// TRK1 CRC remains the end-to-end integrity check after reassembly.
size_t trikke_ble_encode_fragment(
uint8_t *output,
size_t output_size,
const uint8_t *packet,
size_t packet_size,
size_t packet_offset,
size_t att_payload_capacity);
// An application acknowledgement is ASCII "ACK1" followed by the exact
// little-endian TRK1 packet sequence that the receiver persisted.
bool trikke_ble_decode_ack(
const uint8_t *ack,
size_t ack_size,
uint32_t *packet_sequence);
// A session begin is ASCII "BGN1" followed by a receiver-generated uint64
// token. Repeating the same token is idempotent across BLE reconnects.
bool trikke_ble_decode_begin_session(
const uint8_t *command,
size_t command_size,
uint64_t *session_token);
// Notification delivery is active only after both the CCCD and session
// authorization are ready. Returns true when the effective state changes.
bool trikke_ble_update_subscription(
bool notify_enabled,
bool session_connection_ready,
bool *subscribed);
#ifdef __cplusplus
}
#endif
+658
View File
@@ -0,0 +1,658 @@
#include "trikke_ble_transport.h"
#include <string.h>
#include "host/ble_att.h"
#include "host/ble_gap.h"
#include "host/ble_gatt.h"
#include "host/ble_hs.h"
#include "host/ble_uuid.h"
#include "host/util/util.h"
#include "esp_attr.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "nvs_flash.h"
#include "os/os_mbuf.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#include "trikke_ble_protocol.h"
#include "trikke_protocol.h"
#define TRIKKE_BLE_DEVICE_NAME "TrikkeSensor"
#define TRIKKE_BLE_MAX_ATT_PAYLOAD 253
#define TRIKKE_BLE_FRAGMENTS_PER_POLL 8
#define TRIKKE_BLE_ACK_TIMEOUT_US 1000000
#define TRIKKE_BLE_SESSION_RTC_MAGIC UINT32_C(0x54524b53)
#define TRIKKE_BLE_SESSION_RTC_XOR UINT32_C(0xa93cf17e)
#define TRIKKE_BLE_SESSION_RESET_DELAY_MS 50
static trikke_ble_transport_t *s_ble;
static uint8_t s_own_address_type;
static uint16_t s_data_value_handle;
static uint16_t s_ack_value_handle;
static uint16_t s_control_value_handle;
typedef struct {
uint32_t token_low;
uint32_t token_high;
uint32_t checksum;
uint32_t magic;
} trikke_ble_rtc_session_t;
RTC_NOINIT_ATTR static trikke_ble_rtc_session_t s_rtc_session;
// 7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10 and adjacent characteristic UUIDs.
static const ble_uuid128_t TRIKKE_SERVICE_UUID =
BLE_UUID128_INIT(0x10, 0x9c, 0x1e, 0x2a, 0x4c, 0x3d, 0xbe, 0x8f,
0x9b, 0x4a, 0x5b, 0xf7, 0x00, 0xa0, 0x2e, 0x7d);
static const ble_uuid128_t TRIKKE_DATA_UUID =
BLE_UUID128_INIT(0x11, 0x9c, 0x1e, 0x2a, 0x4c, 0x3d, 0xbe, 0x8f,
0x9b, 0x4a, 0x5b, 0xf7, 0x00, 0xa0, 0x2e, 0x7d);
static const ble_uuid128_t TRIKKE_ACK_UUID =
BLE_UUID128_INIT(0x12, 0x9c, 0x1e, 0x2a, 0x4c, 0x3d, 0xbe, 0x8f,
0x9b, 0x4a, 0x5b, 0xf7, 0x00, 0xa0, 0x2e, 0x7d);
static const ble_uuid128_t TRIKKE_CONTROL_UUID =
BLE_UUID128_INIT(0x13, 0x9c, 0x1e, 0x2a, 0x4c, 0x3d, 0xbe, 0x8f,
0x9b, 0x4a, 0x5b, 0xf7, 0x00, 0xa0, 0x2e, 0x7d);
static uint32_t get_u32_le(const uint8_t *input)
{
return (uint32_t)input[0] | ((uint32_t)input[1] << 8) |
((uint32_t)input[2] << 16) | ((uint32_t)input[3] << 24);
}
// Caller must hold ble->lock. Keep the raw CCCD state independent from session
// authorization so either legal write order converges on the same effective
// subscription state.
static void update_subscribed_locked(trikke_ble_transport_t *ble)
{
if (trikke_ble_update_subscription(
ble->notify_enabled,
ble->session_connection_ready,
&ble->subscribed)) {
++ble->delivery_epoch;
}
}
static uint32_t rtc_session_checksum(uint32_t low, uint32_t high)
{
return TRIKKE_BLE_SESSION_RTC_MAGIC ^ low ^ high ^
TRIKKE_BLE_SESSION_RTC_XOR;
}
static void rtc_session_clear(void)
{
s_rtc_session.magic = 0;
s_rtc_session.token_low = 0;
s_rtc_session.token_high = 0;
s_rtc_session.checksum = 0;
}
static void rtc_session_store(uint64_t token)
{
const uint32_t low = (uint32_t)token;
const uint32_t high = (uint32_t)(token >> 32);
s_rtc_session.magic = 0;
s_rtc_session.token_low = low;
s_rtc_session.token_high = high;
s_rtc_session.checksum = rtc_session_checksum(low, high);
s_rtc_session.magic = TRIKKE_BLE_SESSION_RTC_MAGIC;
}
static bool rtc_session_load(uint64_t *token)
{
if (token == NULL || s_rtc_session.magic != TRIKKE_BLE_SESSION_RTC_MAGIC ||
s_rtc_session.checksum != rtc_session_checksum(
s_rtc_session.token_low, s_rtc_session.token_high)) {
return false;
}
*token = (uint64_t)s_rtc_session.token_low |
((uint64_t)s_rtc_session.token_high << 32);
return true;
}
static void session_restart_task(void *argument)
{
(void)argument;
vTaskDelay(pdMS_TO_TICKS(TRIKKE_BLE_SESSION_RESET_DELAY_MS));
esp_restart();
}
static int data_access(
uint16_t connection_handle,
uint16_t attribute_handle,
struct ble_gatt_access_ctxt *context,
void *argument)
{
(void)connection_handle;
(void)attribute_handle;
(void)context;
(void)argument;
// NimBLE requires every characteristic definition to have an access
// callback, even though this notify-only value is never client-accessible.
return BLE_ATT_ERR_UNLIKELY;
}
static int ack_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 ack[TRIKKE_BLE_ACK_SIZE] = {0};
uint16_t ack_size = 0;
if (OS_MBUF_PKTLEN(context->om) != TRIKKE_BLE_ACK_SIZE ||
ble_hs_mbuf_to_flat(context->om, ack, sizeof(ack), &ack_size) != 0) {
portENTER_CRITICAL(&ble->lock);
++ble->counters.invalid_ack_count;
portEXIT_CRITICAL(&ble->lock);
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
uint32_t acknowledged_sequence = 0;
const bool valid_shape =
trikke_ble_decode_ack(ack, ack_size, &acknowledged_sequence);
portENTER_CRITICAL(&ble->lock);
const bool accepted = valid_shape && ble->frame_active &&
ble->frame_fully_sent_once &&
ble->connected && ble->subscribed &&
ble->connection_handle == connection_handle &&
ble->frame_epoch == ble->delivery_epoch &&
acknowledged_sequence == ble->frame_sequence;
if (accepted) {
ble->ack_received = true;
} else {
++ble->counters.invalid_ack_count;
}
portEXIT_CRITICAL(&ble->lock);
return accepted ? 0 : BLE_ATT_ERR_UNLIKELY;
}
static int session_access(
uint16_t connection_handle,
uint16_t attribute_handle,
struct ble_gatt_access_ctxt *context,
void *argument)
{
(void)attribute_handle;
(void)argument;
trikke_ble_transport_t *ble = s_ble;
if (ble == NULL || context->op != BLE_GATT_ACCESS_OP_WRITE_CHR) {
return BLE_ATT_ERR_UNLIKELY;
}
uint8_t command[TRIKKE_BLE_BEGIN_SESSION_SIZE] = {0};
uint16_t command_size = 0;
uint64_t requested_token = 0;
if (OS_MBUF_PKTLEN(context->om) != sizeof(command) ||
ble_hs_mbuf_to_flat(
context->om, command, sizeof(command), &command_size) != 0 ||
!trikke_ble_decode_begin_session(
command, command_size, &requested_token)) {
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
bool notify_session_ready = false;
bool restart = false;
portENTER_CRITICAL(&ble->lock);
if (!ble->connected || ble->connection_handle != connection_handle ||
ble->session_restart_pending) {
portEXIT_CRITICAL(&ble->lock);
return BLE_ATT_ERR_UNLIKELY;
}
if (ble->session_token_valid && ble->session_token == requested_token) {
ble->session_connection_ready = true;
update_subscribed_locked(ble);
if (!ble->session_started) {
ble->session_started = true;
notify_session_ready = true;
}
} else {
ble->session_connection_ready = false;
update_subscribed_locked(ble);
ble->session_restart_pending = true;
rtc_session_store(requested_token);
restart = true;
}
portEXIT_CRITICAL(&ble->lock);
if (notify_session_ready && ble->session_ready != NULL) {
ble->session_ready(ble->session_ready_context);
}
if (restart && xTaskCreate(
session_restart_task, "trikke_session_reset", 2048, NULL,
configMAX_PRIORITIES - 1, NULL) != pdPASS) {
rtc_session_clear();
portENTER_CRITICAL(&ble->lock);
ble->session_restart_pending = false;
portEXIT_CRITICAL(&ble->lock);
return BLE_ATT_ERR_UNLIKELY;
}
return 0;
}
static const struct ble_gatt_svc_def TRIKKE_GATT_SERVICES[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &TRIKKE_SERVICE_UUID.u,
.characteristics = (struct ble_gatt_chr_def[]) {
{
.uuid = &TRIKKE_DATA_UUID.u,
.access_cb = data_access,
.flags = BLE_GATT_CHR_F_NOTIFY,
.val_handle = &s_data_value_handle,
},
{
.uuid = &TRIKKE_ACK_UUID.u,
.access_cb = ack_access,
.flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP,
.val_handle = &s_ack_value_handle,
},
{
.uuid = &TRIKKE_CONTROL_UUID.u,
.access_cb = session_access,
.flags = BLE_GATT_CHR_F_WRITE,
.val_handle = &s_control_value_handle,
},
{0},
},
},
{0},
};
static int gap_event(struct ble_gap_event *event, void *argument);
static int advertise(void)
{
struct ble_hs_adv_fields fields = {0};
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
fields.uuids128 = (ble_uuid128_t *)&TRIKKE_SERVICE_UUID;
fields.num_uuids128 = 1;
fields.uuids128_is_complete = 1;
int result = ble_gap_adv_set_fields(&fields);
if (result != 0) {
return result;
}
const char *name = ble_svc_gap_device_name();
struct ble_hs_adv_fields response = {0};
response.name = (uint8_t *)name;
response.name_len = strlen(name);
response.name_is_complete = 1;
result = ble_gap_adv_rsp_set_fields(&response);
if (result != 0) {
return result;
}
const struct ble_gap_adv_params parameters = {
.conn_mode = BLE_GAP_CONN_MODE_UND,
.disc_mode = BLE_GAP_DISC_MODE_GEN,
};
return ble_gap_adv_start(s_own_address_type, NULL, BLE_HS_FOREVER,
&parameters, gap_event, NULL);
}
static void on_reset(int reason)
{
(void)reason;
trikke_ble_transport_t *ble = s_ble;
if (ble == NULL) {
return;
}
portENTER_CRITICAL(&ble->lock);
if (ble->connected) {
++ble->counters.disconnect_count;
}
ble->connected = false;
ble->notify_enabled = false;
ble->subscribed = false;
ble->session_connection_ready = false;
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
++ble->delivery_epoch;
portEXIT_CRITICAL(&ble->lock);
}
static void on_sync(void)
{
if (ble_hs_util_ensure_addr(0) != 0 ||
ble_hs_id_infer_auto(0, &s_own_address_type) != 0) {
return;
}
(void)advertise();
}
static int gap_event(struct ble_gap_event *event, void *argument)
{
(void)argument;
trikke_ble_transport_t *ble = s_ble;
if (ble == NULL) {
return 0;
}
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
portENTER_CRITICAL(&ble->lock);
ble->connected = true;
ble->notify_enabled = false;
ble->subscribed = false;
ble->session_connection_ready = false;
ble->connection_handle = event->connect.conn_handle;
++ble->delivery_epoch;
portEXIT_CRITICAL(&ble->lock);
} else {
(void)advertise();
}
return 0;
case BLE_GAP_EVENT_DISCONNECT:
portENTER_CRITICAL(&ble->lock);
if (ble->connected) {
++ble->counters.disconnect_count;
}
ble->connected = false;
ble->notify_enabled = false;
ble->subscribed = false;
ble->session_connection_ready = false;
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
++ble->delivery_epoch;
portEXIT_CRITICAL(&ble->lock);
(void)advertise();
return 0;
case BLE_GAP_EVENT_SUBSCRIBE:
if (event->subscribe.attr_handle == s_data_value_handle) {
portENTER_CRITICAL(&ble->lock);
ble->notify_enabled = event->subscribe.cur_notify != 0;
update_subscribed_locked(ble);
portEXIT_CRITICAL(&ble->lock);
}
return 0;
case BLE_GAP_EVENT_ADV_COMPLETE:
(void)advertise();
return 0;
default:
return 0;
}
}
static void host_task(void *argument)
{
(void)argument;
nimble_port_run();
nimble_port_freertos_deinit();
}
static trikke_transport_status_t ble_begin_packet(
void *context,
const uint8_t *packet,
size_t packet_size)
{
trikke_ble_transport_t *ble = context;
if (ble == NULL || packet == NULL ||
packet_size < TRIKKE_WIRE_HEADER_SIZE ||
packet_size > TRIKKE_WIRE_MAX_PACKET_SIZE) {
return TRIKKE_TRANSPORT_FATAL;
}
portENTER_CRITICAL(&ble->lock);
if (!ble->initialized || ble->frame_active) {
portEXIT_CRITICAL(&ble->lock);
return TRIKKE_TRANSPORT_FATAL;
}
if (!ble->connected || !ble->subscribed) {
portEXIT_CRITICAL(&ble->lock);
return TRIKKE_TRANSPORT_RETRY;
}
ble->frame_active = true;
ble->frame_fully_sent_once = false;
ble->ack_received = false;
ble->frame = packet;
ble->frame_size = packet_size;
ble->next_offset = 0;
ble->ack_deadline_us = 0;
ble->frame_sequence = get_u32_le(packet + 12);
ble->frame_epoch = ble->delivery_epoch;
portEXIT_CRITICAL(&ble->lock);
return TRIKKE_TRANSPORT_PENDING;
}
static trikke_transport_status_t ble_poll_once(void *context)
{
trikke_ble_transport_t *ble = context;
if (ble == NULL) {
return TRIKKE_TRANSPORT_FATAL;
}
uint16_t connection_handle = BLE_HS_CONN_HANDLE_NONE;
uint32_t delivery_epoch = 0;
const uint8_t *frame = NULL;
size_t frame_size = 0;
size_t next_offset = 0;
portENTER_CRITICAL(&ble->lock);
if (!ble->initialized || !ble->frame_active) {
portEXIT_CRITICAL(&ble->lock);
return TRIKKE_TRANSPORT_FATAL;
}
if (ble->ack_received) {
ble->frame_active = false;
ble->frame_fully_sent_once = false;
ble->ack_received = false;
ble->frame = NULL;
ble->frame_size = 0;
ble->next_offset = 0;
ble->ack_deadline_us = 0;
portEXIT_CRITICAL(&ble->lock);
return TRIKKE_TRANSPORT_COMPLETE;
}
if (!ble->connected || !ble->subscribed) {
portEXIT_CRITICAL(&ble->lock);
return TRIKKE_TRANSPORT_PENDING;
}
if (ble->frame_epoch != ble->delivery_epoch) {
if (ble->next_offset != 0) {
++ble->counters.replay_count;
}
ble->next_offset = 0;
ble->frame_epoch = ble->delivery_epoch;
ble->ack_deadline_us = 0;
}
if (ble->next_offset == ble->frame_size) {
if (esp_timer_get_time() >= ble->ack_deadline_us) {
ble->next_offset = 0;
ble->ack_deadline_us = 0;
++ble->counters.replay_count;
} else {
portEXIT_CRITICAL(&ble->lock);
return TRIKKE_TRANSPORT_PENDING;
}
}
connection_handle = ble->connection_handle;
delivery_epoch = ble->delivery_epoch;
frame = ble->frame;
frame_size = ble->frame_size;
next_offset = ble->next_offset;
portEXIT_CRITICAL(&ble->lock);
const uint16_t mtu = ble_att_mtu(connection_handle);
if (mtu == 0) {
// The connection can disappear after the locked state snapshot above.
// The GAP callback advances the delivery epoch; retain ownership and
// let a later poll restart the frame after resubscription.
return TRIKKE_TRANSPORT_PENDING;
}
if (mtu <= 3 + TRIKKE_BLE_FRAGMENT_HEADER_SIZE) {
return TRIKKE_TRANSPORT_FATAL;
}
size_t att_payload_capacity = mtu - 3;
if (att_payload_capacity > TRIKKE_BLE_MAX_ATT_PAYLOAD) {
att_payload_capacity = TRIKKE_BLE_MAX_ATT_PAYLOAD;
}
uint8_t fragment[TRIKKE_BLE_MAX_ATT_PAYLOAD] = {0};
const size_t fragment_size = trikke_ble_encode_fragment(
fragment, sizeof(fragment), frame, frame_size, next_offset,
att_payload_capacity);
if (fragment_size == 0) {
return TRIKKE_TRANSPORT_FATAL;
}
struct os_mbuf *notification =
ble_hs_mbuf_from_flat(fragment, fragment_size);
int result = BLE_HS_ENOMEM;
if (notification != NULL) {
result = ble_gatts_notify_custom(
connection_handle, s_data_value_handle, notification);
}
if (result != 0) {
portENTER_CRITICAL(&ble->lock);
++ble->counters.send_failure_count;
portEXIT_CRITICAL(&ble->lock);
if (result == BLE_HS_ENOMEM || result == BLE_HS_EBUSY ||
result == BLE_HS_EAGAIN || result == BLE_HS_ENOTCONN) {
return TRIKKE_TRANSPORT_PENDING;
}
return TRIKKE_TRANSPORT_FATAL;
}
const size_t sent_data_size =
fragment_size - TRIKKE_BLE_FRAGMENT_HEADER_SIZE;
portENTER_CRITICAL(&ble->lock);
if (ble->frame_active && ble->connected && ble->subscribed &&
ble->connection_handle == connection_handle &&
ble->delivery_epoch == delivery_epoch &&
ble->next_offset == next_offset) {
ble->next_offset += sent_data_size;
if (ble->next_offset == ble->frame_size) {
ble->frame_fully_sent_once = true;
ble->ack_deadline_us =
esp_timer_get_time() + TRIKKE_BLE_ACK_TIMEOUT_US;
}
}
portEXIT_CRITICAL(&ble->lock);
return TRIKKE_TRANSPORT_PENDING;
}
static trikke_transport_status_t ble_poll_packet(void *context)
{
trikke_ble_transport_t *ble = context;
if (ble == NULL) {
return TRIKKE_TRANSPORT_FATAL;
}
for (unsigned fragment = 0;
fragment < TRIKKE_BLE_FRAGMENTS_PER_POLL;
++fragment) {
portENTER_CRITICAL(&ble->lock);
const size_t offset_before_poll = ble->next_offset;
portEXIT_CRITICAL(&ble->lock);
const trikke_transport_status_t status = ble_poll_once(context);
if (status != TRIKKE_TRANSPORT_PENDING) {
return status;
}
// Keep a small-MTU connection useful without turning one poll into an
// unbounded loop. Stop as soon as the backend is waiting on either the
// connection/subscription or the receiver's application ACK.
portENTER_CRITICAL(&ble->lock);
const bool waiting = !ble->connected || !ble->subscribed ||
ble->next_offset == ble->frame_size;
const bool made_progress = ble->next_offset != offset_before_poll;
portEXIT_CRITICAL(&ble->lock);
if (waiting || !made_progress) {
return TRIKKE_TRANSPORT_PENDING;
}
}
return TRIKKE_TRANSPORT_PENDING;
}
esp_err_t trikke_ble_transport_init(
trikke_ble_transport_t *ble,
trikke_transport_t *transport,
trikke_ble_session_ready_fn session_ready,
void *session_ready_context)
{
if (ble == NULL || transport == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (s_ble != NULL || ble->initialized) {
return ESP_ERR_INVALID_STATE;
}
memset(ble, 0, sizeof(*ble));
ble->lock = (portMUX_TYPE)portMUX_INITIALIZER_UNLOCKED;
ble->connection_handle = BLE_HS_CONN_HANDLE_NONE;
ble->session_ready = session_ready;
ble->session_ready_context = session_ready_context;
if (esp_reset_reason() == ESP_RST_SW &&
rtc_session_load(&ble->session_token)) {
ble->session_token_valid = true;
} else {
rtc_session_clear();
}
s_ble = ble;
esp_err_t error = nvs_flash_init();
if (error == ESP_ERR_NVS_NO_FREE_PAGES ||
error == ESP_ERR_NVS_NEW_VERSION_FOUND) {
error = nvs_flash_erase();
if (error == ESP_OK) {
error = nvs_flash_init();
}
}
if (error != ESP_OK) {
s_ble = NULL;
return error;
}
error = nimble_port_init();
if (error != ESP_OK) {
s_ble = NULL;
return error;
}
ble_hs_cfg.reset_cb = on_reset;
ble_hs_cfg.sync_cb = on_sync;
ble_svc_gap_init();
ble_svc_gatt_init();
int result = ble_gatts_count_cfg(TRIKKE_GATT_SERVICES);
if (result == 0) {
result = ble_gatts_add_svcs(TRIKKE_GATT_SERVICES);
}
if (result == 0) {
result = ble_svc_gap_device_name_set(TRIKKE_BLE_DEVICE_NAME);
}
if (result != 0) {
(void)nimble_port_deinit();
s_ble = NULL;
return ESP_FAIL;
}
ble->initialized = true;
transport->context = ble;
transport->begin = ble_begin_packet;
transport->poll = ble_poll_packet;
nimble_port_freertos_init(host_task);
return ESP_OK;
}
void trikke_ble_transport_get_counters(
trikke_ble_transport_t *ble,
trikke_ble_transport_counters_t *counters)
{
if (ble == NULL || counters == NULL) {
return;
}
portENTER_CRITICAL(&ble->lock);
*counters = ble->counters;
portEXIT_CRITICAL(&ble->lock);
}
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "trikke_transport.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
uint32_t disconnect_count;
uint32_t send_failure_count;
uint32_t replay_count;
uint32_t invalid_ack_count;
} trikke_ble_transport_counters_t;
typedef void (*trikke_ble_session_ready_fn)(void *context);
typedef struct {
portMUX_TYPE lock;
bool initialized;
bool connected;
bool notify_enabled;
bool subscribed;
bool frame_active;
bool frame_fully_sent_once;
bool ack_received;
bool session_token_valid;
bool session_connection_ready;
bool session_started;
bool session_restart_pending;
uint16_t connection_handle;
uint64_t session_token;
uint32_t delivery_epoch;
uint32_t frame_epoch;
uint32_t frame_sequence;
const uint8_t *frame;
size_t frame_size;
size_t next_offset;
int64_t ack_deadline_us;
trikke_ble_session_ready_fn session_ready;
void *session_ready_context;
trikke_ble_transport_counters_t counters;
} trikke_ble_transport_t;
esp_err_t trikke_ble_transport_init(
trikke_ble_transport_t *ble,
trikke_transport_t *transport,
trikke_ble_session_ready_fn session_ready,
void *session_ready_context);
void trikke_ble_transport_get_counters(
trikke_ble_transport_t *ble,
trikke_ble_transport_counters_t *counters);
#ifdef __cplusplus
}
#endif
+34
View File
@@ -202,3 +202,37 @@ size_t trikke_encode_sample_packet(
put_u32_le(output + 32, packet_crc32(output, payload_size));
return packet_size;
}
size_t trikke_encode_status_packet(
uint8_t *output,
size_t output_size,
uint32_t packet_sequence,
int64_t timestamp_us,
uint32_t dropped_sample_count,
uint32_t loop_overrun_count,
const trikke_wire_status_t *status)
{
const size_t packet_size =
TRIKKE_WIRE_HEADER_SIZE + TRIKKE_WIRE_STATUS_SIZE;
if (output == NULL || status == NULL || output_size < packet_size) {
return 0;
}
encode_header(output, TRIKKE_PACKET_TYPE_STATUS, 0, 0, 0,
TRIKKE_WIRE_STATUS_SIZE, packet_sequence, timestamp_us,
dropped_sample_count, loop_overrun_count);
uint8_t *payload = output + TRIKKE_WIRE_HEADER_SIZE;
put_u16_le(payload, 1); // Status payload version.
put_u16_le(payload + 2, TRIKKE_WIRE_STATUS_SIZE);
put_u32_le(payload + 4, status->sensor_read_failure_count);
put_u32_le(payload + 8, status->queue_overflow_count);
put_u32_le(payload + 12, status->transport_begin_retry_count);
put_u32_le(payload + 16, status->transport_disconnect_count);
put_u32_le(payload + 20, status->transport_send_failure_count);
put_u32_le(payload + 24, status->transport_replay_count);
put_u32_le(payload + 28, status->transport_invalid_ack_count);
put_u32_le(output + 32, packet_crc32(output, TRIKKE_WIRE_STATUS_SIZE));
return packet_size;
}
+21
View File
@@ -8,6 +8,7 @@
#define TRIKKE_WIRE_HEADER_SIZE 36
#define TRIKKE_WIRE_SAMPLE_RECORD_SIZE 20
#define TRIKKE_WIRE_METADATA_SIZE 48
#define TRIKKE_WIRE_STATUS_SIZE 32
#define TRIKKE_WIRE_MAX_RECORDS 8
#define TRIKKE_WIRE_MAX_PACKET_SIZE \
(TRIKKE_WIRE_HEADER_SIZE + \
@@ -15,6 +16,7 @@
#define TRIKKE_PACKET_TYPE_METADATA 1
#define TRIKKE_PACKET_TYPE_SAMPLES 2
#define TRIKKE_PACKET_TYPE_STATUS 3
#define TRIKKE_PACKET_FLAG_TIMESTAMP_DELTA_SATURATED 0x01
#define TRIKKE_WIRE_TIMESTAMP_DELTA_UNIT_US 10
@@ -50,6 +52,16 @@ typedef struct {
float gyro_mdps_per_lsb;
} trikke_wire_metadata_t;
typedef struct {
uint32_t sensor_read_failure_count;
uint32_t queue_overflow_count;
uint32_t transport_begin_retry_count;
uint32_t transport_disconnect_count;
uint32_t transport_send_failure_count;
uint32_t transport_replay_count;
uint32_t transport_invalid_ack_count;
} trikke_wire_status_t;
bool trikke_wire_timestamp_delta_fits(
int64_t previous_timestamp_us,
int64_t timestamp_us);
@@ -71,3 +83,12 @@ size_t trikke_encode_sample_packet(
uint32_t loop_overrun_count,
const trikke_wire_sample_t *samples,
size_t sample_count);
size_t trikke_encode_status_packet(
uint8_t *output,
size_t output_size,
uint32_t packet_sequence,
int64_t timestamp_us,
uint32_t dropped_sample_count,
uint32_t loop_overrun_count,
const trikke_wire_status_t *status);
+162 -34
View File
@@ -14,6 +14,12 @@
#include "freertos/task.h"
#include "l3g4200d.h"
#include "trikke_protocol.h"
#include "trikke_transport.h"
#if CONFIG_TRIKKE_TRANSPORT_BLE
#include "trikke_ble_transport.h"
#else
#include "trikke_usb_transport.h"
#endif
// Seeed Studio XIAO ESP32-C3: D4/SDA = GPIO6, D5/SCL = GPIO7.
#define TRIKKE_I2C_PORT I2C_NUM_0
@@ -22,8 +28,9 @@
#define TRIKKE_I2C_FREQ_HZ 400000
#define TRIKKE_SAMPLE_RATE_HZ 100
#define TRIKKE_SAMPLE_TICKS pdMS_TO_TICKS(1000 / TRIKKE_SAMPLE_RATE_HZ)
#define TRIKKE_SAMPLE_QUEUE_DEPTH 512
#define TRIKKE_SAMPLE_QUEUE_DEPTH 3072
#define TRIKKE_METADATA_INTERVAL_PACKETS 64
#define TRIKKE_STATUS_INTERVAL_PACKETS 64
#define TRIKKE_TRANSPORT_RETRY_DELAY_MS 10
// Software calibration from the 2026-08-17 enclosure six-face capture.
@@ -52,8 +59,20 @@ typedef struct {
adxl345_t accelerometer;
l3g4200d_t gyroscope;
QueueHandle_t sample_queue;
atomic_uint_least32_t dropped_sample_count;
StaticQueue_t sample_queue_control;
uint8_t sample_queue_storage[
TRIKKE_SAMPLE_QUEUE_DEPTH * sizeof(trikke_wire_sample_t)];
atomic_uint_least32_t sensor_read_failure_count;
atomic_uint_least32_t queue_overflow_count;
atomic_uint_least32_t loop_overrun_count;
TaskHandle_t output_task_handle;
TaskHandle_t acquisition_task_handle;
trikke_transport_t transport;
#if CONFIG_TRIKKE_TRANSPORT_BLE
trikke_ble_transport_t ble_transport;
#else
trikke_usb_transport_t usb_transport;
#endif
} trikke_context_t;
static trikke_context_t s_context;
@@ -105,6 +124,46 @@ static trikke_axes_sample_t map_gyro_to_enclosure(const l3g4200d_sample_t *nativ
};
}
static uint32_t dropped_sample_count(const trikke_context_t *context)
{
return atomic_load(&context->sensor_read_failure_count) +
atomic_load(&context->queue_overflow_count);
}
static trikke_wire_status_t status_snapshot(
trikke_context_t *context,
const trikke_transport_sender_t *sender)
{
trikke_wire_status_t status = {
.sensor_read_failure_count =
atomic_load(&context->sensor_read_failure_count),
.queue_overflow_count = atomic_load(&context->queue_overflow_count),
.transport_begin_retry_count = sender->begin_retry_count,
};
#if CONFIG_TRIKKE_TRANSPORT_BLE
trikke_ble_transport_counters_t ble_counters = {0};
trikke_ble_transport_get_counters(
&context->ble_transport, &ble_counters);
status.transport_disconnect_count = ble_counters.disconnect_count;
status.transport_send_failure_count = ble_counters.send_failure_count;
status.transport_replay_count = ble_counters.replay_count;
status.transport_invalid_ack_count = ble_counters.invalid_ack_count;
#endif
return status;
}
#if CONFIG_TRIKKE_TRANSPORT_BLE
static void session_ready(void *argument)
{
trikke_context_t *context = argument;
if (context == NULL) {
return;
}
xTaskNotifyGive(context->output_task_handle);
xTaskNotifyGive(context->acquisition_task_handle);
}
#endif
static void acquisition_task(void *argument)
{
trikke_context_t *context = argument;
@@ -142,10 +201,10 @@ static void acquisition_task(void *argument)
.gyro_status = gyro_status,
};
if (xQueueSend(context->sample_queue, &sample, 0) != pdPASS) {
atomic_fetch_add(&context->dropped_sample_count, 1);
atomic_fetch_add(&context->queue_overflow_count, 1);
}
} else {
atomic_fetch_add(&context->dropped_sample_count, 1);
atomic_fetch_add(&context->sensor_read_failure_count, 1);
}
++sequence;
@@ -157,22 +216,35 @@ static void acquisition_task(void *argument)
}
}
static bool write_binary_packet(const uint8_t *packet, size_t packet_size)
{
const size_t written = fwrite(packet, 1, packet_size, stdout);
const int flush_result = fflush(stdout);
const bool complete = written == packet_size && flush_result == 0;
if (!complete) {
clearerr(stdout);
}
return complete;
}
static void write_binary_packet_until_sent(
trikke_context_t *context,
trikke_transport_sender_t *sender,
const uint8_t *packet,
size_t packet_size)
{
while (!write_binary_packet(packet, packet_size)) {
while (true) {
const bool was_pending = sender->pending;
const trikke_transport_status_t status =
trikke_transport_sender_step(
sender, &context->transport, packet, packet_size);
if (status == TRIKKE_TRANSPORT_COMPLETE) {
return;
}
if (status == TRIKKE_TRANSPORT_FATAL) {
// The binary stream is terminal at this point, so a final text
// diagnostic cannot corrupt later frames. Preserve the packet and
// stop consuming the sample queue.
esp_log_level_set(TAG, ESP_LOG_ERROR);
ESP_LOGE(TAG, "fatal transport invariant; output task suspended");
while (true) {
vTaskSuspend(NULL);
}
}
if (status == TRIKKE_TRANSPORT_PENDING && !was_pending) {
// Poll once immediately after acceptance. Subsequent pending polls
// are paced so a future nonblocking backend cannot busy-spin.
continue;
}
vTaskDelay(pdMS_TO_TICKS(TRIKKE_TRANSPORT_RETRY_DELAY_MS));
}
}
@@ -185,12 +257,21 @@ static void output_task(void *argument)
uint8_t packet[TRIKKE_WIRE_MAX_PACKET_SIZE] = {0};
uint32_t packet_sequence = 0;
uint32_t sample_packet_count = 0;
trikke_transport_sender_t sender;
trikke_transport_sender_init(&sender);
size_t packet_size = trikke_encode_metadata_packet(
packet, sizeof(packet), packet_sequence++, esp_timer_get_time(),
atomic_load(&context->dropped_sample_count),
dropped_sample_count(context),
atomic_load(&context->loop_overrun_count), &TRIKKE_METADATA);
write_binary_packet_until_sent(packet, packet_size);
write_binary_packet_until_sent(context, &sender, packet, packet_size);
trikke_wire_status_t status = status_snapshot(context, &sender);
packet_size = trikke_encode_status_packet(
packet, sizeof(packet), packet_sequence++, esp_timer_get_time(),
dropped_sample_count(context),
atomic_load(&context->loop_overrun_count), &status);
write_binary_packet_until_sent(context, &sender, packet, packet_size);
while (true) {
trikke_wire_sample_t samples[TRIKKE_WIRE_MAX_RECORDS] = {0};
@@ -225,16 +306,28 @@ static void output_task(void *argument)
sample_packet_count % TRIKKE_METADATA_INTERVAL_PACKETS == 0) {
packet_size = trikke_encode_metadata_packet(
packet, sizeof(packet), packet_sequence++, esp_timer_get_time(),
atomic_load(&context->dropped_sample_count),
dropped_sample_count(context),
atomic_load(&context->loop_overrun_count), &TRIKKE_METADATA);
write_binary_packet_until_sent(packet, packet_size);
write_binary_packet_until_sent(
context, &sender, packet, packet_size);
}
if (sample_packet_count > 0 &&
sample_packet_count % TRIKKE_STATUS_INTERVAL_PACKETS == 0) {
status = status_snapshot(context, &sender);
packet_size = trikke_encode_status_packet(
packet, sizeof(packet), packet_sequence++, esp_timer_get_time(),
dropped_sample_count(context),
atomic_load(&context->loop_overrun_count), &status);
write_binary_packet_until_sent(
context, &sender, packet, packet_size);
}
packet_size = trikke_encode_sample_packet(
packet, sizeof(packet), packet_sequence++,
atomic_load(&context->dropped_sample_count),
dropped_sample_count(context),
atomic_load(&context->loop_overrun_count), samples, sample_count);
write_binary_packet_until_sent(packet, packet_size);
write_binary_packet_until_sent(context, &sender, packet, packet_size);
++sample_packet_count;
}
}
@@ -318,14 +411,24 @@ void app_main(void)
"records_per_packet=%d\n",
TRIKKE_WIRE_VERSION, TRIKKE_WIRE_SAMPLE_RECORD_SIZE,
TRIKKE_WIRE_MAX_RECORDS);
#if CONFIG_TRIKKE_TRANSPORT_BLE
printf("# transport=ble,device_name=TrikkeSensor\n");
#else
printf("# transport=usb_serial_jtag\n");
#endif
fflush(stdout);
#if !CONFIG_TRIKKE_TRANSPORT_BLE
// The console defaults to CRLF conversion, which would insert bytes into
// binary frames whenever a payload byte equals LF.
usb_serial_jtag_vfs_set_tx_line_endings(ESP_LINE_ENDINGS_LF);
#endif
s_context.sample_queue =
xQueueCreate(TRIKKE_SAMPLE_QUEUE_DEPTH, sizeof(trikke_wire_sample_t));
s_context.sample_queue = xQueueCreateStatic(
TRIKKE_SAMPLE_QUEUE_DEPTH,
sizeof(trikke_wire_sample_t),
s_context.sample_queue_storage,
&s_context.sample_queue_control);
if (s_context.sample_queue == NULL) {
ESP_LOGE(TAG, "sample queue allocation failed");
l3g4200d_deinit(&s_context.gyroscope);
@@ -334,17 +437,15 @@ void app_main(void)
return;
}
TaskHandle_t output_task_handle = NULL;
TaskHandle_t acquisition_task_handle = NULL;
if (xTaskCreate(output_task, "trikke_output", 4096, &s_context, 5,
&output_task_handle) != pdPASS ||
&s_context.output_task_handle) != pdPASS ||
xTaskCreate(acquisition_task, "trikke_acquire", 4096, &s_context, 10,
&acquisition_task_handle) != pdPASS) {
if (output_task_handle != NULL) {
vTaskDelete(output_task_handle);
&s_context.acquisition_task_handle) != pdPASS) {
if (s_context.output_task_handle != NULL) {
vTaskDelete(s_context.output_task_handle);
}
if (acquisition_task_handle != NULL) {
vTaskDelete(acquisition_task_handle);
if (s_context.acquisition_task_handle != NULL) {
vTaskDelete(s_context.acquisition_task_handle);
}
vQueueDelete(s_context.sample_queue);
ESP_LOGE(TAG, "telemetry task creation failed");
@@ -354,8 +455,35 @@ void app_main(void)
return;
}
#if CONFIG_TRIKKE_TRANSPORT_BLE
err = trikke_ble_transport_init(
&s_context.ble_transport, &s_context.transport,
session_ready, &s_context);
#else
err = trikke_usb_transport_init(
&s_context.usb_transport, &s_context.transport);
#endif
if (err != ESP_OK) {
vTaskDelete(s_context.output_task_handle);
vTaskDelete(s_context.acquisition_task_handle);
vQueueDelete(s_context.sample_queue);
#if CONFIG_TRIKKE_TRANSPORT_BLE
ESP_LOGE(TAG, "BLE transport initialization failed: %s",
esp_err_to_name(err));
#else
ESP_LOGE(TAG, "USB transport initialization failed: %s",
esp_err_to_name(err));
#endif
l3g4200d_deinit(&s_context.gyroscope);
adxl345_deinit(&s_context.accelerometer);
i2c_del_master_bus(bus);
return;
}
// No text may share the byte stream once framed binary output begins.
esp_log_level_set("*", ESP_LOG_NONE);
xTaskNotifyGive(output_task_handle);
xTaskNotifyGive(acquisition_task_handle);
#if !CONFIG_TRIKKE_TRANSPORT_BLE
xTaskNotifyGive(s_context.output_task_handle);
xTaskNotifyGive(s_context.acquisition_task_handle);
#endif
}
+54
View File
@@ -0,0 +1,54 @@
#include "trikke_transport.h"
static bool status_is_valid(trikke_transport_status_t status)
{
return status >= TRIKKE_TRANSPORT_COMPLETE &&
status <= TRIKKE_TRANSPORT_FATAL;
}
void trikke_transport_sender_init(trikke_transport_sender_t *sender)
{
if (sender != NULL) {
sender->pending = false;
sender->begin_retry_count = 0;
}
}
trikke_transport_status_t trikke_transport_sender_step(
trikke_transport_sender_t *sender,
const trikke_transport_t *transport,
const uint8_t *packet,
size_t packet_size)
{
if (sender == NULL || transport == NULL || transport->begin == NULL ||
transport->poll == NULL || packet == NULL || packet_size == 0) {
return TRIKKE_TRANSPORT_FATAL;
}
const bool was_pending = sender->pending;
const trikke_transport_status_t status = was_pending
? transport->poll(transport->context)
: transport->begin(transport->context, packet, packet_size);
if (!status_is_valid(status)) {
sender->pending = was_pending;
return TRIKKE_TRANSPORT_FATAL;
}
if (status == TRIKKE_TRANSPORT_PENDING) {
sender->pending = true;
} else if (status == TRIKKE_TRANSPORT_COMPLETE) {
sender->pending = false;
} else if (status == TRIKKE_TRANSPORT_RETRY) {
if (was_pending) {
// Once accepted, generic transport code cannot prove that retrying
// the whole frame is duplicate-safe. Fail closed and keep ownership.
sender->pending = true;
return TRIKKE_TRANSPORT_FATAL;
}
++sender->begin_retry_count;
sender->pending = false;
} else {
sender->pending = was_pending;
}
return status;
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
TRIKKE_TRANSPORT_COMPLETE = 0,
TRIKKE_TRANSPORT_RETRY,
TRIKKE_TRANSPORT_PENDING,
TRIKKE_TRANSPORT_FATAL,
} trikke_transport_status_t;
typedef trikke_transport_status_t (*trikke_transport_begin_fn)(
void *context,
const uint8_t *packet,
size_t packet_size);
typedef trikke_transport_status_t (*trikke_transport_poll_fn)(void *context);
typedef struct {
void *context;
trikke_transport_begin_fn begin;
trikke_transport_poll_fn poll;
} trikke_transport_t;
typedef struct {
bool pending;
uint32_t begin_retry_count;
} trikke_transport_sender_t;
void trikke_transport_sender_init(trikke_transport_sender_t *sender);
// Advances one bounded transport operation. The packet storage must remain valid
// and unchanged from the first PENDING result through COMPLETE. While pending,
// only poll is called: an ambiguous timeout can never duplicate a frame. RETRY
// is valid only from begin, where it guarantees that no bytes were accepted.
//
// COMPLETE is backend-specific. USB uses endpoint drain, which does not prove
// application receipt. A reliable BLE backend must reserve COMPLETE for an
// application acknowledgement covering this exact frame.
trikke_transport_status_t trikke_transport_sender_step(
trikke_transport_sender_t *sender,
const trikke_transport_t *transport,
const uint8_t *packet,
size_t packet_size);
#ifdef __cplusplus
}
#endif
+95
View File
@@ -0,0 +1,95 @@
#include "trikke_usb_transport.h"
#include "driver/usb_serial_jtag.h"
#include "driver/usb_serial_jtag_vfs.h"
#include "freertos/FreeRTOS.h"
#define TRIKKE_USB_TX_BUFFER_SIZE 512
#define TRIKKE_USB_RX_BUFFER_SIZE 128
#define TRIKKE_USB_OPERATION_TIMEOUT_MS 50
static trikke_transport_status_t usb_begin_packet(
void *context,
const uint8_t *packet,
size_t packet_size)
{
const trikke_usb_transport_t *usb = context;
if (usb == NULL || !usb->initialized || packet == NULL || packet_size == 0 ||
packet_size > TRIKKE_USB_TX_BUFFER_SIZE) {
return TRIKKE_TRANSPORT_FATAL;
}
const int written = usb_serial_jtag_write_bytes(
packet, packet_size,
pdMS_TO_TICKS(TRIKKE_USB_OPERATION_TIMEOUT_MS));
if (written == (int)packet_size) {
return TRIKKE_TRANSPORT_PENDING;
}
if (written == 0) {
// Arguments and lifecycle were validated above, and this backend is the
// driver's sole owner. Under that invariant, zero means the all-or-none
// ring submission timed out without accepting this frame.
return TRIKKE_TRANSPORT_RETRY;
}
return TRIKKE_TRANSPORT_FATAL;
}
static trikke_transport_status_t usb_poll_packet(void *context)
{
const trikke_usb_transport_t *usb = context;
if (usb == NULL || !usb->initialized) {
return TRIKKE_TRANSPORT_FATAL;
}
const esp_err_t err = usb_serial_jtag_wait_tx_done(
pdMS_TO_TICKS(TRIKKE_USB_OPERATION_TIMEOUT_MS));
if (err == ESP_OK) {
return TRIKKE_TRANSPORT_COMPLETE;
}
if (err == ESP_ERR_TIMEOUT) {
return TRIKKE_TRANSPORT_PENDING;
}
return TRIKKE_TRANSPORT_FATAL;
}
esp_err_t trikke_usb_transport_init(
trikke_usb_transport_t *usb,
trikke_transport_t *transport)
{
if (usb == NULL || transport == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (usb->initialized) {
return ESP_ERR_INVALID_STATE;
}
usb_serial_jtag_driver_config_t config = {
.tx_buffer_size = TRIKKE_USB_TX_BUFFER_SIZE,
.rx_buffer_size = TRIKKE_USB_RX_BUFFER_SIZE,
};
const esp_err_t err = usb_serial_jtag_driver_install(&config);
if (err != ESP_OK) {
return err;
}
usb->initialized = true;
transport->context = usb;
transport->begin = usb_begin_packet;
transport->poll = usb_poll_packet;
// Route any unexpected VFS output through the installed driver as well, so
// it cannot race the driver's ISR by touching the hardware FIFO directly.
usb_serial_jtag_vfs_use_driver();
return ESP_OK;
}
void trikke_usb_transport_deinit(trikke_usb_transport_t *usb)
{
if (usb == NULL || !usb->initialized) {
return;
}
usb_serial_jtag_vfs_use_nonblocking();
usb_serial_jtag_driver_uninstall();
usb->initialized = false;
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#include "trikke_transport.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
bool initialized;
} trikke_usb_transport_t;
esp_err_t trikke_usb_transport_init(
trikke_usb_transport_t *usb,
trikke_transport_t *transport);
void trikke_usb_transport_deinit(trikke_usb_transport_t *usb);
#ifdef __cplusplus
}
#endif
+2
View File
@@ -0,0 +1,2 @@
bleak>=3.0,<4
pyserial>=3.5,<4
+10
View File
@@ -10,3 +10,13 @@ CONFIG_FREERTOS_HZ=1000
# The XIAO ESP32-C3 carries 4 MB of flash.
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
# BLE is the prototype's normal telemetry path. The project Kconfig can switch
# a validation build back to the preserved direct USB transport.
CONFIG_TRIKKE_TRANSPORT_BLE=y
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_ROLE_CENTRAL=n
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=256
+4
View File
@@ -0,0 +1,4 @@
# Layer this after sdkconfig.defaults for a reproducible wired validation build.
# CONFIG_TRIKKE_TRANSPORT_BLE is not set
CONFIG_TRIKKE_TRANSPORT_USB=y
# CONFIG_BT_ENABLED is not set
+107
View File
@@ -0,0 +1,107 @@
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "trikke_ble_protocol.h"
#include "trikke_protocol.h"
static int fail(int code, const char *message)
{
fprintf(stderr, "BLE protocol fixture failure %d: %s\n", code, message);
return code;
}
static void put_u16_le(uint8_t *output, uint16_t value)
{
output[0] = (uint8_t)value;
output[1] = (uint8_t)(value >> 8);
}
static void put_u32_le(uint8_t *output, uint32_t value)
{
output[0] = (uint8_t)value;
output[1] = (uint8_t)(value >> 8);
output[2] = (uint8_t)(value >> 16);
output[3] = (uint8_t)(value >> 24);
}
int main(void)
{
uint8_t packet[TRIKKE_WIRE_HEADER_SIZE + 16] = {0};
memcpy(packet, "TRK1", 4);
packet[4] = TRIKKE_WIRE_VERSION;
packet[6] = TRIKKE_WIRE_HEADER_SIZE;
put_u16_le(packet + 10, 16);
put_u32_le(packet + 12, 0x78563412);
for (size_t i = TRIKKE_WIRE_HEADER_SIZE; i < sizeof(packet); ++i) {
packet[i] = (uint8_t)i;
}
uint8_t fragment[32] = {0};
size_t size = trikke_ble_encode_fragment(
fragment, sizeof(fragment), packet, sizeof(packet), 0, 20);
if (size != 20 || memcmp(fragment, "\x12\x34\x56\x78\x00\x00\x34\x00", 8) != 0 ||
memcmp(fragment + 8, packet, 12) != 0) {
return fail(1, "first fragment envelope");
}
size = trikke_ble_encode_fragment(
fragment, sizeof(fragment), packet, sizeof(packet), 48, 20);
if (size != 12 || fragment[4] != 48 ||
memcmp(fragment + 8, packet + 48, 4) != 0) {
return fail(2, "last fragment envelope");
}
uint8_t ack[TRIKKE_BLE_ACK_SIZE] = {'A', 'C', 'K', '1', 0x12, 0x34, 0x56, 0x78};
uint32_t sequence = 0;
if (!trikke_ble_decode_ack(ack, sizeof(ack), &sequence) ||
sequence != 0x78563412) {
return fail(3, "ACK decoding");
}
ack[0] = 'N';
if (trikke_ble_decode_ack(ack, sizeof(ack), &sequence) ||
trikke_ble_encode_fragment(fragment, sizeof(fragment), packet,
sizeof(packet), sizeof(packet), 20) != 0 ||
trikke_ble_encode_fragment(fragment, sizeof(fragment), packet,
sizeof(packet), 0, 8) != 0) {
return fail(4, "invalid input rejection");
}
const uint8_t begin[TRIKKE_BLE_BEGIN_SESSION_SIZE] = {
'B', 'G', 'N', '1', 0x08, 0x07, 0x06, 0x05,
0x04, 0x03, 0x02, 0x01,
};
uint64_t session_token = 0;
if (!trikke_ble_decode_begin_session(
begin, sizeof(begin), &session_token) ||
session_token != UINT64_C(0x0102030405060708)) {
return fail(5, "begin-session decoding");
}
uint8_t invalid_begin[TRIKKE_BLE_BEGIN_SESSION_SIZE] = {0};
memcpy(invalid_begin, begin, sizeof(begin));
invalid_begin[3] = '2';
if (trikke_ble_decode_begin_session(
invalid_begin, sizeof(invalid_begin), &session_token) ||
trikke_ble_decode_begin_session(
begin, sizeof(begin) - 1, &session_token)) {
return fail(6, "invalid begin-session rejection");
}
bool subscribed = false;
if (trikke_ble_update_subscription(true, false, &subscribed) ||
subscribed ||
!trikke_ble_update_subscription(true, true, &subscribed) ||
!subscribed) {
return fail(7, "CCCD-before-control subscription ordering");
}
subscribed = false;
if (trikke_ble_update_subscription(false, true, &subscribed) ||
subscribed ||
!trikke_ble_update_subscription(true, true, &subscribed) ||
!subscribed ||
!trikke_ble_update_subscription(false, true, &subscribed) ||
subscribed) {
return fail(8, "control-before-CCCD subscription ordering");
}
return 0;
}
+97 -5
View File
@@ -1,9 +1,11 @@
# Hardware outage fixtures
These captures came from the assembled XIAO ESP32-C3 prototype. A temporary
validation build made the packet writer report failure while acquisition kept
running; that failure injection was removed before the production firmware was
built and flashed. The files contain only complete, CRC-valid `TRK1` frames.
These captures came from the assembled XIAO ESP32-C3 prototype. The files
contain only complete, CRC-valid `TRK1` frames.
The two forced-outage captures used a temporary validation build that made the
packet writer report failure while acquisition kept running. That injection was
removed before production firmware was built and flashed.
- `forced_outage_3s.trk` — SHA-256
`01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6`.
@@ -15,10 +17,100 @@ built and flashed. The files contain only complete, CRC-valid `TRK1` frames.
138 samples were dropped after the 512-entry queue filled, the cumulative
drop count reached 138, and the corresponding timestamp delta is exactly
1,390,000 us.
- `direct_usb_stall.trk` — SHA-256
`40f874b7eaa7f705524ecdd75f832e8a724252366633116ac015fc75dfd16558`.
This came from the direct USB driver build after leaving the enumerated USB
endpoint without a serial reader long enough to overflow the acquisition
queue. It contains 864 samples. The retained block ends at sequence 511,
delivery resumes at 1,706, and both the sole 1,194-sample gap and the device's
cumulative drop counter equal 1,194. There are no packet gaps, CRC failures,
loop overruns, trailing bytes, or timestamp-saturation flags.
The first captured sample is sequence 8 because the flashing process still
had the endpoint open long enough to drain sequences 0 through 7 before the
capture application opened. That is deliberate evidence of the remaining
distinction: USB endpoint drain is observable, but application receipt
requires the planned acknowledgement/replay layer.
- `direct_usb_3c95f3d.trk` — SHA-256
`f495486f094a758bb785e145026e3934d60b52dd5083aef7f5d896195d967869`.
This is the validated-frame output from the final exact-commit smoke capture:
1,680 contiguous samples, sequences 0 through 1,679, with zero packet/sample
gaps, resets, CRC failures, reported drops, loop overruns, trailing bytes, or
timestamp saturation.
- `direct_usb_3c95f3d.wire` — SHA-256
`3bdaeadff7962c6eac48c4ebeda285c8eb359e439d5e1728104add2009122c03`.
This is the byte-for-byte wire side of the same capture. It contains 563 bytes
of startup text before the valid frames. That text includes the literal
`TRK1`, producing one rejected candidate header as designed. Extracting all
valid frames reproduces `direct_usb_3c95f3d.trk` byte-for-byte.
- `direct_usb_73e5680.trk` — SHA-256
`fd34bb3bf8f92a64960024f1287e553c03076ff3714fe629ec629bec81ddf821`.
This exact firmware-hardening capture contains 1,184 contiguous samples,
sequences 0 through 1,183, with zero packet/sample gaps, resets, CRC failures,
reported drops, loop overruns, trailing bytes, or timestamp saturation.
- `direct_usb_73e5680.wire` — SHA-256
`82d6d17bbf0729e9bfc53f337ec9adf70bcb5e5898b039685eda5dfa19cf4eea`.
This is the byte-for-byte `--reset --wire` capture corresponding to the
validated file above. Its 3,589 skipped startup bytes and one candidate-header
rejection are deterministic, and extracting its 151 valid frames reproduces
`direct_usb_73e5680.trk` byte-for-byte.
- `ble_reconnect_483ace3.trk` — SHA-256
`c4a0d795cdf9d490acaca0144c3ad33f85bbfb2214d3f7abdfe515c4e5f25398`.
This is the resumed half of a real macOS receiver interruption against exact
firmware commit `483ace3`. It contains 2,576 contiguous samples, sequences
1,992 through 4,567, with zero packet/sample gaps, resets, CRC failures,
reported drops, loop overruns, trailing bytes, or timestamp saturation. The
final status records one disconnect and one deliberate frame replay, with
zero notification failures or invalid ACKs. It validates that the 10.24-second
queue covered the measured resubscription interval without permanent loss.
Of its 2,575 contiguous sample intervals, 1,293 differ from exactly 10 ms.
Absolute deviation has a 4 us median, 160 us p95, 170 us p99, and 780 us
maximum; cumulative error is 152 us over 25.75 seconds. This records bounded
BLE scheduling jitter without rate drift and requires timestamp-derived `dt`
in future fusion work.
- `ble_mtu_race_4bf00eb.trk` — SHA-256
`4a231b54a3c7320fd69cac869b830e94aca8f704f946881b9cdfd21991bfa41f`.
This exact-commit stress capture followed six rapid subscribe, first-fragment,
and disconnect cycles aimed at the MTU lookup race. Telemetry remained live
afterward: the capture contains 1,848 CRC-valid samples with zero packet gaps,
resets, send failures, or invalid ACKs, while status records exactly six
disconnects and six replays. The test deliberately withheld application ACKs
beyond the 10.24-second queue window, so sequences 1,024 through 4,021 were
intentionally lost; the single 2,998-sample gap, matching queue-overflow total,
demonstrates bounded buffer exhaustion rather than a suspended output task.
- `ride_20260820_090607.trk` — SHA-256
`c938934c0905748d2f8d8be61cff1ff446d8415b34850628654967398fd7d91f`.
This is the Android recorder acceptance capture after increasing the firmware
queue to 3,072 samples (30.72 seconds). Bluetooth was deliberately disabled
from the phone for five seconds and then restored. The completed capture
contains 2,578 consecutive packets and 19,984 consecutive samples, both
starting at sequence zero, with zero CRC/header errors, packet/sample gaps,
sensor failures, queue overflows, transport send failures, invalid ACKs,
loop overruns, or trailing bytes. Firmware status records one disconnect and
one replay. The Android recorder durably deduplicated that exact replay, as
recorded in `ride_20260820_090607.session.json` (SHA-256
`b16a8f0c213a14fd2760b6efba1c6288f3631e57a331c553893e281dce047888`),
and closed the session with `complete=true` and no error.
All 19,983 sample intervals are contiguous. Of those, 8,745 differ from
exactly 10 ms. Across all intervals, absolute deviation is 0 us median, 40 us
p95, 240 us p99, and 1,630 us maximum; among only the off-grid intervals it is
10 us median, 60 us p95, and 750 us p99. Cumulative error is -726 us over
199.83 seconds. The capture also has six explicitly flagged ADXL345 overruns;
timestamps are taken before both I2C reads and therefore understate delays
caused by preemption between the timestamp and physical sensor access.
The post-rename phone smoke test for application ID
`com.jsjdesigns.trikkerecorder` was verified live but not retained as a fixture.
It closed cleanly with 366 consecutive frames and 2,832 consecutive samples,
zero CRC/header errors, packet/sample gaps, drops, queue overflows, loop
overruns, or trailing bytes. The package-only rename did not change the Android
protocol or storage implementation, and the retained outage fixture above
remains the authoritative end-to-end acceptance artifact.
`tests/test_trikke_protocol.py` verifies the hashes, parses the captures in
fragmented chunks, and asserts these signatures so the hardware evidence remains
executable regression data. To inspect either file manually:
executable regression data. To inspect a file manually:
```sh
python3 tools/decode_binary.py tests/fixtures/forced_outage_3s.trk /tmp/outage.csv
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
{
"schema": 1,
"captureFile": "ride_20260820_090607.trk",
"sessionTokenHex": "77f213814e3c41da",
"complete": true,
"startWallClockMs": 1787231167523,
"startElapsedRealtimeNs": 2415494841543927,
"endWallClockMs": 1787231378168,
"endElapsedRealtimeNs": 2415705485735774,
"firstFrame": {"packetSequence":0,"deviceBaseTimestampUs":7652604,"phoneElapsedRealtimeNs":2415505638698090,"phoneWallClockMs":1787231178321},
"lastFrame": {"packetSequence":2577,"deviceBaseTimestampUs":207411553,"phoneElapsedRealtimeNs":2415705468358690,"phoneWallClockMs":1787231378150},
"frames": 2578,
"samples": 19984,
"duplicateReplays": 1,
"packetGaps": 0,
"packetResets": 0,
"sampleGaps": 0,
"sampleResets": 0,
"droppedSamples": 0,
"loopOverruns": 0,
"queueOverflows": 0,
"transportDisconnects": 1,
"transportReplays": 1,
"error": null
}
Binary file not shown.
+22 -3
View File
@@ -88,10 +88,26 @@ int main(void)
return fail(4, "saturated timestamp encoding or output");
}
const trikke_wire_status_t status = {
.sensor_read_failure_count = 5,
.queue_overflow_count = 6,
.transport_begin_retry_count = 7,
.transport_disconnect_count = 8,
.transport_send_failure_count = 9,
.transport_replay_count = 10,
.transport_invalid_ack_count = 11,
};
size = trikke_encode_status_packet(packet, sizeof(packet), 45, 5000000,
11, 12, &status);
if (size != TRIKKE_WIRE_HEADER_SIZE + TRIKKE_WIRE_STATUS_SIZE ||
fwrite(packet, 1, size, stdout) != size) {
return fail(5, "status encoding or output");
}
if (!trikke_wire_timestamp_delta_fits(0, 655350) ||
trikke_wire_timestamp_delta_fits(0, 655351) ||
trikke_wire_timestamp_delta_fits(1, 0)) {
return fail(5, "timestamp-delta boundary contract");
return fail(6, "timestamp-delta boundary contract");
}
if (trikke_encode_metadata_packet(
packet, TRIKKE_WIRE_HEADER_SIZE + TRIKKE_WIRE_METADATA_SIZE - 1,
@@ -103,8 +119,11 @@ int main(void)
TRIKKE_WIRE_MAX_RECORDS + 1) != 0 ||
trikke_encode_sample_packet(packet, TRIKKE_WIRE_MAX_PACKET_SIZE - 1,
0, 0, 0, full_packet,
TRIKKE_WIRE_MAX_RECORDS) != 0) {
return fail(6, "invalid argument rejection contract");
TRIKKE_WIRE_MAX_RECORDS) != 0 ||
trikke_encode_status_packet(
packet, TRIKKE_WIRE_HEADER_SIZE + TRIKKE_WIRE_STATUS_SIZE - 1,
0, 0, 0, 0, &status) != 0) {
return fail(7, "invalid argument rejection contract");
}
return 0;
}
+302 -7
View File
@@ -1,4 +1,5 @@
import hashlib
import json
import shutil
import subprocess
import sys
@@ -10,12 +11,15 @@ ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
from trikke_protocol import ( # noqa: E402
IntegrityTracker,
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
PACKET_TYPE_METADATA,
PACKET_TYPE_SAMPLES,
PACKET_TYPE_STATUS,
StreamParser,
sample_to_csv_row,
)
from trikke_ble import BleFrameReassembler, encode_ack, encode_begin_session # noqa: E402
class ProtocolContractTest(unittest.TestCase):
@@ -50,6 +54,62 @@ class ProtocolContractTest(unittest.TestCase):
)
cls.encoded = fixture.stdout
transport_executable = Path(cls.tempdir.name) / "transport_fixture"
subprocess.run(
[
compiler,
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-I",
str(ROOT / "main"),
str(ROOT / "main" / "trikke_transport.c"),
str(ROOT / "tests" / "transport_fixture.c"),
"-o",
str(transport_executable),
],
check=True,
)
transport_fixture = subprocess.run(
[str(transport_executable)], capture_output=True
)
if transport_fixture.returncode != 0:
stderr = transport_fixture.stderr.decode(errors="replace").strip()
raise AssertionError(
"transport fixture exited "
f"{transport_fixture.returncode}: {stderr}"
)
cls.transport_fixture_passed = True
ble_protocol_executable = Path(cls.tempdir.name) / "ble_protocol_fixture"
subprocess.run(
[
compiler,
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-I",
str(ROOT / "main"),
str(ROOT / "main" / "trikke_ble_protocol.c"),
str(ROOT / "tests" / "ble_protocol_fixture.c"),
"-o",
str(ble_protocol_executable),
],
check=True,
)
ble_protocol_fixture = subprocess.run(
[str(ble_protocol_executable)], capture_output=True
)
if ble_protocol_fixture.returncode != 0:
stderr = ble_protocol_fixture.stderr.decode(errors="replace").strip()
raise AssertionError(
"BLE protocol fixture exited "
f"{ble_protocol_fixture.returncode}: {stderr}"
)
cls.ble_protocol_fixture_passed = True
@classmethod
def tearDownClass(cls) -> None:
cls.tempdir.cleanup()
@@ -61,8 +121,8 @@ class ProtocolContractTest(unittest.TestCase):
for offset in range(0, len(stream), 7):
frames.extend(parser.feed(stream[offset : offset + 7]))
self.assertEqual(4, len(frames))
metadata_frame, sample_frame, full_frame, saturated_frame = frames
self.assertEqual(5, len(frames))
metadata_frame, sample_frame, full_frame, saturated_frame, status_frame = frames
self.assertEqual(PACKET_TYPE_METADATA, metadata_frame.packet_type)
self.assertEqual(41, metadata_frame.packet_sequence)
self.assertEqual(2, metadata_frame.dropped_sample_count)
@@ -96,6 +156,16 @@ class ProtocolContractTest(unittest.TestCase):
)
self.assertEqual(4_655_350, saturated_frame.samples[-1].timestamp_us)
self.assertEqual(PACKET_TYPE_STATUS, status_frame.packet_type)
self.assertEqual(45, status_frame.packet_sequence)
self.assertEqual(5, status_frame.status.sensor_read_failure_count)
self.assertEqual(6, status_frame.status.queue_overflow_count)
self.assertEqual(7, status_frame.status.transport_begin_retry_count)
self.assertEqual(8, status_frame.status.transport_disconnect_count)
self.assertEqual(9, status_frame.status.transport_send_failure_count)
self.assertEqual(10, status_frame.status.transport_replay_count)
self.assertEqual(11, status_frame.status.transport_invalid_ack_count)
row = sample_to_csv_row(
sample_frame.samples[0], metadata_frame.metadata, 3
)
@@ -103,6 +173,49 @@ class ProtocolContractTest(unittest.TestCase):
self.assertEqual((2, 1, 258), tuple(row[14:17]))
self.assertEqual(3, row[-1])
def test_transport_state_machine_contract(self) -> None:
self.assertTrue(self.transport_fixture_passed)
def test_ble_fragment_and_ack_contract(self) -> None:
self.assertTrue(self.ble_protocol_fixture_passed)
def test_ble_reassembly_and_replay_contract(self) -> None:
frame = self.encoded[: 36 + 48]
sequence = int.from_bytes(frame[12:16], "little")
def fragment(offset: int, size: int) -> bytes:
data = frame[offset : offset + size]
return (
sequence.to_bytes(4, "little")
+ offset.to_bytes(2, "little")
+ len(frame).to_bytes(2, "little")
+ data
)
reassembler = BleFrameReassembler()
self.assertIsNone(reassembler.feed(fragment(0, 20)))
# A replay from offset zero discards the partial attempt cleanly.
self.assertIsNone(reassembler.feed(fragment(0, 40)))
self.assertEqual(frame, reassembler.feed(fragment(40, len(frame) - 40)))
self.assertEqual(b"ACK1" + sequence.to_bytes(4, "little"), encode_ack(sequence))
self.assertEqual(
b"BGN1\x08\x07\x06\x05\x04\x03\x02\x01",
encode_begin_session(0x0102030405060708),
)
self.assertEqual(0, reassembler.rejected_fragment_count)
self.assertIsNone(reassembler.feed(fragment(20, 20)))
self.assertEqual(1, reassembler.rejected_fragment_count)
def test_integrity_sequence_wrap_classification(self) -> None:
self.assertEqual(
(0, 0), IntegrityTracker._classify_sequence(0xFFFFFFFF, 0)
)
self.assertEqual(
(2, 0), IntegrityTracker._classify_sequence(0xFFFFFFFE, 1)
)
self.assertEqual((0, 1), IntegrityTracker._classify_sequence(1000, 0))
def test_crc_failure_resynchronizes_to_next_frame(self) -> None:
first_size = 36 + 48
damaged = bytearray(self.encoded[:first_size])
@@ -111,7 +224,7 @@ class ProtocolContractTest(unittest.TestCase):
frames = parser.feed(bytes(damaged) + self.encoded[first_size:])
self.assertEqual(1, parser.startup_crc_errors)
self.assertEqual(0, parser.crc_errors)
self.assertEqual(3, len(frames))
self.assertEqual(4, len(frames))
self.assertEqual(PACKET_TYPE_SAMPLES, frames[0].packet_type)
def test_crc_failure_after_sync_is_stream_error(self) -> None:
@@ -127,31 +240,117 @@ class ProtocolContractTest(unittest.TestCase):
)
self.assertEqual(0, parser.startup_crc_errors)
self.assertEqual(1, parser.crc_errors)
self.assertEqual(3, len(frames))
self.assertEqual(4, len(frames))
self.assertEqual(PACKET_TYPE_METADATA, frames[0].packet_type)
def test_trailing_partial_frame_is_observable(self) -> None:
parser = StreamParser()
frames = parser.feed(self.encoded[:-5])
self.assertEqual(3, len(frames))
self.assertEqual(36 + 2 * 20 - 5, parser.buffered_bytes)
self.assertEqual(4, len(frames))
self.assertEqual(36 + 32 - 5, parser.buffered_bytes)
def test_hardware_outage_validation_artifacts(self) -> None:
expected = {
"forced_outage_3s.trk": {
"sha256": "01482816cdaa668e4681c33c8baa1df331d733b9bbcbc4f448ece25e88185ad6",
"sample_count": 2144,
"first_sequence": 0,
"last_sequence": 2143,
"max_dropped": 0,
"timing_anomalies": 2,
"gaps": [],
},
"forced_outage_7s.trk": {
"sha256": "2ea8a5742944bdebc13bec2ccdbceba75f0bb71e48c856b0f86285878e190cd3",
"sample_count": 1840,
"first_sequence": 0,
"last_sequence": 1977,
"max_dropped": 138,
"timing_anomalies": 3,
"gaps": [(511, 650, 1_390_000)],
},
"direct_usb_stall.trk": {
"sha256": "40f874b7eaa7f705524ecdd75f832e8a724252366633116ac015fc75dfd16558",
"sample_count": 864,
"first_sequence": 8,
"last_sequence": 2065,
"max_dropped": 1194,
"timing_anomalies": 1,
"gaps": [(511, 1706, 11_950_000)],
},
"direct_usb_3c95f3d.trk": {
"sha256": "f495486f094a758bb785e145026e3934d60b52dd5083aef7f5d896195d967869",
"sample_count": 1680,
"first_sequence": 0,
"last_sequence": 1679,
"max_dropped": 0,
"timing_anomalies": 8,
"gaps": [],
},
"direct_usb_73e5680.trk": {
"sha256": "fd34bb3bf8f92a64960024f1287e553c03076ff3714fe629ec629bec81ddf821",
"sample_count": 1184,
"first_sequence": 0,
"last_sequence": 1183,
"max_dropped": 0,
"timing_anomalies": 2,
"gaps": [],
},
"ble_reconnect_483ace3.trk": {
"sha256": "c4a0d795cdf9d490acaca0144c3ad33f85bbfb2214d3f7abdfe515c4e5f25398",
"sample_count": 2576,
"first_sequence": 1992,
"last_sequence": 4567,
"max_dropped": 0,
"timing_anomalies": 1293,
"gaps": [],
"final_status": {
"sensor_read_failure_count": 0,
"queue_overflow_count": 0,
"transport_begin_retry_count": 187,
"transport_disconnect_count": 1,
"transport_send_failure_count": 0,
"transport_replay_count": 1,
"transport_invalid_ack_count": 0,
},
},
"ble_mtu_race_4bf00eb.trk": {
"sha256": "4a231b54a3c7320fd69cac869b830e94aca8f704f946881b9cdfd21991bfa41f",
"sample_count": 1848,
"first_sequence": 0,
"last_sequence": 4845,
"max_dropped": 2998,
"timing_anomalies": 412,
"gaps": [(1023, 4022, 29_990_016)],
"final_status": {
"sensor_read_failure_count": 0,
"queue_overflow_count": 2998,
"transport_begin_retry_count": 2471,
"transport_disconnect_count": 6,
"transport_send_failure_count": 0,
"transport_replay_count": 6,
"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():
@@ -175,7 +374,7 @@ class ProtocolContractTest(unittest.TestCase):
samples = [sample for frame in frames for sample in frame.samples]
self.assertEqual(contract["sample_count"], len(samples))
self.assertEqual(0, samples[0].sequence)
self.assertEqual(contract["first_sequence"], samples[0].sequence)
self.assertEqual(contract["last_sequence"], samples[-1].sequence)
self.assertEqual(
contract["max_dropped"],
@@ -191,6 +390,37 @@ class ProtocolContractTest(unittest.TestCase):
)
)
integrity = IntegrityTracker()
for frame in frames:
integrity.observe(frame)
self.assertEqual(0, integrity.packet_gap_count)
self.assertEqual(0, integrity.packet_reset_count)
self.assertEqual(
contract["max_dropped"], integrity.sample_gap_count
)
self.assertEqual(0, integrity.sample_reset_count)
self.assertEqual(
contract["timing_anomalies"],
integrity.timing_anomaly_count,
)
if "accel_overruns" in contract:
self.assertEqual(
contract["accel_overruns"],
integrity.accel_overrun_count,
)
self.assertEqual(
contract["max_dropped"],
integrity.final_dropped_sample_count,
)
self.assertEqual(0, integrity.final_loop_overrun_count)
if "final_status" in contract:
self.assertIsNotNone(integrity.final_status)
for field, value in contract["final_status"].items():
self.assertEqual(
value,
getattr(integrity.final_status, field),
)
gaps = [
(
left.sequence,
@@ -202,6 +432,71 @@ class ProtocolContractTest(unittest.TestCase):
]
self.assertEqual(contract["gaps"], gaps)
def test_android_reconnect_session_sidecar(self) -> None:
data = (
ROOT
/ "tests"
/ "fixtures"
/ "ride_20260820_090607.session.json"
).read_bytes()
self.assertEqual(
"b16a8f0c213a14fd2760b6efba1c6288f3631e57a331c553893e281dce047888",
hashlib.sha256(data).hexdigest(),
)
summary = json.loads(data)
self.assertTrue(summary["complete"])
self.assertIsNone(summary["error"])
self.assertEqual("ride_20260820_090607.trk", summary["captureFile"])
self.assertEqual(2578, summary["frames"])
self.assertEqual(19984, summary["samples"])
self.assertEqual(1, summary["duplicateReplays"])
self.assertEqual(0, summary["packetGaps"])
self.assertEqual(0, summary["sampleGaps"])
self.assertEqual(0, summary["droppedSamples"])
self.assertEqual(0, summary["queueOverflows"])
self.assertEqual(1, summary["transportDisconnects"])
self.assertEqual(1, summary["transportReplays"])
def test_exact_usb_raw_wire_evidence(self) -> None:
expected = {
"direct_usb_3c95f3d": {
"sha256": "3bdaeadff7962c6eac48c4ebeda285c8eb359e439d5e1728104add2009122c03",
"frames": 214,
"skipped": 563,
},
"direct_usb_73e5680": {
"sha256": "82d6d17bbf0729e9bfc53f337ec9adf70bcb5e5898b039685eda5dfa19cf4eea",
"frames": 151,
"skipped": 3589,
},
}
for stem, contract in expected.items():
with self.subTest(fixture=stem):
wire = (
ROOT / "tests" / "fixtures" / f"{stem}.wire"
).read_bytes()
validated = (
ROOT / "tests" / "fixtures" / f"{stem}.trk"
).read_bytes()
self.assertEqual(
contract["sha256"], hashlib.sha256(wire).hexdigest()
)
parser = StreamParser()
frames = []
for offset in range(0, len(wire), 113):
frames.extend(parser.feed(wire[offset : offset + 113]))
self.assertEqual(contract["frames"], len(frames))
self.assertEqual(
validated, b"".join(frame.raw for frame in frames)
)
self.assertEqual(0, parser.startup_crc_errors)
self.assertEqual(0, parser.crc_errors)
self.assertEqual(1, parser.header_errors)
self.assertEqual(contract["skipped"], parser.skipped_bytes)
self.assertEqual(0, parser.buffered_bytes)
if __name__ == "__main__":
unittest.main()
+134
View File
@@ -0,0 +1,134 @@
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include "trikke_transport.h"
typedef struct {
trikke_transport_status_t begin_status;
trikke_transport_status_t poll_status;
unsigned int begin_calls;
unsigned int poll_calls;
const uint8_t *packet;
size_t packet_size;
} mock_transport_t;
static int fail(int code, const char *message)
{
fprintf(stderr, "transport fixture failure %d: %s\n", code, message);
return code;
}
static trikke_transport_status_t mock_begin(
void *context,
const uint8_t *packet,
size_t packet_size)
{
mock_transport_t *mock = context;
++mock->begin_calls;
mock->packet = packet;
mock->packet_size = packet_size;
return mock->begin_status;
}
static trikke_transport_status_t mock_poll(void *context)
{
mock_transport_t *mock = context;
++mock->poll_calls;
return mock->poll_status;
}
int main(void)
{
const uint8_t packet[] = {0x54, 0x52, 0x4B, 0x31};
mock_transport_t mock = {
.begin_status = TRIKKE_TRANSPORT_RETRY,
.poll_status = TRIKKE_TRANSPORT_PENDING,
};
const trikke_transport_t transport = {
.context = &mock,
.begin = mock_begin,
.poll = mock_poll,
};
trikke_transport_sender_t sender;
trikke_transport_sender_init(&sender);
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_RETRY ||
sender.pending || mock.begin_calls != 1 || mock.poll_calls != 0 ||
mock.packet != packet || mock.packet_size != sizeof(packet)) {
return fail(1, "zero-accept submission must remain retryable");
}
mock.begin_status = TRIKKE_TRANSPORT_PENDING;
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_PENDING ||
!sender.pending || mock.begin_calls != 2 || mock.poll_calls != 0) {
return fail(2, "accepted submission must become pending");
}
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_PENDING ||
!sender.pending || mock.begin_calls != 2 || mock.poll_calls != 1) {
return fail(3, "pending transfer must poll without resubmission");
}
mock.poll_status = TRIKKE_TRANSPORT_COMPLETE;
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_COMPLETE ||
sender.pending || mock.begin_calls != 2 || mock.poll_calls != 2) {
return fail(4, "completed transfer must return to idle");
}
mock.begin_status = TRIKKE_TRANSPORT_COMPLETE;
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_COMPLETE ||
sender.pending || mock.begin_calls != 3) {
return fail(5, "synchronous completion contract");
}
mock.begin_status = TRIKKE_TRANSPORT_PENDING;
mock.poll_status = TRIKKE_TRANSPORT_RETRY;
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_PENDING ||
trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_FATAL ||
!sender.pending || mock.begin_calls != 4 || mock.poll_calls != 3 ||
trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_FATAL ||
mock.begin_calls != 4 || mock.poll_calls != 4) {
return fail(6, "retry after acceptance must fail closed without resubmit");
}
trikke_transport_sender_init(&sender);
if (trikke_transport_sender_step(
NULL, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_FATAL ||
trikke_transport_sender_step(
&sender, NULL, packet, sizeof(packet)) != TRIKKE_TRANSPORT_FATAL ||
trikke_transport_sender_step(
&sender, &transport, NULL, sizeof(packet)) !=
TRIKKE_TRANSPORT_FATAL ||
trikke_transport_sender_step(
&sender, &transport, packet, 0) != TRIKKE_TRANSPORT_FATAL) {
return fail(7, "invalid arguments must fail closed");
}
mock.begin_status = (trikke_transport_status_t)99;
if (trikke_transport_sender_step(
&sender, &transport, packet, sizeof(packet)) !=
TRIKKE_TRANSPORT_FATAL ||
sender.pending) {
return fail(8, "unknown backend status must fail closed");
}
return 0;
}
+74 -65
View File
@@ -6,6 +6,8 @@ import csv
import glob
import signal
import sys
import time
from contextlib import ExitStack
from datetime import datetime
from pathlib import Path
@@ -13,9 +15,9 @@ import serial
from trikke_protocol import (
CSV_COLUMNS,
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
PACKET_TYPE_METADATA,
Frame,
IntegrityTracker,
Metadata,
StreamParser,
sample_to_csv_row,
@@ -26,8 +28,18 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--port", help="serial port; auto-detected when omitted")
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument(
"--reset",
action="store_true",
help="hard-reset the ESP32-C3 after opening the serial port",
)
parser.add_argument("--output", type=Path, help="validated binary .trk output")
parser.add_argument("--csv", type=Path, help="decoded CSV output")
parser.add_argument(
"--wire",
type=Path,
help="optional byte-for-byte serial capture, including startup text",
)
return parser.parse_args()
@@ -57,6 +69,8 @@ def main() -> int:
csv_output = args.csv or output.with_suffix(".csv")
output.parent.mkdir(parents=True, exist_ok=True)
csv_output.parent.mkdir(parents=True, exist_ok=True)
if args.wire is not None:
args.wire.parent.mkdir(parents=True, exist_ok=True)
stop_requested = False
@@ -72,83 +86,56 @@ def main() -> int:
pending_frames: list[Frame] = []
sample_count = 0
metadata_count = 0
packet_gap_count = 0
packet_reset_count = 0
sample_gap_count = 0
sample_reset_count = 0
timing_anomaly_count = 0
timestamp_saturation_frame_count = 0
accel_stale_count = 0
accel_overrun_count = 0
gyro_stale_count = 0
gyro_overrun_count = 0
previous_packet_sequence = None
previous_sample_sequence = None
previous_timestamp_us = None
final_dropped_count = 0
final_loop_overrun_count = 0
integrity = IntegrityTracker()
serial_error: serial.SerialException | None = None
def render_frame(frame: Frame, writer: csv.writer) -> None:
nonlocal sample_count, sample_gap_count, sample_reset_count
nonlocal timing_anomaly_count
nonlocal accel_stale_count, accel_overrun_count
nonlocal gyro_stale_count, gyro_overrun_count
nonlocal previous_sample_sequence, previous_timestamp_us
nonlocal sample_count
if metadata is None:
pending_frames.append(frame)
return
for sample in frame.samples:
if previous_sample_sequence is not None:
expected = (previous_sample_sequence + 1) & 0xFFFFFFFF
if sample.sequence != expected:
if sample.sequence > expected:
sample_gap_count += sample.sequence - expected
else:
sample_reset_count += 1
if previous_timestamp_us is not None:
if sample.timestamp_us - previous_timestamp_us != 10_000:
timing_anomaly_count += 1
if not sample.accel_status & 0x80:
accel_stale_count += 1
if sample.accel_status & 0x01:
accel_overrun_count += 1
if not sample.gyro_status & 0x08:
gyro_stale_count += 1
if sample.gyro_status & 0x80:
gyro_overrun_count += 1
writer.writerow(sample_to_csv_row(sample, metadata, frame.loop_overrun_count))
previous_sample_sequence = sample.sequence
previous_timestamp_us = sample.timestamp_us
sample_count += 1
if sample_count % 500 == 0:
print(f" {sample_count} samples captured", flush=True)
print(f"Recording {port} to {output} and {csv_output}; press Ctrl-C to stop")
try:
with serial.Serial(port, args.baud, timeout=0.25) as sensor, output.open(
"wb"
) as raw_capture, csv_output.open("w", encoding="utf-8", newline="") as decoded:
with ExitStack() as stack:
sensor = stack.enter_context(
serial.Serial(port, args.baud, timeout=0.25)
)
raw_capture = stack.enter_context(output.open("wb"))
decoded = stack.enter_context(
csv_output.open("w", encoding="utf-8", newline="")
)
wire_capture = (
stack.enter_context(args.wire.open("wb"))
if args.wire is not None
else None
)
if args.reset:
# Match ESP-IDF monitor's USB Serial/JTAG hard-reset state:
# release DTR/RTS first, discard the old session, pulse reset,
# and keep this same reader open for the new boot stream.
sensor.dtr = False
sensor.rts = False
sensor.reset_input_buffer()
sensor.rts = True
time.sleep(0.2)
sensor.rts = False
writer = csv.writer(decoded)
writer.writerow(CSV_COLUMNS)
while not stop_requested:
chunk = sensor.read(4096)
if not chunk:
continue
if wire_capture is not None:
wire_capture.write(chunk)
for frame in parser.feed(chunk):
raw_capture.write(frame.raw)
final_dropped_count = frame.dropped_sample_count
final_loop_overrun_count = frame.loop_overrun_count
if previous_packet_sequence is not None:
expected = (previous_packet_sequence + 1) & 0xFFFFFFFF
if frame.packet_sequence != expected:
if frame.packet_sequence > expected:
packet_gap_count += frame.packet_sequence - expected
else:
packet_reset_count += 1
previous_packet_sequence = frame.packet_sequence
if frame.flags & PACKET_FLAG_TIMESTAMP_DELTA_SATURATED:
timestamp_saturation_frame_count += 1
integrity.observe(frame)
if frame.packet_type == PACKET_TYPE_METADATA:
metadata = frame.metadata
metadata_count += 1
@@ -159,16 +146,21 @@ def main() -> int:
render_frame(frame, writer)
raw_capture.flush()
decoded.flush()
if wire_capture is not None:
wire_capture.flush()
except serial.SerialException as exc:
print(f"Serial error: {exc}", file=sys.stderr)
serial_error = exc
print(
f"Stopped after {sample_count} samples and {metadata_count} metadata frames; "
f"packet_gaps={packet_gap_count}, packet_resets={packet_reset_count}, "
f"sample_gaps={sample_gap_count}, sample_resets={sample_reset_count}, "
f"timing_anomalies={timing_anomaly_count}, "
f"timestamp_saturation_frames={timestamp_saturation_frame_count}, "
f"packet_gaps={integrity.packet_gap_count}, "
f"packet_resets={integrity.packet_reset_count}, "
f"sample_gaps={integrity.sample_gap_count}, "
f"sample_resets={integrity.sample_reset_count}, "
f"timing_anomalies={integrity.timing_anomaly_count}, "
"timestamp_saturation_frames="
f"{integrity.timestamp_saturation_frame_count}, "
f"startup_crc_rejects={parser.startup_crc_errors}, "
f"stream_crc_errors={parser.crc_errors}, "
f"header_errors={parser.header_errors}, "
@@ -176,12 +168,29 @@ def main() -> int:
f"trailing_partial_bytes={parser.buffered_bytes}"
)
print(
f"Status totals: accel_stale={accel_stale_count}, "
f"accel_overrun={accel_overrun_count}, gyro_stale={gyro_stale_count}, "
f"gyro_overrun={gyro_overrun_count}, dropped={final_dropped_count}, "
f"acquisition_loop_overruns={final_loop_overrun_count}"
f"Status totals: accel_stale={integrity.accel_stale_count}, "
f"accel_overrun={integrity.accel_overrun_count}, "
f"gyro_stale={integrity.gyro_stale_count}, "
f"gyro_overrun={integrity.gyro_overrun_count}, "
f"dropped={integrity.final_dropped_sample_count}, "
f"acquisition_loop_overruns={integrity.final_loop_overrun_count}"
)
print(f"Saved {output} and {csv_output}")
if integrity.final_status is not None:
status = integrity.final_status
print(
"Cause totals: "
f"sensor_read_failures={status.sensor_read_failure_count}, "
f"queue_overflows={status.queue_overflow_count}, "
f"transport_begin_retries={status.transport_begin_retry_count}, "
f"transport_disconnects={status.transport_disconnect_count}, "
f"transport_send_failures={status.transport_send_failure_count}, "
f"transport_replays={status.transport_replay_count}, "
f"transport_invalid_acks={status.transport_invalid_ack_count}"
)
saved = f"Saved {output} and {csv_output}"
if args.wire is not None:
saved += f"; raw wire saved to {args.wire}"
print(saved)
if serial_error is not None:
return 1
return 0 if metadata is not None else 4
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""Capture acknowledged TRK1 telemetry from the Trikke BLE service."""
from __future__ import annotations
import argparse
import asyncio
import csv
import os
import secrets
import signal
from contextlib import ExitStack
from datetime import datetime
from pathlib import Path
from trikke_ble import BleFrameReassembler, encode_ack, encode_begin_session
from trikke_protocol import (
CSV_COLUMNS,
PACKET_TYPE_METADATA,
Frame,
IntegrityTracker,
Metadata,
StreamParser,
sample_to_csv_row,
)
DEVICE_NAME = "TrikkeSensor"
SERVICE_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10"
DATA_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11"
ACK_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12"
CONTROL_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c13"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--address", help="BLE address/identifier; scan by name when omitted")
parser.add_argument("--name", default=DEVICE_NAME)
parser.add_argument("--output", type=Path, help="validated binary .trk output")
parser.add_argument("--csv", type=Path, help="decoded CSV output")
return parser.parse_args()
async def capture(args: argparse.Namespace) -> int:
try:
from bleak import BleakClient, BleakScanner
from bleak.exc import BleakError
except ImportError:
print("BLE capture requires bleak: python3 -m pip install -r requirements.txt")
return 2
stem = datetime.now().strftime("ble_%Y%m%d_%H%M%S")
output = args.output or Path("captures") / f"{stem}.trk"
csv_output = args.csv or output.with_suffix(".csv")
output.parent.mkdir(parents=True, exist_ok=True)
csv_output.parent.mkdir(parents=True, exist_ok=True)
device = args.address
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for signum in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(signum, stop.set)
except NotImplementedError:
pass
fragments: asyncio.Queue[bytes] = asyncio.Queue(maxsize=512)
callback_drop_count = 0
def on_fragment(_characteristic: object, data: bytearray) -> None:
payload = bytes(data)
def enqueue() -> None:
nonlocal callback_drop_count
try:
fragments.put_nowait(payload)
except asyncio.QueueFull:
callback_drop_count += 1
loop.call_soon_threadsafe(enqueue)
reassembler = BleFrameReassembler()
parser = StreamParser()
integrity = IntegrityTracker()
metadata: Metadata | None = None
pending_frames: list[Frame] = []
last_persisted_sequence: int | None = None
last_persisted_raw: bytes | None = None
sample_count = 0
frame_count = 0
session_token = secrets.randbits(64)
with ExitStack() as stack:
raw_capture = stack.enter_context(output.open("wb"))
decoded = stack.enter_context(csv_output.open("w", encoding="utf-8", newline=""))
writer = csv.writer(decoded)
writer.writerow(CSV_COLUMNS)
print(f"Recording to {output} and {csv_output}; press Ctrl-C to stop")
while not stop.is_set():
try:
if device is None:
print(f"Scanning for {args.name}...")
device = await BleakScanner.find_device_by_name(
args.name,
timeout=5.0,
service_uuids=[SERVICE_UUID],
)
if device is None:
await asyncio.sleep(0.5)
continue
print(f"Connecting to {device}...")
async with BleakClient(device) as client:
reassembler.reset()
while not fragments.empty():
fragments.get_nowait()
await client.write_gatt_char(
CONTROL_UUID,
encode_begin_session(session_token),
response=True,
)
await client.start_notify(DATA_UUID, on_fragment)
print("BLE connected and subscribed")
while not stop.is_set() and client.is_connected:
try:
fragment = await asyncio.wait_for(
fragments.get(), timeout=0.25
)
except TimeoutError:
continue
assembled = reassembler.feed(fragment)
if assembled is None:
continue
frames = parser.feed(assembled)
if len(frames) != 1 or frames[0].raw != assembled:
continue
frame = frames[0]
if (
frame.packet_sequence == last_persisted_sequence
and frame.raw == last_persisted_raw
):
await client.write_gatt_char(
ACK_UUID,
encode_ack(frame.packet_sequence),
response=True,
)
continue
raw_capture.write(frame.raw)
raw_capture.flush()
os.fsync(raw_capture.fileno())
integrity.observe(frame)
if frame.packet_type == PACKET_TYPE_METADATA:
metadata = frame.metadata
for pending in pending_frames:
for sample in pending.samples:
writer.writerow(sample_to_csv_row(
sample,
metadata,
pending.loop_overrun_count,
))
sample_count += 1
pending_frames.clear()
elif metadata is None:
pending_frames.append(frame)
else:
for sample in frame.samples:
writer.writerow(sample_to_csv_row(
sample, metadata, frame.loop_overrun_count
))
sample_count += 1
decoded.flush()
# The binary stream is authoritative and fsynced before
# ACK. A lost ACK is safe: replay is deduped above.
last_persisted_sequence = frame.packet_sequence
last_persisted_raw = frame.raw
await client.write_gatt_char(
ACK_UUID,
encode_ack(frame.packet_sequence),
response=True,
)
frame_count += 1
if client.is_connected:
await client.stop_notify(DATA_UUID)
except (BleakError, OSError) as error:
if not stop.is_set():
print(f"BLE interrupted ({error}); reconnecting")
if args.address is None:
device = None
if not stop.is_set():
await asyncio.sleep(0.5)
print(
f"Stopped after {frame_count} frames and {sample_count} samples; "
f"fragment_rejects={reassembler.rejected_fragment_count}, "
f"callback_drops={callback_drop_count}, "
f"packet_gaps={integrity.packet_gap_count}, "
f"packet_resets={integrity.packet_reset_count}, "
f"sample_gaps={integrity.sample_gap_count}, "
f"sample_resets={integrity.sample_reset_count}, "
f"timing_anomalies={integrity.timing_anomaly_count}, "
"timestamp_saturation_frames="
f"{integrity.timestamp_saturation_frame_count}, "
f"startup_crc_rejects={parser.startup_crc_errors}, "
f"stream_crc_errors={parser.crc_errors}, "
f"header_errors={parser.header_errors}, "
f"skipped_nonframe_bytes={parser.skipped_bytes}, "
f"trailing_partial_bytes={parser.buffered_bytes}"
)
print(
f"Status totals: accel_stale={integrity.accel_stale_count}, "
f"accel_overrun={integrity.accel_overrun_count}, "
f"gyro_stale={integrity.gyro_stale_count}, "
f"gyro_overrun={integrity.gyro_overrun_count}, "
f"dropped={integrity.final_dropped_sample_count}, "
f"acquisition_loop_overruns={integrity.final_loop_overrun_count}"
)
if integrity.final_status is not None:
status = integrity.final_status
print(
"Cause totals: "
f"sensor_read_failures={status.sensor_read_failure_count}, "
f"queue_overflows={status.queue_overflow_count}, "
f"transport_begin_retries={status.transport_begin_retry_count}, "
f"transport_disconnects={status.transport_disconnect_count}, "
f"transport_send_failures={status.transport_send_failure_count}, "
f"transport_replays={status.transport_replay_count}, "
f"transport_invalid_acks={status.transport_invalid_ack_count}"
)
return 0 if metadata is not None else 4
def main() -> int:
return asyncio.run(capture(parse_args()))
if __name__ == "__main__":
raise SystemExit(main())
+28 -5
View File
@@ -7,9 +7,9 @@ from pathlib import Path
from trikke_protocol import (
CSV_COLUMNS,
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED,
PACKET_TYPE_METADATA,
Frame,
IntegrityTracker,
StreamParser,
sample_to_csv_row,
)
@@ -40,13 +40,12 @@ def main() -> int:
args.output.parent.mkdir(parents=True, exist_ok=True)
sample_count = 0
timestamp_saturation_frame_count = 0
integrity = IntegrityTracker()
with args.output.open("w", encoding="utf-8", newline="") as target:
writer = csv.writer(target)
writer.writerow(CSV_COLUMNS)
for frame in frames:
if frame.flags & PACKET_FLAG_TIMESTAMP_DELTA_SATURATED:
timestamp_saturation_frame_count += 1
integrity.observe(frame)
if frame.packet_type == PACKET_TYPE_METADATA:
if frame.metadata is not None:
metadata = frame.metadata
@@ -62,9 +61,33 @@ def main() -> int:
f"startup_crc_rejects={stream.startup_crc_errors}, "
f"stream_crc_errors={stream.crc_errors}, header_errors={stream.header_errors}, "
f"skipped_nonframe_bytes={stream.skipped_bytes}, "
f"timestamp_saturation_frames={timestamp_saturation_frame_count}, "
f"packet_gaps={integrity.packet_gap_count}, "
f"packet_resets={integrity.packet_reset_count}, "
f"sample_gaps={integrity.sample_gap_count}, "
f"sample_resets={integrity.sample_reset_count}, "
f"timing_anomalies={integrity.timing_anomaly_count}, "
"timestamp_saturation_frames="
f"{integrity.timestamp_saturation_frame_count}, "
f"accel_stale={integrity.accel_stale_count}, "
f"accel_overrun={integrity.accel_overrun_count}, "
f"gyro_stale={integrity.gyro_stale_count}, "
f"gyro_overrun={integrity.gyro_overrun_count}, "
f"dropped={integrity.final_dropped_sample_count}, "
f"acquisition_loop_overruns={integrity.final_loop_overrun_count}, "
f"trailing_partial_bytes={stream.buffered_bytes}; saved {args.output}"
)
if integrity.final_status is not None:
status = integrity.final_status
print(
"Cause totals: "
f"sensor_read_failures={status.sensor_read_failure_count}, "
f"queue_overflows={status.queue_overflow_count}, "
f"transport_begin_retries={status.transport_begin_retry_count}, "
f"transport_disconnects={status.transport_disconnect_count}, "
f"transport_send_failures={status.transport_send_failure_count}, "
f"transport_replays={status.transport_replay_count}, "
f"transport_invalid_acks={status.transport_invalid_ack_count}"
)
return 0
+71
View File
@@ -0,0 +1,71 @@
"""BLE fragment reassembly and acknowledgement helpers for TRK1 frames."""
from __future__ import annotations
import struct
BLE_FRAGMENT_HEADER = struct.Struct("<IHH")
BLE_ACK = struct.Struct("<4sI")
BLE_BEGIN_SESSION = struct.Struct("<4sQ")
BLE_MIN_FRAME_SIZE = 36
BLE_MAX_FRAME_SIZE = 196
class BleFrameReassembler:
def __init__(self) -> None:
self.rejected_fragment_count = 0
self._sequence: int | None = None
self._total_size = 0
self._frame = bytearray()
def reset(self) -> None:
self._sequence = None
self._total_size = 0
self._frame.clear()
def feed(self, fragment: bytes) -> bytes | None:
if len(fragment) <= BLE_FRAGMENT_HEADER.size:
self.rejected_fragment_count += 1
self.reset()
return None
sequence, offset, total_size = BLE_FRAGMENT_HEADER.unpack_from(fragment)
data = fragment[BLE_FRAGMENT_HEADER.size :]
if (
total_size < BLE_MIN_FRAME_SIZE
or total_size > BLE_MAX_FRAME_SIZE
or offset >= total_size
or offset + len(data) > total_size
):
self.rejected_fragment_count += 1
self.reset()
return None
# Offset zero is an explicit replay boundary, including when the same
# packet restarts after an ACK timeout or reconnect.
if offset == 0:
self._sequence = sequence
self._total_size = total_size
self._frame = bytearray()
if (
self._sequence != sequence
or self._total_size != total_size
or offset != len(self._frame)
):
self.rejected_fragment_count += 1
self.reset()
return None
self._frame.extend(data)
if len(self._frame) != self._total_size:
return None
frame = bytes(self._frame)
self.reset()
return frame
def encode_ack(packet_sequence: int) -> bytes:
return BLE_ACK.pack(b"ACK1", packet_sequence & 0xFFFFFFFF)
def encode_begin_session(session_token: int) -> bytes:
return BLE_BEGIN_SESSION.pack(b"BGN1", session_token & 0xFFFFFFFFFFFFFFFF)
+114 -3
View File
@@ -12,15 +12,18 @@ VERSION = 1
HEADER_SIZE = 36
SAMPLE_RECORD_SIZE = 20
METADATA_SIZE = 48
STATUS_SIZE = 32
MAX_RECORDS = 8
PACKET_TYPE_METADATA = 1
PACKET_TYPE_SAMPLES = 2
PACKET_TYPE_STATUS = 3
PACKET_FLAG_TIMESTAMP_DELTA_SATURATED = 0x01
HEADER = struct.Struct("<4sBBBBBBHIQIII")
METADATA = struct.Struct("<HHHH10f")
SAMPLE = struct.Struct("<IHhhhhhhBB")
STATUS = struct.Struct("<HH7I")
CSV_COLUMNS = [
"sequence",
@@ -71,6 +74,17 @@ class Sample:
gyro_status: int
@dataclass(frozen=True)
class Status:
sensor_read_failure_count: int
queue_overflow_count: int
transport_begin_retry_count: int
transport_disconnect_count: int
transport_send_failure_count: int
transport_replay_count: int
transport_invalid_ack_count: int
@dataclass(frozen=True)
class Frame:
packet_type: int
@@ -80,10 +94,83 @@ class Frame:
dropped_sample_count: int
loop_overrun_count: int
metadata: Metadata | None
status: Status | None
samples: tuple[Sample, ...]
raw: bytes
@dataclass
class IntegrityTracker:
packet_gap_count: int = 0
packet_reset_count: int = 0
sample_gap_count: int = 0
sample_reset_count: int = 0
timing_anomaly_count: int = 0
timestamp_saturation_frame_count: int = 0
accel_stale_count: int = 0
accel_overrun_count: int = 0
gyro_stale_count: int = 0
gyro_overrun_count: int = 0
final_dropped_sample_count: int = 0
final_loop_overrun_count: int = 0
final_status: Status | None = None
_previous_packet_sequence: int | None = None
_previous_sample_sequence: int | None = None
_previous_timestamp_us: int | None = None
@staticmethod
def _classify_sequence(
previous: int,
current: int,
) -> tuple[int, int]:
expected = (previous + 1) & 0xFFFFFFFF
forward_distance = (current - expected) & 0xFFFFFFFF
if forward_distance == 0:
return (0, 0)
if forward_distance < 0x80000000:
return (forward_distance, 0)
return (0, 1)
def observe(self, frame: Frame) -> None:
if self._previous_packet_sequence is not None:
gaps, resets = self._classify_sequence(
self._previous_packet_sequence, frame.packet_sequence
)
self.packet_gap_count += gaps
self.packet_reset_count += resets
self._previous_packet_sequence = frame.packet_sequence
if frame.flags & PACKET_FLAG_TIMESTAMP_DELTA_SATURATED:
self.timestamp_saturation_frame_count += 1
self.final_dropped_sample_count = frame.dropped_sample_count
self.final_loop_overrun_count = frame.loop_overrun_count
if frame.status is not None:
self.final_status = frame.status
for sample in frame.samples:
if self._previous_sample_sequence is not None:
gaps, resets = self._classify_sequence(
self._previous_sample_sequence, sample.sequence
)
self.sample_gap_count += gaps
self.sample_reset_count += resets
if (
self._previous_timestamp_us is not None
and sample.timestamp_us - self._previous_timestamp_us != 10_000
):
self.timing_anomaly_count += 1
if not sample.accel_status & 0x80:
self.accel_stale_count += 1
if sample.accel_status & 0x01:
self.accel_overrun_count += 1
if not sample.gyro_status & 0x08:
self.gyro_stale_count += 1
if sample.gyro_status & 0x80:
self.gyro_overrun_count += 1
self._previous_sample_sequence = sample.sequence
self._previous_timestamp_us = sample.timestamp_us
def _lround(value: float) -> int:
"""Match C lroundf: nearest integer, halfway cases away from zero."""
return math.floor(value + 0.5) if value >= 0 else math.ceil(value - 0.5)
@@ -172,8 +259,16 @@ class StreamParser:
valid_shape = (
version == VERSION
and header_size == HEADER_SIZE
and packet_type in (PACKET_TYPE_METADATA, PACKET_TYPE_SAMPLES)
and payload_size <= max(METADATA_SIZE, SAMPLE_RECORD_SIZE * MAX_RECORDS)
and packet_type in (
PACKET_TYPE_METADATA,
PACKET_TYPE_SAMPLES,
PACKET_TYPE_STATUS,
)
and payload_size <= max(
METADATA_SIZE,
STATUS_SIZE,
SAMPLE_RECORD_SIZE * MAX_RECORDS,
)
)
if packet_type == PACKET_TYPE_METADATA:
valid_shape = valid_shape and (
@@ -185,6 +280,12 @@ class StreamParser:
and 1 <= record_count <= MAX_RECORDS
and payload_size == record_size * record_count
)
elif packet_type == PACKET_TYPE_STATUS:
valid_shape = valid_shape and (
record_size == 0
and record_count == 0
and payload_size == STATUS_SIZE
)
if not valid_shape:
self.header_errors += 1
self.skipped_bytes += 1
@@ -207,6 +308,7 @@ class StreamParser:
continue
metadata = None
status = None
samples: tuple[Sample, ...] = ()
payload = raw[HEADER_SIZE:]
if packet_type == PACKET_TYPE_METADATA:
@@ -221,7 +323,7 @@ class StreamParser:
gyro_bias_counts=values[10:13],
gyro_mdps_per_lsb=values[13],
)
else:
elif packet_type == PACKET_TYPE_SAMPLES:
decoded: list[Sample] = []
timestamp_us = base_timestamp_us
for index in range(record_count):
@@ -239,6 +341,14 @@ class StreamParser:
)
)
samples = tuple(decoded)
else:
values = STATUS.unpack(payload)
if values[0] != 1 or values[1] != STATUS_SIZE:
self.header_errors += 1
self.skipped_bytes += 1
del self._buffer[0]
continue
status = Status(*values[2:])
frames.append(
Frame(
@@ -249,6 +359,7 @@ class StreamParser:
dropped_sample_count=dropped_sample_count,
loop_overrun_count=loop_overrun_count,
metadata=metadata,
status=status,
samples=samples,
raw=raw,
)