feat(train): a ride reads the bike's speed and cadence sensor and power meter (ADR 0030, D49)
11d, its sensors: a ride's Record sheet offers the CSC sensor (0x1816) and the power meter (0x1818), found nearby and remembered as the strap is; the person's devices are now one map of DeviceKind to DeviceStore. The service parses CSC and Cycling Power measurements (JVM-tested, counters' rollover, coasting), writes the wheel's revolutions, the crank's cadence and the watts to the file and to series of their own. The recording screen shows cadence and watts; the page the power's chart, average, highest and normalised power and the cadence's chart and average; GPX, TCX and FIT carry them. A plan's watts target reads the power meter too. The wheel's speed and distance follow. Not tried with a real sensor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
57cbd97fd1
commit
5c13e6f3c5
5 changed files with 1209 additions and 62 deletions
|
|
@ -0,0 +1,112 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/** A CSC Measurement's fields; each null where the flags leave it out. */
|
||||
data class Csc(val wheelRevolutions: Long?, val wheelTime: Int?, val crankRevolutions: Int?, val crankTime: Int?)
|
||||
|
||||
/**
|
||||
* The Cycling Speed and Cadence Service's measurement (Bluetooth SIG's CSCS 1.0,
|
||||
* characteristic 0x2A5B), as a bike's speed and cadence sensor notifies it (ADR 0030, D49):
|
||||
* the wheel's cumulative revolutions (32 bits) and the last event's time, the crank's (16 bits)
|
||||
* and its time, in 1/1024 s.
|
||||
*/
|
||||
object CscMeasurement {
|
||||
val SERVICE: UUID = UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
|
||||
val CHARACTERISTIC: UUID = UUID.fromString("00002a5b-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
fun of(value: ByteArray): Csc? {
|
||||
if (value.isEmpty()) return null
|
||||
val flags = value[0].toInt() and 0xFF
|
||||
val wheel = flags and 0x01 != 0
|
||||
val crank = flags and 0x02 != 0
|
||||
if (value.size < 1 + (if (wheel) 6 else 0) + (if (crank) 4 else 0)) return null
|
||||
var at = 1
|
||||
var wheelRevolutions: Long? = null
|
||||
var wheelTime: Int? = null
|
||||
if (wheel) {
|
||||
wheelRevolutions = value.uint32(at)
|
||||
wheelTime = value.uint16(at + 4)
|
||||
at += 6
|
||||
}
|
||||
return Csc(wheelRevolutions, wheelTime, if (crank) value.uint16(at) else null, if (crank) value.uint16(at + 2) else null)
|
||||
}
|
||||
}
|
||||
|
||||
/** A Cycling Power Measurement's watts and its crank's revolutions and time, where it has them. */
|
||||
data class CyclingPower(val watts: Int, val crankRevolutions: Int?, val crankTime: Int?)
|
||||
|
||||
/**
|
||||
* The Cycling Power Service's measurement (Bluetooth SIG's CPS 1.1, characteristic 0x2A63), as a
|
||||
* power meter notifies it (D49): its flags (16 bits), the instantaneous power (signed), then the
|
||||
* pedal power balance, the accumulated torque and the wheel's revolutions where the flags say,
|
||||
* which come before the crank's revolutions and time (1/1024 s); the rest is left aside.
|
||||
*/
|
||||
object PowerMeasurement {
|
||||
val SERVICE: UUID = UUID.fromString("00001818-0000-1000-8000-00805f9b34fb")
|
||||
val CHARACTERISTIC: UUID = UUID.fromString("00002a63-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
fun of(value: ByteArray): CyclingPower? {
|
||||
if (value.size < 4) return null
|
||||
val flags = value.uint16(0)
|
||||
val watts = value.uint16(2).toShort().toInt()
|
||||
var at = 4
|
||||
if (flags and 0x01 != 0) at += 1
|
||||
if (flags and 0x04 != 0) at += 2
|
||||
if (flags and 0x10 != 0) at += 6
|
||||
if (flags and 0x20 == 0 || value.size < at + 4) return CyclingPower(watts, null, null)
|
||||
return CyclingPower(watts, value.uint16(at), value.uint16(at + 2))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The crank's cadence, in turns a minute, from its cumulative revolutions and last event's time
|
||||
* (16 bits each, 1/1024 s) as a sensor or a power meter sends them: one new for each new event,
|
||||
* 0 once no new revolution came for [coastMs], none otherwise.
|
||||
*/
|
||||
class CrankCadence(private val coastMs: Long = 3_000) {
|
||||
private var last: Pair<Int, Int>? = null
|
||||
private var lastNewAt = 0L
|
||||
private var coasting = false
|
||||
|
||||
fun add(at: Long, revolutions: Int, time: Int): Int? {
|
||||
val before = last
|
||||
last = revolutions to time
|
||||
if (before == null) {
|
||||
lastNewAt = at
|
||||
return null
|
||||
}
|
||||
val turns = (revolutions - before.first) and 0xFFFF
|
||||
val ticks = (time - before.second) and 0xFFFF
|
||||
if (turns == 0 || ticks == 0) {
|
||||
if (!coasting && at - lastNewAt >= coastMs) {
|
||||
coasting = true
|
||||
return 0
|
||||
}
|
||||
return null
|
||||
}
|
||||
lastNewAt = at
|
||||
coasting = false
|
||||
return (turns * 60 * 1024.0 / ticks).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
/** The wheel's revolutions counted from the first frame on (32 bits as sent); none until they grew. */
|
||||
class WheelCount {
|
||||
private var last: Long? = null
|
||||
private var total = 0L
|
||||
|
||||
fun add(revolutions: Long): Long? {
|
||||
val before = last
|
||||
last = revolutions
|
||||
if (before == null) return null
|
||||
val grown = (revolutions - before) and 0xFFFF_FFFFL
|
||||
if (grown == 0L) return null
|
||||
total += grown
|
||||
return total
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.uint16(at: Int): Int = (this[at].toInt() and 0xFF) or ((this[at + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun ByteArray.uint32(at: Int): Long = uint16(at).toLong() or (uint16(at + 2).toLong() shl 16)
|
||||
|
|
@ -86,6 +86,8 @@ class MainActivity : FlutterActivity() {
|
|||
File(noBackupFilesDir, "music.json").apply { writeText(music) }.path
|
||||
},
|
||||
announce = call.argument<String>("announce"),
|
||||
speedSensor = call.argument<String>("speedSensor"),
|
||||
powerMeter = call.argument<String>("powerMeter"),
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
|
|
@ -104,6 +106,10 @@ class MainActivity : FlutterActivity() {
|
|||
.setStreamHandler(DeviceScanner(this, HeartRateMeasurement.SERVICE))
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/rowers")
|
||||
.setStreamHandler(DeviceScanner(this, RowerData.SERVICE, ::mayBeRower))
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/speedSensors")
|
||||
.setStreamHandler(DeviceScanner(this, CscMeasurement.SERVICE))
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/powerMeters")
|
||||
.setStreamHandler(DeviceScanner(this, PowerMeasurement.SERVICE))
|
||||
MethodChannel(messenger, "app.halkyon.train/health").setMethodCallHandler(health)
|
||||
EventChannel(messenger, "app.halkyon.train/health/rationale").setStreamHandler(health.rationaleRequests)
|
||||
MethodChannel(messenger, "app.halkyon.train/loop").setMethodCallHandler(loop)
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Lon
|
|||
private val recent = ArrayDeque<At>()
|
||||
private var heart: Pair<Long, Int>? = null
|
||||
private var rowing: Triple<Long, Double?, Int?>? = null
|
||||
private var power: Pair<Long, Int>? = null
|
||||
|
||||
/** How far it went so far. */
|
||||
val metres: Double
|
||||
|
|
@ -165,6 +166,11 @@ class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Lon
|
|||
rowing = Triple(at, strokeRate, watts)
|
||||
}
|
||||
|
||||
/** The bike's power meter's watts (D49), which a watts target reads as it reads the rower's. */
|
||||
fun powered(at: Long, watts: Int) {
|
||||
power = at to watts
|
||||
}
|
||||
|
||||
fun heart(at: Long, bpm: Int) {
|
||||
heart = at to bpm
|
||||
}
|
||||
|
|
@ -174,7 +180,9 @@ class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Lon
|
|||
when (kind) {
|
||||
"heartRate" -> heart?.takeIf { now - it.first <= freshMs }?.second?.toDouble()
|
||||
"strokeRate" -> rowing?.takeIf { now - it.first <= freshMs }?.second
|
||||
"watts" -> rowing?.takeIf { now - it.first <= freshMs }?.third?.toDouble()
|
||||
"watts" ->
|
||||
power?.takeIf { now - it.first <= freshMs }?.second?.toDouble()
|
||||
?: rowing?.takeIf { now - it.first <= freshMs }?.third?.toDouble()
|
||||
else -> {
|
||||
val first = recent.firstOrNull()
|
||||
val last = recent.lastOrNull()
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private const val EXTRA_ROUTE = "route"
|
||||
private const val EXTRA_MUSIC = "music"
|
||||
private const val EXTRA_ANNOUNCE = "announce"
|
||||
private const val EXTRA_SPEED_SENSOR = "speedSensor"
|
||||
private const val EXTRA_POWER_METER = "powerMeter"
|
||||
private const val CHANNEL = "recording"
|
||||
private const val NOTIFICATION_ID = 5301
|
||||
private const val FLUSH_EVERY_MS = 5_000L
|
||||
|
|
@ -84,7 +86,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
* [notice] says, running [plan] (JSON) and following the route in the
|
||||
* file at [route] (D45; null: none of either), playing the music the
|
||||
* file at [music] says (D47, D48; null: none), announcing each distance
|
||||
* passed in the words of [announce] (JSON, D49; null: never).
|
||||
* passed in the words of [announce] (JSON, D49; null: never), with the
|
||||
* bike's speed and cadence sensor at [speedSensor] and its power meter at
|
||||
* [powerMeter] (D49; null: none).
|
||||
*/
|
||||
fun start(
|
||||
context: Context,
|
||||
|
|
@ -99,6 +103,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
route: String?,
|
||||
music: String?,
|
||||
announce: String?,
|
||||
speedSensor: String?,
|
||||
powerMeter: String?,
|
||||
) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
|
|
@ -114,7 +120,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
.putExtra(EXTRA_PLAN, plan)
|
||||
.putExtra(EXTRA_ROUTE, route)
|
||||
.putExtra(EXTRA_MUSIC, music)
|
||||
.putExtra(EXTRA_ANNOUNCE, announce),
|
||||
.putExtra(EXTRA_ANNOUNCE, announce)
|
||||
.putExtra(EXTRA_SPEED_SENSOR, speedSensor)
|
||||
.putExtra(EXTRA_POWER_METER, powerMeter),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -150,6 +158,14 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private var strap: NotifyLink? = null
|
||||
private var rower: NotifyLink? = null
|
||||
|
||||
/** The bike's sensors (D49): the wheel counted, the crank's cadence, and whether the sensor gives it. */
|
||||
private var speedSensor: NotifyLink? = null
|
||||
private var powerMeter: NotifyLink? = null
|
||||
private val wheel = WheelCount()
|
||||
private val crank = CrankCadence()
|
||||
private val powerCrank = CrankCadence()
|
||||
private var sensorCranks = false
|
||||
|
||||
/** The Loop read for its heart rate (D41), and whether the strap sends meanwhile. */
|
||||
private var loopId: String? = null
|
||||
private var loopReading: Job? = null
|
||||
|
|
@ -244,6 +260,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
val strapAt = intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }
|
||||
val rowerAt = intent.getStringExtra(EXTRA_ROWER)?.takeIf { connects }
|
||||
val loopAt = intent.getStringExtra(EXTRA_LOOP)?.takeIf { connects }
|
||||
val speedSensorAt = intent.getStringExtra(EXTRA_SPEED_SENSOR)?.takeIf { connects }
|
||||
val powerMeterAt = intent.getStringExtra(EXTRA_POWER_METER)?.takeIf { connects }
|
||||
val autoPauseBy = intent.getStringExtra(EXTRA_AUTO_PAUSE)
|
||||
// Steps on foot whatever pauses it: they are the workout's cadence and
|
||||
// what its music follows (D48).
|
||||
|
|
@ -256,7 +274,11 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
this,
|
||||
NOTIFICATION_ID,
|
||||
notification(),
|
||||
types(located, stepping, linked = strapAt != null || rowerAt != null || loopAt != null),
|
||||
types(
|
||||
located,
|
||||
stepping,
|
||||
linked = listOf(strapAt, rowerAt, loopAt, speedSensorAt, powerMeterAt).any { it != null },
|
||||
),
|
||||
)
|
||||
running = this
|
||||
val now = System.currentTimeMillis()
|
||||
|
|
@ -326,6 +348,30 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
onValue = { value -> RowerData.of(value)?.let(::rowed) },
|
||||
).also { it.open() }
|
||||
}
|
||||
speedSensorAt?.let { address ->
|
||||
speedSensor =
|
||||
NotifyLink(
|
||||
this,
|
||||
address,
|
||||
CscMeasurement.SERVICE,
|
||||
CscMeasurement.CHARACTERISTIC,
|
||||
handler,
|
||||
onState = { state -> write("csc ${System.currentTimeMillis()} $state") },
|
||||
onValue = { value -> CscMeasurement.of(value)?.let(::sensed) },
|
||||
).also { it.open() }
|
||||
}
|
||||
powerMeterAt?.let { address ->
|
||||
powerMeter =
|
||||
NotifyLink(
|
||||
this,
|
||||
address,
|
||||
PowerMeasurement.SERVICE,
|
||||
PowerMeasurement.CHARACTERISTIC,
|
||||
handler,
|
||||
onState = { state -> write("power ${System.currentTimeMillis()} $state") },
|
||||
onValue = { value -> PowerMeasurement.of(value)?.let(::powered) },
|
||||
).also { it.open() }
|
||||
}
|
||||
loopAt?.let(::readLoop)
|
||||
intent.getStringExtra(EXTRA_PLAN)?.let { runPlan(JSONObject(it), now) }
|
||||
intent.getStringExtra(EXTRA_ROUTE)?.let { at ->
|
||||
|
|
@ -569,6 +615,29 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
measures.rowed(at, metres.total, data.strokeRate, data.watts)
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame of the speed and cadence sensor's (D49): the wheel's revolutions since its first,
|
||||
* as they grow, and the crank's cadence as each new turn says it.
|
||||
*/
|
||||
private fun sensed(csc: Csc) {
|
||||
val at = System.currentTimeMillis()
|
||||
csc.wheelRevolutions?.let { revolutions -> wheel.add(revolutions)?.let { write("w $at $it") } }
|
||||
if (csc.crankRevolutions != null && csc.crankTime != null) {
|
||||
sensorCranks = true
|
||||
crank.add(at, csc.crankRevolutions, csc.crankTime)?.let { write("c $at $it") }
|
||||
}
|
||||
}
|
||||
|
||||
/** A frame of the power meter's (D49): its watts, and its crank's cadence where no sensor gives one. */
|
||||
private fun powered(power: CyclingPower) {
|
||||
val at = System.currentTimeMillis()
|
||||
write("pw $at ${power.watts.coerceAtLeast(0)}")
|
||||
if (!sensorCranks && power.crankRevolutions != null && power.crankTime != null) {
|
||||
powerCrank.add(at, power.crankRevolutions, power.crankTime)?.let { write("c $at $it") }
|
||||
}
|
||||
measures.powered(at, power.watts)
|
||||
}
|
||||
|
||||
/** A console's running total, counted from the first reading. */
|
||||
private class Counted {
|
||||
private var last: Int? = null
|
||||
|
|
@ -660,6 +729,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
strap = null
|
||||
rower?.close()
|
||||
rower = null
|
||||
speedSensor?.close()
|
||||
speedSensor = null
|
||||
powerMeter?.close()
|
||||
powerMeter = null
|
||||
loopReading?.cancel()
|
||||
loopReading = null
|
||||
plan = null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
// The bike's sensors (ADR 0030, D49): CSC Measurement (0x2A5B, Bluetooth SIG's Cycling
|
||||
// Speed and Cadence Service 1.0) and Cycling Power Measurement (0x2A63, Cycling Power
|
||||
// Service 1.1), little-endian, their fields where the flags say.
|
||||
class CyclingSensorsTest {
|
||||
private fun frame(vararg bytes: Int) = ByteArray(bytes.size) { bytes[it].toByte() }
|
||||
|
||||
@Test
|
||||
fun `a CSC frame with the wheel and the crank`() {
|
||||
// Wheel: 0x00012345 revolutions at 0x0400/1024 s; crank: 0x0102 at 0x0800/1024 s.
|
||||
val csc = CscMeasurement.of(frame(0x03, 0x45, 0x23, 0x01, 0x00, 0x00, 0x04, 0x02, 0x01, 0x00, 0x08))!!
|
||||
assertEquals(0x12345L, csc.wheelRevolutions)
|
||||
assertEquals(0x0400, csc.wheelTime)
|
||||
assertEquals(0x0102, csc.crankRevolutions)
|
||||
assertEquals(0x0800, csc.crankTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a CSC frame with the crank alone, and one cut short`() {
|
||||
val csc = CscMeasurement.of(frame(0x02, 0x0A, 0x00, 0x00, 0x04))!!
|
||||
assertNull(csc.wheelRevolutions)
|
||||
assertEquals(10, csc.crankRevolutions)
|
||||
assertNull(CscMeasurement.of(frame(0x03, 0x45, 0x23)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a power frame, its watts signed, the crank after the fields before it`() {
|
||||
// Flags 0x0021: pedal power balance and crank data; 250 W, balance 50, crank 3 at 0x0C00.
|
||||
val power = PowerMeasurement.of(frame(0x21, 0x00, 0xFA, 0x00, 0x32, 0x03, 0x00, 0x00, 0x0C))!!
|
||||
assertEquals(250, power.watts)
|
||||
assertEquals(3, power.crankRevolutions)
|
||||
assertEquals(0x0C00, power.crankTime)
|
||||
// Accumulated torque and the wheel come before the crank: flags 0x0034.
|
||||
val full =
|
||||
PowerMeasurement.of(
|
||||
frame(0x34, 0x00, 0x2C, 0x01, 0x10, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x08, 0x05, 0x00, 0x00, 0x10),
|
||||
)!!
|
||||
assertEquals(300, full.watts)
|
||||
assertEquals(5, full.crankRevolutions)
|
||||
assertEquals(0x1000, full.crankTime)
|
||||
assertEquals(-5, PowerMeasurement.of(frame(0x00, 0x00, 0xFB, 0xFF))!!.watts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the crank's cadence from two events, over the rollover, nothing new for the same event`() {
|
||||
val crank = CrankCadence()
|
||||
assertNull(crank.add(at = 0, revolutions = 65_534, time = 65_000))
|
||||
// Two revolutions in 1024/1024 s, over both counters' rollover: 120 rpm.
|
||||
assertEquals(120, crank.add(at = 1_000, revolutions = 0, time = (65_000 + 1024) and 0xFFFF))
|
||||
assertNull(crank.add(at = 1_500, revolutions = 0, time = (65_000 + 1024) and 0xFFFF))
|
||||
// No new revolution for 3 s: coasting, 0 rpm.
|
||||
assertEquals(0, crank.add(at = 4_100, revolutions = 0, time = (65_000 + 1024) and 0xFFFF))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the wheel's revolutions counted from the first frame, over the rollover`() {
|
||||
val wheel = WheelCount()
|
||||
assertNull(wheel.add(0xFFFF_FFF0L))
|
||||
assertEquals(16L, wheel.add(0L))
|
||||
assertNull(wheel.add(0L))
|
||||
assertEquals(26L, wheel.add(10L))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue