feat(train): rowing on the WP 5000 over FTMS (ADR 0030, D28)

Rowing indoors is offered again, with the rower chosen on the Record
sheet: a sheet looks for Fitness Machine Service rowers, and the one
chosen is remembered in the person's folder. The recorder service
connects to it as to the strap (one NotifyLink for both), parses
FTMS's Rower Data in Kotlin (tested on the JVM on frames written from
the specification and D8's), counts its totals from its first frame,
pauses 5 s after the last stroke, and gets the connectedDevice type. A
row's screen shows its metres, split per 500 m, stroke rate and watts;
its page its average split, stroke rate and power, the highest power,
its strokes, splits per 500 m and charts of the split, stroke rate and
power. Not yet tried on the WP 5000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 16:20:37 +01:00
commit f3d08e4e69
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 2266 additions and 514 deletions

View file

@ -14,8 +14,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- The heart-rate strap (ADR 0030, D26, D27): nearby devices from Android
<!-- The heart-rate strap and the rower (ADR 0030, D26 to D28): nearby devices from Android
12 on, never used to locate; before 12, Bluetooth's own permissions,
and scanning needs location, which the app has for GPS anyway. -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
@ -68,7 +69,7 @@
<service
android:name=".RecorderService"
android:exported="false"
android:foregroundServiceType="location|health" />
android:foregroundServiceType="location|health|connectedDevice" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View file

@ -2,7 +2,8 @@ package app.halkyon.train
// Auto-pause by each activity's best signal (ADR 0030, D16), decided in the
// service, which runs while the app may not: steps on foot, confirmed by GPS;
// GPS speed on a bike; GPS speed on foot where steps aren't allowed (D19).
// GPS speed on a bike; GPS speed on foot where steps aren't allowed (D19);
// the rower's strokes (D28).
// Times in milliseconds; each call returns true where it paused, false where
// it went on, null where nothing changed.
@ -17,6 +18,9 @@ class AutoPause(private val signal: Signal) {
/** By bike, by GPS speed: paused below 3 km/h, going on above 5 km/h. */
BIKE_SPEED,
/** On the rower, by its strokes: paused 5 s after the last, going on with the next. */
STROKES,
}
/** Whether it paused by itself. */
@ -41,6 +45,17 @@ class AutoPause(private val signal: Signal) {
return null
}
/** A new stroke at [at]. */
fun stroke(at: Long): Boolean? {
if (signal != Signal.STROKES || byHand) return null
lastStep = at
if (paused) {
paused = false
return false
}
return null
}
/** A fix at [at], moving at [metresPerSecond]. */
fun speed(at: Long, metresPerSecond: Double): Boolean? {
val kmh = metresPerSecond * 3.6
@ -70,6 +85,11 @@ class AutoPause(private val signal: Signal) {
if (byHand || paused) return null
val still =
when (signal) {
Signal.STROKES -> {
// Starting counts as a stroke.
val last = lastStep ?: at.also { lastStep = it }
at - last >= STILL_MS
}
Signal.STEPS -> {
// Starting counts as a step.
val last = lastStep ?: at.also { lastStep = it }

View file

@ -0,0 +1,87 @@
package app.halkyon.train
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
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.ParcelUuid
import io.flutter.plugin.common.EventChannel
import java.util.UUID
/**
* Looks for devices nearby while the app listens (ADR 0030, D27, D28): those
* advertising [service] that [accepts] lets through, as `{address, name,
* rssi}`; the error `off` where Bluetooth is, `refused` where scanning isn't
* allowed (the app asks first).
*/
class DeviceScanner(
private val context: Context,
private val service: UUID,
private val accepts: (ScanResult) -> Boolean = { true },
) : EventChannel.StreamHandler {
private var callback: ScanCallback? = null
@SuppressLint("MissingPermission") // Refused scans end in the SecurityException below.
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter
val scanner = adapter?.takeIf { it.isEnabled }?.bluetoothLeScanner
if (scanner == null) {
events.error("off", "Bluetooth is off", null)
return
}
val found =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (!accepts(result)) return
val name =
result.scanRecord?.deviceName
?: try {
result.device.name
} catch (_: SecurityException) {
null
}
events.success(mapOf("address" to result.device.address, "name" to name, "rssi" to result.rssi))
}
override fun onScanFailed(errorCode: Int) {
events.error("failed", "Scan failed: $errorCode", null)
}
}
try {
scanner.startScan(
listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(service)).build()),
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
found,
)
callback = found
} catch (_: SecurityException) {
events.error("refused", "Scanning not allowed", null)
}
}
@SuppressLint("MissingPermission")
override fun onCancel(arguments: Any?) {
val found = callback ?: return
callback = null
try {
context.getSystemService(BluetoothManager::class.java)?.adapter?.bluetoothLeScanner?.stopScan(found)
} catch (_: SecurityException) {
// Taken away while it looked: it stopped with it.
}
}
}
/**
* Whether [result] may be a rower (D28): its Fitness Machine service data
* says "rower" (FTMS 3.1.1: flags, then the type's 16 bits); one without the
* data can't tell, and is listed too.
*/
fun mayBeRower(result: ScanResult): Boolean {
val data = result.scanRecord?.getServiceData(ParcelUuid(RowerData.SERVICE)) ?: return true
if (data.size < 3) return true
val type = (data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8)
return type and RowerData.ROWER_TYPE != 0
}

View file

@ -29,6 +29,7 @@ class MainActivity : FlutterActivity() {
autoPause = call.argument<String>("autoPause"),
notice = call.argument<String>("notice")!!,
strap = call.argument<String>("strap"),
rower = call.argument<String>("rower"),
)
result.success(null)
}
@ -40,8 +41,11 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented()
}
}
// Heart-rate straps nearby, while the strap sheet looks (D27).
EventChannel(messenger, "app.halkyon.train/recorder/straps").setStreamHandler(StrapScanner(this))
// Heart-rate straps and rowers nearby, while their sheets look (D27, D28).
EventChannel(messenger, "app.halkyon.train/recorder/straps")
.setStreamHandler(DeviceScanner(this, HeartRateMeasurement.SERVICE))
EventChannel(messenger, "app.halkyon.train/recorder/rowers")
.setStreamHandler(DeviceScanner(this, RowerData.SERVICE, ::mayBeRower))
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {

View file

@ -0,0 +1,130 @@
package app.halkyon.train
import android.annotation.SuppressLint
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.content.Context
import android.os.Build
import android.os.Handler
import java.util.UUID
/**
* A device during a recording (ADR 0030, D26 to D28), the heart-rate strap
* or the rower: connected by its address without bonding, [characteristic]
* of [service] notified, and asked for again by itself whenever it goes,
* until [close]. Each state and each value reaches [onState] and [onValue]
* on [handler]'s thread, the one the recorder writes its file on.
*/
@SuppressLint("MissingPermission") // The recorder opens it only where connecting is allowed.
class NotifyLink(
private val context: Context,
private val address: String,
private val service: UUID,
private val characteristic: UUID,
private val handler: Handler,
private val onState: (String) -> Unit,
private val onValue: (ByteArray) -> Unit,
) : BluetoothGattCallback() {
private var gatt: BluetoothGatt? = null
private var closed = false
private var connected = false
/** Asks for the device: at once first, then in the background after each loss. */
fun open() {
val device = adapterDevice() ?: return onState("lost")
onState("connecting")
// A direct attempt finds a device in reach within seconds; one that
// fails (out of reach, off, or held by another app) falls back on the
// background one.
gatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
}
/** Lets the device go. */
fun close() {
handler.post {
closed = true
gatt?.disconnect()
gatt?.close()
gatt = null
}
}
private fun adapterDevice(): BluetoothDevice? =
try {
context.getSystemService(BluetoothManager::class.java)?.adapter?.getRemoteDevice(address)
} catch (_: IllegalArgumentException) {
null
}
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
handler.post {
if (closed) return@post
when (newState) {
BluetoothProfile.STATE_CONNECTED -> gatt.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> {
if (connected) onState("lost")
connected = false
gatt.close()
// In the background, for as long as the recording runs: Android
// connects once the device is in reach again.
this.gatt = adapterDevice()?.connectGatt(context, true, this, BluetoothDevice.TRANSPORT_LE)
}
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
handler.post {
if (closed) return@post
val measurement =
gatt.getService(service)?.getCharacteristic(characteristic) ?: return@post gatt.disconnect()
gatt.setCharacteristicNotification(measurement, true)
val config = measurement.getDescriptor(HeartRateMeasurement.CONFIG) ?: return@post gatt.disconnect()
val enable = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(config, enable)
} else {
@Suppress("DEPRECATION")
config.value = enable
@Suppress("DEPRECATION")
gatt.writeDescriptor(config)
}
}
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
handler.post {
if (closed || status != BluetoothGatt.GATT_SUCCESS) return@post
connected = true
onState("connected")
}
}
// Android 13 on.
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
measured(characteristic, value)
}
// Before Android 13.
@Deprecated("Android 13 hands the value itself")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
@Suppress("DEPRECATION")
measured(characteristic, characteristic.value ?: return)
}
private fun measured(characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (characteristic.uuid != this.characteristic) return
val copy = value.copyOf()
handler.post { if (!closed) onValue(copy) }
}
}

View file

@ -35,8 +35,8 @@ import org.json.JSONObject
// The recording's side of the system (ADR 0030, D13, D16, D18, D19): a
// foreground service, started from the open app, that takes GPS fixes from
// Android's LocationManager (no Play services) and steps from the step
// detector, reads the heart-rate strap where the person chose one (D26,
// D27), pauses by itself, and appends each sample as a line to the
// detector, reads the heart-rate strap and the rower where the person chose
// them (D26 to D28), pauses by itself, and appends each sample as a line to the
// recording file, flushed every few seconds, so a recording survives the app
// and is found cut off if the process doesn't. Each line goes to the app as
// well while it listens; the app works out the totals and tells the service
@ -53,6 +53,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private const val EXTRA_AUTO_PAUSE = "autoPause"
private const val EXTRA_NOTICE = "notice"
private const val EXTRA_STRAP = "strap"
private const val EXTRA_ROWER = "rower"
private const val CHANNEL = "recording"
private const val NOTIFICATION_ID = 5301
private const val FLUSH_EVERY_MS = 5_000L
@ -68,10 +69,18 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
/**
* Starts recording [activity] into the file at [path], pausing by itself
* by [autoPause] (null: never), with the strap at [strap] (null: none),
* its notification as [notice] says.
* by [autoPause] (null: never), with the strap at [strap] and the rower
* at [rower] (null: none), its notification as [notice] says.
*/
fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String, strap: String?) {
fun start(
context: Context,
path: String,
activity: String,
autoPause: String?,
notice: String,
strap: String?,
rower: String?,
) {
ContextCompat.startForegroundService(
context,
Intent(context, RecorderService::class.java)
@ -80,7 +89,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
.putExtra(EXTRA_ACTIVITY, activity)
.putExtra(EXTRA_AUTO_PAUSE, autoPause)
.putExtra(EXTRA_NOTICE, notice)
.putExtra(EXTRA_STRAP, strap),
.putExtra(EXTRA_STRAP, strap)
.putExtra(EXTRA_ROWER, rower),
)
}
@ -110,7 +120,12 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var pausedByHand = false
private var notice: JSONObject? = null
private var wakeLock: PowerManager.WakeLock? = null
private var strap: StrapLink? = null
private var strap: NotifyLink? = null
private var rower: NotifyLink? = null
/** The rower's strokes and metres, from its first frame on (D28). */
private val strokes = Counted()
private val metres = Counted()
private val handler = Handler(Looper.getMainLooper())
private var lastFlush = 0L
@ -147,30 +162,45 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private fun begin(intent: Intent) {
path = intent.getStringExtra(EXTRA_PATH) ?: return stopSelf()
val activity = intent.getStringExtra(EXTRA_ACTIVITY) ?: return stopSelf()
val located = granted(Manifest.permission.ACCESS_FINE_LOCATION)
// No GPS on the rower, whatever location allows.
val located = activity != "indoorRowing" && granted(Manifest.permission.ACCESS_FINE_LOCATION)
val counted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || granted(Manifest.permission.ACTIVITY_RECOGNITION)
// The strap and the rower where they were chosen and connecting is
// allowed (Android 12 on asks; before, scanning's location stands for it).
val connects =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || granted(Manifest.permission.BLUETOOTH_CONNECT)
val strapAt = intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }
val rowerAt = intent.getStringExtra(EXTRA_ROWER)?.takeIf { connects }
val autoPauseBy = intent.getStringExtra(EXTRA_AUTO_PAUSE)
val stepping = autoPauseBy == "steps" && counted
notice = intent.getStringExtra(EXTRA_NOTICE)?.let(::JSONObject)
channel(notice?.optString("channelName"))
// In the foreground first: Android allows a few seconds for it.
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), types(located, counted))
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification(),
types(located, stepping, linked = strapAt != null || rowerAt != null),
)
running = this
val now = System.currentTimeMillis()
writer = File(path).apply { parentFile?.mkdirs() }.bufferedWriter().also {
it.append("halkyon-train-recording 1 $activity $now\n")
}
autoPause =
when (intent.getStringExtra(EXTRA_AUTO_PAUSE)) {
when (autoPauseBy) {
// Steps where they may be counted, GPS speed where not (D19).
"steps" -> AutoPause(if (counted) AutoPause.Signal.STEPS else AutoPause.Signal.FOOT_SPEED)
"bike" -> AutoPause(AutoPause.Signal.BIKE_SPEED)
"strokes" -> AutoPause(AutoPause.Signal.STROKES)
else -> null
}
if (located) {
getSystemService(LocationManager::class.java)
.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1_000L, 0f, this, Looper.getMainLooper())
}
if (counted && autoPause != null) {
if (stepping) {
getSystemService(SensorManager::class.java).let { sensors ->
sensors.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)?.let {
sensors.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
@ -183,35 +213,94 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HalkyonTrain:recording")
.apply { acquire(12 * 60 * 60 * 1_000L) }
// The strap where one was chosen and connecting is allowed (Android 12
// on asks; before, scanning's location stands for it).
val connects =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || granted(Manifest.permission.BLUETOOTH_CONNECT)
intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }?.let { address ->
strapAt?.let { address ->
strap =
StrapLink(
NotifyLink(
this,
address,
HeartRateMeasurement.SERVICE,
HeartRateMeasurement.CHARACTERISTIC,
handler,
onState = { state -> write("strap ${System.currentTimeMillis()} $state") },
onBpm = { bpm -> write("h ${System.currentTimeMillis()} $bpm") },
onValue = { value ->
HeartRateMeasurement.bpmOf(value)?.let { bpm -> write("h ${System.currentTimeMillis()} $bpm") }
},
).also { it.open() }
}
rowerAt?.let { address ->
rower =
NotifyLink(
this,
address,
RowerData.SERVICE,
RowerData.CHARACTERISTIC,
handler,
onState = { state -> write("rower ${System.currentTimeMillis()} $state") },
onValue = { value -> RowerData.of(value)?.let(::rowed) },
).also { it.open() }
}
lastFlush = now
handler.postDelayed(ticker, 1_000)
}
/** The foreground service's types: location with GPS, health with the step detector (Android 14 on asks). */
private fun types(located: Boolean, counted: Boolean): Int {
/**
* The foreground service's types: location with GPS, health with the step
* detector, connectedDevice with the strap or the rower (Android 14 on asks).
*/
private fun types(located: Boolean, stepping: Boolean, linked: Boolean): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0
var types = 0
if (located) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
if (counted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
if (linked) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
if (stepping && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
}
return types
}
/**
* A frame of the rower's (D28): its totals counted from its first frame
* (a console reset to zero mid-row counting on from there), a line of what
* it carries, a new stroke for auto-pause, and its heart rate where no
* strap gives one. A frame of a reading split in two keeps the totals the
* other half brought.
*/
private fun rowed(data: RowerData) {
val at = System.currentTimeMillis()
data.strokes?.let { raw ->
val stroked = strokes.add(raw)
if (stroked && !pausedByHand) {
autoPause?.stroke(at)?.let { paused -> mark(paused, auto = true, at = at) }
}
}
data.metres?.let { metres.add(it) }
fun field(value: Any?) = value?.toString() ?: "-"
write("r $at ${field(data.strokeRate)} ${field(strokes.total)} ${field(metres.total)} ${field(data.watts)}")
if (strap == null) data.bpm?.let { write("h $at $it") }
}
/** A console's running total, counted from the first reading. */
private class Counted {
private var last: Int? = null
/** Null before the first reading. */
var total: Int? = null
private set
/** Takes [raw] in; true where the total grew. */
fun add(raw: Int): Boolean {
val before = last
last = raw
if (before == null) {
total = 0
return false
}
val grown = if (raw < before) raw else raw - before
total = (total ?: 0) + grown
return grown > 0
}
}
override fun onLocationChanged(location: Location) {
val at = wallTime(location.elapsedRealtimeNanos)
val altitude = if (location.hasAltitude()) (location.altitude * 100).roundToLong().toString() else "-"
@ -267,6 +356,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
getSystemService(SensorManager::class.java).unregisterListener(this)
strap?.close()
strap = null
rower?.close()
rower = null
write("end ${System.currentTimeMillis()}")
writer?.close()
writer = null

View file

@ -0,0 +1,104 @@
package app.halkyon.train
import java.util.UUID
/**
* A rower's Rower Data (Bluetooth SIG's Fitness Machine Service 1.0, 4.8,
* characteristic 0x2AD1), as it notifies it (ADR 0030, D8, D28): what Train
* keeps of a frame, each null where the frame doesn't carry it. A machine may
* split one reading over several frames (More Data), each with its own
* fields; the averages, the resistance, the metabolic equivalent and the
* remaining time are left aside.
*/
data class RowerData(
/** Strokes a minute, in halves. */
val strokeRate: Double?,
/** Strokes since the console started counting. */
val strokes: Int?,
/** Metres since the console started counting. */
val metres: Int?,
/** Seconds 500 m take at this moment. */
val paceSeconds: Int?,
val watts: Int?,
/** Kilocalories since the console started counting. */
val kcal: Int?,
/** The console's own heart rate; null for its 0, no receiver or no belt. */
val bpm: Int?,
val elapsedSeconds: Int?,
) {
companion object {
val SERVICE: UUID = UUID.fromString("00001826-0000-1000-8000-00805f9b34fb")
val CHARACTERISTIC: UUID = UUID.fromString("00002ad1-0000-1000-8000-00805f9b34fb")
/** The Fitness Machine Type's rower bit in the service's advertised data (FTMS 3.1.1). */
const val ROWER_TYPE = 0x10
private const val MORE_DATA = 1 shl 0
private const val AVERAGE_STROKE_RATE = 1 shl 1
private const val TOTAL_DISTANCE = 1 shl 2
private const val INSTANT_PACE = 1 shl 3
private const val AVERAGE_PACE = 1 shl 4
private const val INSTANT_POWER = 1 shl 5
private const val AVERAGE_POWER = 1 shl 6
private const val RESISTANCE = 1 shl 7
private const val ENERGY = 1 shl 8
private const val HEART_RATE = 1 shl 9
private const val METABOLIC = 1 shl 10
private const val ELAPSED = 1 shl 11
/** [value] read; null where it is cut short. */
fun of(value: ByteArray): RowerData? {
val reader = Reader(value)
val flags = reader.uint16() ?: return null
fun has(flag: Int) = flags and flag != 0
// Stroke rate and count are there where More Data is clear (4.8.1.2).
val strokeRate = if (!has(MORE_DATA)) (reader.uint8() ?: return null) / 2.0 else null
val strokes = if (!has(MORE_DATA)) reader.uint16() ?: return null else null
if (has(AVERAGE_STROKE_RATE)) reader.skip(1) ?: return null
val metres = if (has(TOTAL_DISTANCE)) reader.uint24() ?: return null else null
val pace = if (has(INSTANT_PACE)) reader.uint16() ?: return null else null
if (has(AVERAGE_PACE)) reader.skip(2) ?: return null
val watts = if (has(INSTANT_POWER)) reader.sint16() ?: return null else null
if (has(AVERAGE_POWER)) reader.skip(2) ?: return null
if (has(RESISTANCE)) reader.skip(2) ?: return null
// Total energy, then the energy per hour and per minute.
val kcal = if (has(ENERGY)) (reader.uint16() ?: return null).also { reader.skip(3) ?: return null } else null
val bpm = if (has(HEART_RATE)) reader.uint8() ?: return null else null
if (has(METABOLIC)) reader.skip(1) ?: return null
val elapsed = if (has(ELAPSED)) reader.uint16() ?: return null else null
return RowerData(
strokeRate = strokeRate,
strokes = strokes,
metres = metres,
paceSeconds = pace,
watts = watts,
// 0xFFFF: not available.
kcal = kcal?.takeIf { it != 0xFFFF },
bpm = bpm?.takeIf { it > 0 },
elapsedSeconds = elapsed,
)
}
}
/** Reads little-endian fields off [bytes], null once they run out. */
private class Reader(private val bytes: ByteArray) {
private var at = 0
private fun byte(i: Int) = bytes[at + i].toInt() and 0xFF
private inline fun <T> take(size: Int, read: () -> T): T? {
if (at + size > bytes.size) return null
return read().also { at += size }
}
fun skip(size: Int): Unit? = take(size) {}
fun uint8(): Int? = take(1) { byte(0) }
fun uint16(): Int? = take(2) { byte(0) or (byte(1) shl 8) }
fun sint16(): Int? = take(2) { (byte(0) or (byte(1) shl 8)).toShort().toInt() }
fun uint24(): Int? = take(3) { byte(0) or (byte(1) shl 8) or (byte(2) shl 16) }
}
}

View file

@ -84,4 +84,19 @@ class AutoPauseTest {
assertNull(pause.tick(16_000))
assertEquals(true, pause.tick(17_000))
}
@Test
fun `a row pauses 5 s after its last stroke, and goes on with the next`() {
val pause = AutoPause(AutoPause.Signal.STROKES)
val changes = mutableMapOf<Int, Boolean>()
for (second in 0..20) {
val at = second * 1000L
// Strokes every 2 s up to 6, then none until 15.
if (second <= 6 && second % 2 == 0 || second == 15) pause.stroke(at)?.let { changes[second] = it }
pause.tick(at)?.let { changes[second] = it }
}
// The one stroke at 15 is followed by none again: paused at 20.
assertEquals(mapOf(11 to true, 15 to false, 20 to true), changes)
}
}

View file

@ -0,0 +1,83 @@
package app.halkyon.train
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
// Rower Data (0x2AD1, Bluetooth SIG's Fitness Machine Service 1.0, 4.8), as a
// rower notifies it (ADR 0030, D8, D28): 16 bits of flags, then each field the
// flags name, in the specification's order, little-endian.
class RowerDataTest {
private fun frame(vararg bytes: Int) = ByteArray(bytes.size) { bytes[it].toByte() }
@Test
fun `the WP 5000's frame as nRF Connect showed it (D8)`() {
// Stroke rate and count (More Data clear), total distance, pace, power,
// resistance, energy, heart rate, elapsed time.
val data =
RowerData.of(
frame(
0xAC, 0x0B,
50, 0x06, 0x00,
0x12, 0x00, 0x00,
0xB7, 0x01,
0x64, 0x00,
0x01, 0x00,
0x01, 0x00, 0xFF, 0xFF, 0xFF,
0x00,
0x11, 0x00,
),
)!!
assertEquals(25.0, data.strokeRate!!, 0.0)
assertEquals(6, data.strokes)
assertEquals(18, data.metres)
assertEquals(439, data.paceSeconds)
assertEquals(100, data.watts)
assertEquals(1, data.kcal)
// 0 is no heart rate: no strap on the console.
assertNull(data.bpm)
assertEquals(17, data.elapsedSeconds)
}
@Test
fun `More Data set leaves the stroke out, the rest read on`() {
// A frame split in two (4.8.1.2): the second carries no stroke, only the
// distance and the power here.
val data = RowerData.of(frame(0x25, 0x00, 0x2C, 0x01, 0x00, 0x96, 0x00))!!
assertNull(data.strokeRate)
assertNull(data.strokes)
assertEquals(300, data.metres)
assertEquals(150, data.watts)
}
@Test
fun `the averages are skipped over, and a stroke rate in halves`() {
// Stroke (51 = 25.5 a minute), average stroke rate, average pace,
// average power, heart rate.
val data =
RowerData.of(
frame(0x52, 0x02, 51, 0x10, 0x00, 48, 0x00, 0x02, 0x50, 0x00, 132),
)!!
assertEquals(25.5, data.strokeRate!!, 0.0)
assertEquals(16, data.strokes)
assertNull(data.watts)
assertEquals(132, data.bpm)
}
@Test
fun `energy not available reads as none`() {
val data = RowerData.of(frame(0x01, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF))!!
assertNull(data.kcal)
}
@Test
fun `a frame cut short reads as nothing`() {
assertNull(RowerData.of(frame()))
assertNull(RowerData.of(frame(0x00, 0x00, 50)))
assertNull(RowerData.of(frame(0x05, 0x00, 0x12, 0x00)))
}
}