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:
parent
de5fd43e06
commit
f3d08e4e69
9 changed files with 2266 additions and 514 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue