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
37 changed files with 1209 additions and 62 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
File(noBackupFilesDir, "music.json").apply { writeText(music) }.path
|
||||||
},
|
},
|
||||||
announce = call.argument<String>("announce"),
|
announce = call.argument<String>("announce"),
|
||||||
|
speedSensor = call.argument<String>("speedSensor"),
|
||||||
|
powerMeter = call.argument<String>("powerMeter"),
|
||||||
)
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +106,10 @@ class MainActivity : FlutterActivity() {
|
||||||
.setStreamHandler(DeviceScanner(this, HeartRateMeasurement.SERVICE))
|
.setStreamHandler(DeviceScanner(this, HeartRateMeasurement.SERVICE))
|
||||||
EventChannel(messenger, "app.halkyon.train/recorder/rowers")
|
EventChannel(messenger, "app.halkyon.train/recorder/rowers")
|
||||||
.setStreamHandler(DeviceScanner(this, RowerData.SERVICE, ::mayBeRower))
|
.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)
|
MethodChannel(messenger, "app.halkyon.train/health").setMethodCallHandler(health)
|
||||||
EventChannel(messenger, "app.halkyon.train/health/rationale").setStreamHandler(health.rationaleRequests)
|
EventChannel(messenger, "app.halkyon.train/health/rationale").setStreamHandler(health.rationaleRequests)
|
||||||
MethodChannel(messenger, "app.halkyon.train/loop").setMethodCallHandler(loop)
|
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 val recent = ArrayDeque<At>()
|
||||||
private var heart: Pair<Long, Int>? = null
|
private var heart: Pair<Long, Int>? = null
|
||||||
private var rowing: Triple<Long, Double?, Int?>? = null
|
private var rowing: Triple<Long, Double?, Int?>? = null
|
||||||
|
private var power: Pair<Long, Int>? = null
|
||||||
|
|
||||||
/** How far it went so far. */
|
/** How far it went so far. */
|
||||||
val metres: Double
|
val metres: Double
|
||||||
|
|
@ -165,6 +166,11 @@ class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Lon
|
||||||
rowing = Triple(at, strokeRate, watts)
|
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) {
|
fun heart(at: Long, bpm: Int) {
|
||||||
heart = at to bpm
|
heart = at to bpm
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +180,9 @@ class PlanMeasures(private val windowMs: Long = 30_000, private val freshMs: Lon
|
||||||
when (kind) {
|
when (kind) {
|
||||||
"heartRate" -> heart?.takeIf { now - it.first <= freshMs }?.second?.toDouble()
|
"heartRate" -> heart?.takeIf { now - it.first <= freshMs }?.second?.toDouble()
|
||||||
"strokeRate" -> rowing?.takeIf { now - it.first <= freshMs }?.second
|
"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 -> {
|
else -> {
|
||||||
val first = recent.firstOrNull()
|
val first = recent.firstOrNull()
|
||||||
val last = recent.lastOrNull()
|
val last = recent.lastOrNull()
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
private const val EXTRA_ROUTE = "route"
|
private const val EXTRA_ROUTE = "route"
|
||||||
private const val EXTRA_MUSIC = "music"
|
private const val EXTRA_MUSIC = "music"
|
||||||
private const val EXTRA_ANNOUNCE = "announce"
|
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 CHANNEL = "recording"
|
||||||
private const val NOTIFICATION_ID = 5301
|
private const val NOTIFICATION_ID = 5301
|
||||||
private const val FLUSH_EVERY_MS = 5_000L
|
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
|
* [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 [route] (D45; null: none of either), playing the music the
|
||||||
* file at [music] says (D47, D48; null: none), announcing each distance
|
* 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(
|
fun start(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -99,6 +103,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
route: String?,
|
route: String?,
|
||||||
music: String?,
|
music: String?,
|
||||||
announce: String?,
|
announce: String?,
|
||||||
|
speedSensor: String?,
|
||||||
|
powerMeter: String?,
|
||||||
) {
|
) {
|
||||||
ContextCompat.startForegroundService(
|
ContextCompat.startForegroundService(
|
||||||
context,
|
context,
|
||||||
|
|
@ -114,7 +120,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
.putExtra(EXTRA_PLAN, plan)
|
.putExtra(EXTRA_PLAN, plan)
|
||||||
.putExtra(EXTRA_ROUTE, route)
|
.putExtra(EXTRA_ROUTE, route)
|
||||||
.putExtra(EXTRA_MUSIC, music)
|
.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 strap: NotifyLink? = null
|
||||||
private var rower: 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. */
|
/** The Loop read for its heart rate (D41), and whether the strap sends meanwhile. */
|
||||||
private var loopId: String? = null
|
private var loopId: String? = null
|
||||||
private var loopReading: Job? = null
|
private var loopReading: Job? = null
|
||||||
|
|
@ -244,6 +260,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
val strapAt = intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }
|
val strapAt = intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }
|
||||||
val rowerAt = intent.getStringExtra(EXTRA_ROWER)?.takeIf { connects }
|
val rowerAt = intent.getStringExtra(EXTRA_ROWER)?.takeIf { connects }
|
||||||
val loopAt = intent.getStringExtra(EXTRA_LOOP)?.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)
|
val autoPauseBy = intent.getStringExtra(EXTRA_AUTO_PAUSE)
|
||||||
// Steps on foot whatever pauses it: they are the workout's cadence and
|
// Steps on foot whatever pauses it: they are the workout's cadence and
|
||||||
// what its music follows (D48).
|
// what its music follows (D48).
|
||||||
|
|
@ -256,7 +274,11 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
this,
|
this,
|
||||||
NOTIFICATION_ID,
|
NOTIFICATION_ID,
|
||||||
notification(),
|
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
|
running = this
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|
@ -326,6 +348,30 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
onValue = { value -> RowerData.of(value)?.let(::rowed) },
|
onValue = { value -> RowerData.of(value)?.let(::rowed) },
|
||||||
).also { it.open() }
|
).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)
|
loopAt?.let(::readLoop)
|
||||||
intent.getStringExtra(EXTRA_PLAN)?.let { runPlan(JSONObject(it), now) }
|
intent.getStringExtra(EXTRA_PLAN)?.let { runPlan(JSONObject(it), now) }
|
||||||
intent.getStringExtra(EXTRA_ROUTE)?.let { at ->
|
intent.getStringExtra(EXTRA_ROUTE)?.let { at ->
|
||||||
|
|
@ -569,6 +615,29 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
measures.rowed(at, metres.total, data.strokeRate, data.watts)
|
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. */
|
/** A console's running total, counted from the first reading. */
|
||||||
private class Counted {
|
private class Counted {
|
||||||
private var last: Int? = null
|
private var last: Int? = null
|
||||||
|
|
@ -660,6 +729,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
strap = null
|
strap = null
|
||||||
rower?.close()
|
rower?.close()
|
||||||
rower = null
|
rower = null
|
||||||
|
speedSensor?.close()
|
||||||
|
speedSensor = null
|
||||||
|
powerMeter?.close()
|
||||||
|
powerMeter = null
|
||||||
loopReading?.cancel()
|
loopReading?.cancel()
|
||||||
loopReading = null
|
loopReading = null
|
||||||
plan = 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -519,5 +519,26 @@
|
||||||
"workoutExportTitle": "Training exportieren",
|
"workoutExportTitle": "Training exportieren",
|
||||||
"workoutExportGpxHint": "Die Strecke, für Karten und andere Apps",
|
"workoutExportGpxHint": "Die Strecke, für Karten und andere Apps",
|
||||||
"workoutExportTcxHint": "Runden, Puls und Kadenz, für die meisten Trainingsseiten",
|
"workoutExportTcxHint": "Runden, Puls und Kadenz, für die meisten Trainingsseiten",
|
||||||
"workoutExportFitHint": "Was Strava, Garmin und intervals.icu am besten lesen"
|
"workoutExportFitHint": "Was Strava, Garmin und intervals.icu am besten lesen",
|
||||||
|
"recordSpeedSensor": "Geschwindigkeits- und Trittfrequenzsensor",
|
||||||
|
"recordNoSpeedSensor": "Keiner gewählt",
|
||||||
|
"recordPowerMeter": "Leistungsmesser",
|
||||||
|
"recordNoPowerMeter": "Keiner gewählt",
|
||||||
|
"speedSensorSheetTitle": "Geschwindigkeits- und Trittfrequenzsensor",
|
||||||
|
"speedSensorSearching": "Suche Sensoren in der Nähe …",
|
||||||
|
"speedSensorSearchingHint": "Dreh das Rad oder die Kurbel, um ihn zu wecken. Einer, den eine andere App hält, ist nicht zu finden: Schließ diese App zuerst.",
|
||||||
|
"speedSensorUnnamed": "Sensor ohne Namen",
|
||||||
|
"speedSensorForget": "Sensor vergessen",
|
||||||
|
"speedSensorRefused": "Train braucht Geräte in der Nähe, um deinen Sensor zu finden. Du kannst das in den Systemeinstellungen erlauben.",
|
||||||
|
"powerMeterSheetTitle": "Leistungsmesser",
|
||||||
|
"powerMeterSearching": "Suche Leistungsmesser in der Nähe …",
|
||||||
|
"powerMeterSearchingHint": "Dreh die Kurbel, um ihn zu wecken. Einer, den eine andere App hält, ist nicht zu finden: Schließ diese App zuerst.",
|
||||||
|
"powerMeterUnnamed": "Leistungsmesser ohne Namen",
|
||||||
|
"powerMeterForget": "Leistungsmesser vergessen",
|
||||||
|
"powerMeterRefused": "Train braucht Geräte in der Nähe, um deinen Leistungsmesser zu finden. Du kannst das in den Systemeinstellungen erlauben.",
|
||||||
|
"recordingCadence": "Umdrehungen pro Minute",
|
||||||
|
"workoutPedalling": "Trittfrequenz",
|
||||||
|
"workoutAverageCadence": "durchschnittliche Trittfrequenz",
|
||||||
|
"workoutNormalisedPower": "normalisierte Leistung",
|
||||||
|
"workoutRpmValue": "{rpm} U/min"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -658,5 +658,27 @@
|
||||||
"workoutExportTitle": "Export the workout",
|
"workoutExportTitle": "Export the workout",
|
||||||
"workoutExportGpxHint": "The track, for maps and other apps",
|
"workoutExportGpxHint": "The track, for maps and other apps",
|
||||||
"workoutExportTcxHint": "Laps, heart rate and cadence, for most training sites",
|
"workoutExportTcxHint": "Laps, heart rate and cadence, for most training sites",
|
||||||
"workoutExportFitHint": "What Strava, Garmin and intervals.icu read best"
|
"workoutExportFitHint": "What Strava, Garmin and intervals.icu read best",
|
||||||
|
"recordSpeedSensor": "Speed and cadence sensor",
|
||||||
|
"recordNoSpeedSensor": "None chosen",
|
||||||
|
"recordPowerMeter": "Power meter",
|
||||||
|
"recordNoPowerMeter": "None chosen",
|
||||||
|
"speedSensorSheetTitle": "Speed and cadence sensor",
|
||||||
|
"speedSensorSearching": "Looking for speed and cadence sensors nearby…",
|
||||||
|
"speedSensorSearchingHint": "Turn the wheel or the crank to wake it. One another app holds can't be found: close that app first.",
|
||||||
|
"speedSensorUnnamed": "Unnamed sensor",
|
||||||
|
"speedSensorForget": "Forget sensor",
|
||||||
|
"speedSensorRefused": "Train needs nearby devices to find your sensor. You can allow it in the system's settings.",
|
||||||
|
"powerMeterSheetTitle": "Power meter",
|
||||||
|
"powerMeterSearching": "Looking for power meters nearby…",
|
||||||
|
"powerMeterSearchingHint": "Turn the crank to wake it. One another app holds can't be found: close that app first.",
|
||||||
|
"powerMeterUnnamed": "Unnamed power meter",
|
||||||
|
"powerMeterForget": "Forget power meter",
|
||||||
|
"powerMeterRefused": "Train needs nearby devices to find your power meter. You can allow it in the system's settings.",
|
||||||
|
"recordingCadence": "turns a minute",
|
||||||
|
"workoutPedalling": "Cadence",
|
||||||
|
"workoutAverageCadence": "average cadence",
|
||||||
|
"workoutNormalisedPower": "normalised power",
|
||||||
|
"workoutRpmValue": "{rpm} rpm",
|
||||||
|
"@workoutRpmValue": {"placeholders": {"rpm": {"type": "int"}}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3141,6 +3141,132 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'What Strava, Garmin and intervals.icu read best'**
|
/// **'What Strava, Garmin and intervals.icu read best'**
|
||||||
String get workoutExportFitHint;
|
String get workoutExportFitHint;
|
||||||
|
|
||||||
|
/// No description provided for @recordSpeedSensor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Speed and cadence sensor'**
|
||||||
|
String get recordSpeedSensor;
|
||||||
|
|
||||||
|
/// No description provided for @recordNoSpeedSensor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'None chosen'**
|
||||||
|
String get recordNoSpeedSensor;
|
||||||
|
|
||||||
|
/// No description provided for @recordPowerMeter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Power meter'**
|
||||||
|
String get recordPowerMeter;
|
||||||
|
|
||||||
|
/// No description provided for @recordNoPowerMeter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'None chosen'**
|
||||||
|
String get recordNoPowerMeter;
|
||||||
|
|
||||||
|
/// No description provided for @speedSensorSheetTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Speed and cadence sensor'**
|
||||||
|
String get speedSensorSheetTitle;
|
||||||
|
|
||||||
|
/// No description provided for @speedSensorSearching.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Looking for speed and cadence sensors nearby…'**
|
||||||
|
String get speedSensorSearching;
|
||||||
|
|
||||||
|
/// No description provided for @speedSensorSearchingHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Turn the wheel or the crank to wake it. One another app holds can\'t be found: close that app first.'**
|
||||||
|
String get speedSensorSearchingHint;
|
||||||
|
|
||||||
|
/// No description provided for @speedSensorUnnamed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unnamed sensor'**
|
||||||
|
String get speedSensorUnnamed;
|
||||||
|
|
||||||
|
/// No description provided for @speedSensorForget.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Forget sensor'**
|
||||||
|
String get speedSensorForget;
|
||||||
|
|
||||||
|
/// No description provided for @speedSensorRefused.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Train needs nearby devices to find your sensor. You can allow it in the system\'s settings.'**
|
||||||
|
String get speedSensorRefused;
|
||||||
|
|
||||||
|
/// No description provided for @powerMeterSheetTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Power meter'**
|
||||||
|
String get powerMeterSheetTitle;
|
||||||
|
|
||||||
|
/// No description provided for @powerMeterSearching.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Looking for power meters nearby…'**
|
||||||
|
String get powerMeterSearching;
|
||||||
|
|
||||||
|
/// No description provided for @powerMeterSearchingHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Turn the crank to wake it. One another app holds can\'t be found: close that app first.'**
|
||||||
|
String get powerMeterSearchingHint;
|
||||||
|
|
||||||
|
/// No description provided for @powerMeterUnnamed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unnamed power meter'**
|
||||||
|
String get powerMeterUnnamed;
|
||||||
|
|
||||||
|
/// No description provided for @powerMeterForget.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Forget power meter'**
|
||||||
|
String get powerMeterForget;
|
||||||
|
|
||||||
|
/// No description provided for @powerMeterRefused.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Train needs nearby devices to find your power meter. You can allow it in the system\'s settings.'**
|
||||||
|
String get powerMeterRefused;
|
||||||
|
|
||||||
|
/// No description provided for @recordingCadence.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'turns a minute'**
|
||||||
|
String get recordingCadence;
|
||||||
|
|
||||||
|
/// No description provided for @workoutPedalling.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cadence'**
|
||||||
|
String get workoutPedalling;
|
||||||
|
|
||||||
|
/// No description provided for @workoutAverageCadence.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'average cadence'**
|
||||||
|
String get workoutAverageCadence;
|
||||||
|
|
||||||
|
/// No description provided for @workoutNormalisedPower.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'normalised power'**
|
||||||
|
String get workoutNormalisedPower;
|
||||||
|
|
||||||
|
/// No description provided for @workoutRpmValue.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{rpm} rpm'**
|
||||||
|
String workoutRpmValue(int rpm);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -1889,4 +1889,74 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get workoutExportFitHint =>
|
String get workoutExportFitHint =>
|
||||||
'Was Strava, Garmin und intervals.icu am besten lesen';
|
'Was Strava, Garmin und intervals.icu am besten lesen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordSpeedSensor => 'Geschwindigkeits- und Trittfrequenzsensor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordNoSpeedSensor => 'Keiner gewählt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordPowerMeter => 'Leistungsmesser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordNoPowerMeter => 'Keiner gewählt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorSheetTitle =>
|
||||||
|
'Geschwindigkeits- und Trittfrequenzsensor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorSearching => 'Suche Sensoren in der Nähe …';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorSearchingHint =>
|
||||||
|
'Dreh das Rad oder die Kurbel, um ihn zu wecken. Einer, den eine andere App hält, ist nicht zu finden: Schließ diese App zuerst.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorUnnamed => 'Sensor ohne Namen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorForget => 'Sensor vergessen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorRefused =>
|
||||||
|
'Train braucht Geräte in der Nähe, um deinen Sensor zu finden. Du kannst das in den Systemeinstellungen erlauben.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterSheetTitle => 'Leistungsmesser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterSearching => 'Suche Leistungsmesser in der Nähe …';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterSearchingHint =>
|
||||||
|
'Dreh die Kurbel, um ihn zu wecken. Einer, den eine andere App hält, ist nicht zu finden: Schließ diese App zuerst.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterUnnamed => 'Leistungsmesser ohne Namen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterForget => 'Leistungsmesser vergessen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterRefused =>
|
||||||
|
'Train braucht Geräte in der Nähe, um deinen Leistungsmesser zu finden. Du kannst das in den Systemeinstellungen erlauben.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingCadence => 'Umdrehungen pro Minute';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workoutPedalling => 'Trittfrequenz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workoutAverageCadence => 'durchschnittliche Trittfrequenz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workoutNormalisedPower => 'normalisierte Leistung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String workoutRpmValue(int rpm) {
|
||||||
|
return '$rpm U/min';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1877,6 +1877,76 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get workoutExportFitHint =>
|
String get workoutExportFitHint =>
|
||||||
'What Strava, Garmin and intervals.icu read best';
|
'What Strava, Garmin and intervals.icu read best';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordSpeedSensor => 'Speed and cadence sensor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordNoSpeedSensor => 'None chosen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordPowerMeter => 'Power meter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordNoPowerMeter => 'None chosen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorSheetTitle => 'Speed and cadence sensor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorSearching =>
|
||||||
|
'Looking for speed and cadence sensors nearby…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorSearchingHint =>
|
||||||
|
'Turn the wheel or the crank to wake it. One another app holds can\'t be found: close that app first.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorUnnamed => 'Unnamed sensor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorForget => 'Forget sensor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get speedSensorRefused =>
|
||||||
|
'Train needs nearby devices to find your sensor. You can allow it in the system\'s settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterSheetTitle => 'Power meter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterSearching => 'Looking for power meters nearby…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterSearchingHint =>
|
||||||
|
'Turn the crank to wake it. One another app holds can\'t be found: close that app first.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterUnnamed => 'Unnamed power meter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterForget => 'Forget power meter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get powerMeterRefused =>
|
||||||
|
'Train needs nearby devices to find your power meter. You can allow it in the system\'s settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingCadence => 'turns a minute';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workoutPedalling => 'Cadence';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workoutAverageCadence => 'average cadence';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workoutNormalisedPower => 'normalised power';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String workoutRpmValue(int rpm) {
|
||||||
|
return '$rpm rpm';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -143,8 +143,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
|
||||||
recording.platform,
|
recording.platform,
|
||||||
train.workouts,
|
train.workouts,
|
||||||
recording.file,
|
recording.file,
|
||||||
recording.straps,
|
recording.devices,
|
||||||
recording.rowers,
|
|
||||||
notice: (live) => noticeOf(
|
notice: (live) => noticeOf(
|
||||||
AppLocalizations.of(context),
|
AppLocalizations.of(context),
|
||||||
Localizations.localeOf(context).toString(),
|
Localizations.localeOf(context).toString(),
|
||||||
|
|
|
||||||
|
|
@ -46,13 +46,13 @@ import 'package:serverpod_flutter/serverpod_flutter.dart';
|
||||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
||||||
/// Where a device records workouts (ADR 0030, D13, D18, D27, D28): its
|
/// Where a device records workouts (ADR 0030, D13, D18, D27, D28): its
|
||||||
/// recorder, and the person's recording file, strap and rower in their
|
/// recorder, and the person's recording file, their devices (strap, rower,
|
||||||
/// folder, and the activities they have announced (D49).
|
/// the bike's sensors, D49) in their folder, and the activities they have
|
||||||
|
/// announced (D49).
|
||||||
typedef RecordingDevice = ({
|
typedef RecordingDevice = ({
|
||||||
RecorderPlatform platform,
|
RecorderPlatform platform,
|
||||||
File file,
|
File file,
|
||||||
DeviceStore straps,
|
Map<DeviceKind, DeviceStore> devices,
|
||||||
DeviceStore rowers,
|
|
||||||
AnnounceStore announces,
|
AnnounceStore announces,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -452,8 +452,15 @@ Future<TrainPerson> _openPersonData(
|
||||||
? (
|
? (
|
||||||
platform: recorder,
|
platform: recorder,
|
||||||
file: File(p.join(folder.path, 'recording.txt')),
|
file: File(p.join(folder.path, 'recording.txt')),
|
||||||
straps: DeviceStore(File(p.join(folder.path, 'strap.json'))),
|
devices: {
|
||||||
rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))),
|
for (final (kind, name) in const [
|
||||||
|
(DeviceKind.strap, 'strap'),
|
||||||
|
(DeviceKind.rower, 'rower'),
|
||||||
|
(DeviceKind.speedSensor, 'speed_sensor'),
|
||||||
|
(DeviceKind.powerMeter, 'power_meter'),
|
||||||
|
])
|
||||||
|
kind: DeviceStore(File(p.join(folder.path, '$name.json'))),
|
||||||
|
},
|
||||||
announces: AnnounceStore(
|
announces: AnnounceStore(
|
||||||
File(p.join(folder.path, 'announce.json')),
|
File(p.join(folder.path, 'announce.json')),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
/// it away once stopped. As it opens, it finds a recording still running (the
|
/// it away once stopped. As it opens, it finds a recording still running (the
|
||||||
/// app was gone, the service not), one cut off, or one stopped and not saved.
|
/// app was gone, the service not), one cut off, or one stopped and not saved.
|
||||||
/// It remembers the person's heart-rate strap, which a recording uses unless
|
/// It remembers the person's heart-rate strap, which a recording uses unless
|
||||||
/// it is switched off for it (D27), and their rower, which a row needs
|
/// it is switched off for it (D27), the bike's sensors a ride uses (D49),
|
||||||
|
/// and their rower, which a row needs
|
||||||
/// (D28); their Loop, where they have one, gives the heart rate while no
|
/// (D28); their Loop, where they have one, gives the heart rate while no
|
||||||
/// strap does (D41). A recording may run an interval plan (D42), which the
|
/// strap does (D41). A recording may run an interval plan (D42), which the
|
||||||
/// recorder runs, its cues given there, and follow a route (D45), which the
|
/// recorder runs, its cues given there, and follow a route (D45), which the
|
||||||
|
|
@ -40,9 +41,9 @@ class RecordingBloc(
|
||||||
/// The person's recording file, in their folder.
|
/// The person's recording file, in their folder.
|
||||||
final File _file,
|
final File _file,
|
||||||
|
|
||||||
/// The person's strap and rower on this device, in their folder.
|
/// The person's devices on this device, each kind in a file of their
|
||||||
final DeviceStore _straps,
|
/// folder: the strap, the rower, the bike's sensors (D49).
|
||||||
final DeviceStore _rowers, {
|
final Map<DeviceKind, DeviceStore> _devices, {
|
||||||
|
|
||||||
/// What the notification shows of [LiveRecording], in the app's language.
|
/// What the notification shows of [LiveRecording], in the app's language.
|
||||||
required final RecorderNotice Function(LiveRecording live) notice,
|
required final RecorderNotice Function(LiveRecording live) notice,
|
||||||
|
|
@ -120,26 +121,23 @@ class RecordingBloc(
|
||||||
/// What the notification shows now, so it is told only of changes.
|
/// What the notification shows now, so it is told only of changes.
|
||||||
(String, String, bool)? _shown;
|
(String, String, bool)? _shown;
|
||||||
|
|
||||||
DeviceStore _storeOf(DeviceKind kind) => switch (kind) {
|
DeviceStore _storeOf(DeviceKind kind) => _devices[kind]!;
|
||||||
DeviceKind.strap => _straps,
|
|
||||||
DeviceKind.rower => _rowers,
|
|
||||||
};
|
|
||||||
|
|
||||||
Future<void> _onOpened(
|
Future<void> _onOpened(
|
||||||
RecordingEventOpened event,
|
RecordingEventOpened event,
|
||||||
Emitter<RecordingState> emit,
|
Emitter<RecordingState> emit,
|
||||||
) async {
|
) async {
|
||||||
final lastActivity = await _lastActivity();
|
final lastActivity = await _lastActivity();
|
||||||
final strap = _straps.read();
|
final devices = {
|
||||||
final rower = _rowers.read();
|
for (final MapEntry(:key, :value) in _devices.entries) key: ?value.read(),
|
||||||
|
};
|
||||||
if (await _platform.isRecording()) {
|
if (await _platform.isRecording()) {
|
||||||
_follow();
|
_follow();
|
||||||
emit(
|
emit(
|
||||||
RecordingState(
|
RecordingState(
|
||||||
phase: RecordingPhase.recording,
|
phase: RecordingPhase.recording,
|
||||||
lastActivity: lastActivity,
|
lastActivity: lastActivity,
|
||||||
strap: strap,
|
devices: devices,
|
||||||
rower: rower,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
@ -154,8 +152,7 @@ class RecordingBloc(
|
||||||
},
|
},
|
||||||
lastActivity: lastActivity,
|
lastActivity: lastActivity,
|
||||||
recording: kept,
|
recording: kept,
|
||||||
strap: strap,
|
devices: devices,
|
||||||
rower: rower,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -238,6 +235,12 @@ class RecordingBloc(
|
||||||
notice: notice,
|
notice: notice,
|
||||||
strap: event.useStrap ? state.strap?.address : null,
|
strap: event.useStrap ? state.strap?.address : null,
|
||||||
rower: rows ? rower?.address : null,
|
rower: rows ? rower?.address : null,
|
||||||
|
speedSensor: activity == Activity.cycling
|
||||||
|
? state.speedSensor?.address
|
||||||
|
: null,
|
||||||
|
powerMeter: activity == Activity.cycling
|
||||||
|
? state.powerMeter?.address
|
||||||
|
: null,
|
||||||
loop: loop?.call(),
|
loop: loop?.call(),
|
||||||
plan: plan,
|
plan: plan,
|
||||||
route: route,
|
route: route,
|
||||||
|
|
@ -251,8 +254,7 @@ class RecordingBloc(
|
||||||
phase: RecordingPhase.recording,
|
phase: RecordingPhase.recording,
|
||||||
lastActivity: activity,
|
lastActivity: activity,
|
||||||
live: liveOf(Recording(activity: activity, startedAt: now), now),
|
live: liveOf(Recording(activity: activity, startedAt: now), now),
|
||||||
strap: state.strap,
|
devices: state.devices,
|
||||||
rower: state.rower,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -269,8 +271,7 @@ class RecordingBloc(
|
||||||
phase: recording == null ? RecordingPhase.idle : RecordingPhase.stopped,
|
phase: recording == null ? RecordingPhase.idle : RecordingPhase.stopped,
|
||||||
lastActivity: state.lastActivity,
|
lastActivity: state.lastActivity,
|
||||||
recording: recording,
|
recording: recording,
|
||||||
strap: state.strap,
|
devices: state.devices,
|
||||||
rower: state.rower,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -297,8 +298,7 @@ class RecordingBloc(
|
||||||
RecordingState(
|
RecordingState(
|
||||||
phase: RecordingPhase.idle,
|
phase: RecordingPhase.idle,
|
||||||
lastActivity: recording.activity,
|
lastActivity: recording.activity,
|
||||||
strap: state.strap,
|
devices: state.devices,
|
||||||
rower: state.rower,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -312,8 +312,7 @@ class RecordingBloc(
|
||||||
RecordingState(
|
RecordingState(
|
||||||
phase: RecordingPhase.idle,
|
phase: RecordingPhase.idle,
|
||||||
lastActivity: state.lastActivity,
|
lastActivity: state.lastActivity,
|
||||||
strap: state.strap,
|
devices: state.devices,
|
||||||
rower: state.rower,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -440,11 +439,15 @@ final class const RecordingState({
|
||||||
/// Whether a row was to start without a rower chosen.
|
/// Whether a row was to start without a rower chosen.
|
||||||
final bool rowerMissing = false,
|
final bool rowerMissing = false,
|
||||||
|
|
||||||
/// The person's strap and rower on this device, if they chose them (D27,
|
/// The person's devices on this device, those they chose: the strap and
|
||||||
/// D28).
|
/// the rower (D27, D28), the bike's sensors (D49).
|
||||||
final KnownDevice? strap,
|
final Map<DeviceKind, KnownDevice> devices = const {},
|
||||||
final KnownDevice? rower,
|
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
|
KnownDevice? get strap => devices[DeviceKind.strap];
|
||||||
|
KnownDevice? get rower => devices[DeviceKind.rower];
|
||||||
|
KnownDevice? get speedSensor => devices[DeviceKind.speedSensor];
|
||||||
|
KnownDevice? get powerMeter => devices[DeviceKind.powerMeter];
|
||||||
|
|
||||||
RecordingState copyWith({
|
RecordingState copyWith({
|
||||||
LiveRecording? live,
|
LiveRecording? live,
|
||||||
bool? locationRefused,
|
bool? locationRefused,
|
||||||
|
|
@ -460,8 +463,7 @@ final class const RecordingState({
|
||||||
saveFailed: saveFailed ?? this.saveFailed,
|
saveFailed: saveFailed ?? this.saveFailed,
|
||||||
bluetoothRefused: bluetoothRefused ?? this.bluetoothRefused,
|
bluetoothRefused: bluetoothRefused ?? this.bluetoothRefused,
|
||||||
rowerMissing: rowerMissing ?? this.rowerMissing,
|
rowerMissing: rowerMissing ?? this.rowerMissing,
|
||||||
strap: strap,
|
devices: devices,
|
||||||
rower: rower,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
RecordingState _withDevice(DeviceKind kind, KnownDevice? device) =>
|
RecordingState _withDevice(DeviceKind kind, KnownDevice? device) =>
|
||||||
|
|
@ -474,8 +476,11 @@ final class const RecordingState({
|
||||||
saveFailed: saveFailed,
|
saveFailed: saveFailed,
|
||||||
bluetoothRefused: bluetoothRefused,
|
bluetoothRefused: bluetoothRefused,
|
||||||
rowerMissing: rowerMissing,
|
rowerMissing: rowerMissing,
|
||||||
strap: kind == DeviceKind.strap ? device : strap,
|
devices: {
|
||||||
rower: kind == DeviceKind.rower ? device : rower,
|
for (final MapEntry(:key, :value) in devices.entries)
|
||||||
|
if (key != kind) key: value,
|
||||||
|
kind: ?device,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -488,8 +493,7 @@ final class const RecordingState({
|
||||||
saveFailed,
|
saveFailed,
|
||||||
bluetoothRefused,
|
bluetoothRefused,
|
||||||
rowerMissing,
|
rowerMissing,
|
||||||
strap,
|
devices,
|
||||||
rower,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||||
static const _scans = {
|
static const _scans = {
|
||||||
DeviceKind.strap: EventChannel('app.halkyon.train/recorder/straps'),
|
DeviceKind.strap: EventChannel('app.halkyon.train/recorder/straps'),
|
||||||
DeviceKind.rower: EventChannel('app.halkyon.train/recorder/rowers'),
|
DeviceKind.rower: EventChannel('app.halkyon.train/recorder/rowers'),
|
||||||
|
DeviceKind.speedSensor: EventChannel(
|
||||||
|
'app.halkyon.train/recorder/speedSensors',
|
||||||
|
),
|
||||||
|
DeviceKind.powerMeter: EventChannel(
|
||||||
|
'app.halkyon.train/recorder/powerMeters',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -41,6 +47,8 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||||
Map<String, Object?>? route,
|
Map<String, Object?>? route,
|
||||||
Map<String, Object?>? music,
|
Map<String, Object?>? music,
|
||||||
Map<String, Object?>? announce,
|
Map<String, Object?>? announce,
|
||||||
|
String? speedSensor,
|
||||||
|
String? powerMeter,
|
||||||
}) => _channel.invokeMethod<void>('start', {
|
}) => _channel.invokeMethod<void>('start', {
|
||||||
'path': path,
|
'path': path,
|
||||||
'activity': activity.name,
|
'activity': activity.name,
|
||||||
|
|
@ -53,6 +61,8 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||||
'route': route == null ? null : jsonEncode(route),
|
'route': route == null ? null : jsonEncode(route),
|
||||||
'music': music == null ? null : jsonEncode(music),
|
'music': music == null ? null : jsonEncode(music),
|
||||||
'announce': announce == null ? null : jsonEncode(announce),
|
'announce': announce == null ? null : jsonEncode(announce),
|
||||||
|
'speedSensor': speedSensor,
|
||||||
|
'powerMeter': powerMeter,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,12 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
||||||
final lastRowing = recording.rowing.lastOrNull;
|
final lastRowing = recording.rowing.lastOrNull;
|
||||||
final rowingFresh =
|
final rowingFresh =
|
||||||
lastRowing != null && now.difference(lastRowing.time) <= freshHeartRate;
|
lastRowing != null && now.difference(lastRowing.time) <= freshHeartRate;
|
||||||
|
int? fresh(List<Reading> readings) => switch (readings.lastOrNull) {
|
||||||
|
final reading?
|
||||||
|
when !paused && now.difference(reading.time) <= freshHeartRate =>
|
||||||
|
reading.value,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
final track = [
|
final track = [
|
||||||
for (final fix in recording.fixes)
|
for (final fix in recording.fixes)
|
||||||
if (fix.accuracyCm <= maxAccuracyCm)
|
if (fix.accuracyCm <= maxAccuracyCm)
|
||||||
|
|
@ -87,7 +93,10 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
||||||
rower: recording.rowers.lastOrNull?.state,
|
rower: recording.rowers.lastOrNull?.state,
|
||||||
split: paused ? null : _splitOf(recording, now),
|
split: paused ? null : _splitOf(recording, now),
|
||||||
strokeRate: rowingFresh && !paused ? lastRowing.strokeRate : null,
|
strokeRate: rowingFresh && !paused ? lastRowing.strokeRate : null,
|
||||||
watts: rowingFresh && !paused ? lastRowing.watts : null,
|
watts: rowingFresh && !paused ? lastRowing.watts : fresh(recording.power),
|
||||||
|
bikeSensors:
|
||||||
|
recording.speedSensors.isNotEmpty || recording.powerMeters.isNotEmpty,
|
||||||
|
cadence: fresh(recording.pedalling),
|
||||||
plan: _planOf(recording, now),
|
plan: _planOf(recording, now),
|
||||||
route: _routeOf(recording, track.lastOrNull),
|
route: _routeOf(recording, track.lastOrNull),
|
||||||
track: track,
|
track: track,
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,18 @@ List<SeriesChunk> seriesOf(Recording recording) {
|
||||||
final pauses = recording.pauses;
|
final pauses = recording.pauses;
|
||||||
final rowing = recording.rowing;
|
final rowing = recording.rowing;
|
||||||
final pressures = recording.pressures;
|
final pressures = recording.pressures;
|
||||||
|
List<SeriesChunk> readings(
|
||||||
|
SeriesKind kind,
|
||||||
|
String column,
|
||||||
|
List<Reading> readings,
|
||||||
|
) => chunksOf(
|
||||||
|
kind,
|
||||||
|
start: start,
|
||||||
|
times: times(readings.map((reading) => reading.time)),
|
||||||
|
columns: {
|
||||||
|
column: [for (final reading in readings) reading.value],
|
||||||
|
},
|
||||||
|
);
|
||||||
return [
|
return [
|
||||||
...chunksOf(
|
...chunksOf(
|
||||||
SeriesKind.position,
|
SeriesKind.position,
|
||||||
|
|
@ -284,6 +296,9 @@ List<SeriesChunk> seriesOf(Recording recording) {
|
||||||
'dpa': [for (final pressure in pressures) pressure.decipascals],
|
'dpa': [for (final pressure in pressures) pressure.decipascals],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
...readings(SeriesKind.wheel, 'revs', recording.wheel),
|
||||||
|
...readings(SeriesKind.pedalling, 'rpm', recording.pedalling),
|
||||||
|
...readings(SeriesKind.power, 'watts', recording.power),
|
||||||
...chunksOf(
|
...chunksOf(
|
||||||
SeriesKind.pauses,
|
SeriesKind.pauses,
|
||||||
start: start,
|
start: start,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,13 @@ enum DeviceKind() {
|
||||||
|
|
||||||
/// A rower (the Fitness Machine Service).
|
/// A rower (the Fitness Machine Service).
|
||||||
rower,
|
rower,
|
||||||
|
|
||||||
|
/// A bike's speed and cadence sensor (the Cycling Speed and Cadence
|
||||||
|
/// Service, D49).
|
||||||
|
speedSensor,
|
||||||
|
|
||||||
|
/// A bike's power meter (the Cycling Power Service, D49).
|
||||||
|
powerMeter,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the recording's notification shows (D21), in the app's language.
|
/// What the recording's notification shows (D21), in the app's language.
|
||||||
|
|
@ -118,6 +125,10 @@ abstract interface class RecorderPlatform() {
|
||||||
/// The words each distance passed is announced in (D49,
|
/// The words each distance passed is announced in (D49,
|
||||||
/// `recorderAnnouncementsOf`); null: none is.
|
/// `recorderAnnouncementsOf`); null: none is.
|
||||||
Map<String, Object?>? announce,
|
Map<String, Object?>? announce,
|
||||||
|
|
||||||
|
/// The bike's speed and cadence sensor and power meter it reads (D49).
|
||||||
|
String? speedSensor,
|
||||||
|
String? powerMeter,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Pauses, or goes on, by hand.
|
/// Pauses, or goes on, by hand.
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ const recordingFileMagic = 'halkyon-train-recording';
|
||||||
/// strap <time> connecting|connected|lost
|
/// strap <time> connecting|connected|lost
|
||||||
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
|
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
|
||||||
/// rower <time> connecting|connected|lost
|
/// rower <time> connecting|connected|lost
|
||||||
|
/// w <time> <the wheel's revolutions since its first frame>
|
||||||
|
/// c <time> <the crank's turns a minute>
|
||||||
|
/// pw <time> <watts>
|
||||||
|
/// csc <time> connecting|connected|lost
|
||||||
|
/// power <time> connecting|connected|lost
|
||||||
/// loop <time> connecting|connected|lost
|
/// loop <time> connecting|connected|lost
|
||||||
/// plan <time> <the plan's payload, JSON>
|
/// plan <time> <the plan's payload, JSON>
|
||||||
/// step <time> <its index in the plan's run>|done
|
/// step <time> <its index in the plan's run>|done
|
||||||
|
|
@ -72,6 +77,11 @@ Recording? readRecording(String text) {
|
||||||
final loops = <LinkMark>[];
|
final loops = <LinkMark>[];
|
||||||
final rowing = <RowingSample>[];
|
final rowing = <RowingSample>[];
|
||||||
final rowers = <LinkMark>[];
|
final rowers = <LinkMark>[];
|
||||||
|
final wheel = <Reading>[];
|
||||||
|
final pedalling = <Reading>[];
|
||||||
|
final power = <Reading>[];
|
||||||
|
final speedSensors = <LinkMark>[];
|
||||||
|
final powerMeters = <LinkMark>[];
|
||||||
PlanContent? plan;
|
PlanContent? plan;
|
||||||
int? planMaxHeartRate;
|
int? planMaxHeartRate;
|
||||||
final planSteps = <StepMark>[];
|
final planSteps = <StepMark>[];
|
||||||
|
|
@ -166,14 +176,27 @@ Recording? readRecording(String text) {
|
||||||
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
|
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
|
||||||
);
|
);
|
||||||
case [final link, _, final name]
|
case [final link, _, final name]
|
||||||
when link == 'strap' || link == 'rower' || link == 'loop':
|
when const {'strap', 'rower', 'loop', 'csc', 'power'}.contains(
|
||||||
|
link,
|
||||||
|
):
|
||||||
if (LinkState.values.asNameMap()[name] case final state?) {
|
if (LinkState.values.asNameMap()[name] case final state?) {
|
||||||
(switch (link) {
|
(switch (link) {
|
||||||
'strap' => straps,
|
'strap' => straps,
|
||||||
'loop' => loops,
|
'loop' => loops,
|
||||||
|
'csc' => speedSensors,
|
||||||
|
'power' => powerMeters,
|
||||||
_ => rowers,
|
_ => rowers,
|
||||||
}).add(LinkMark(time: time, state: state));
|
}).add(LinkMark(time: time, state: state));
|
||||||
}
|
}
|
||||||
|
case [final kind, _, final value]
|
||||||
|
when kind == 'w' || kind == 'c' || kind == 'pw':
|
||||||
|
if (int.tryParse(value) case final value?) {
|
||||||
|
(switch (kind) {
|
||||||
|
'w' => wheel,
|
||||||
|
'c' => pedalling,
|
||||||
|
_ => power,
|
||||||
|
}).add(Reading(time: time, value: value));
|
||||||
|
}
|
||||||
case ['r', _, final rate, final strokes, final metres, final watts]:
|
case ['r', _, final rate, final strokes, final metres, final watts]:
|
||||||
rowing.add(
|
rowing.add(
|
||||||
RowingSample(
|
RowingSample(
|
||||||
|
|
@ -200,6 +223,11 @@ Recording? readRecording(String text) {
|
||||||
loops: loops,
|
loops: loops,
|
||||||
rowing: rowing,
|
rowing: rowing,
|
||||||
rowers: rowers,
|
rowers: rowers,
|
||||||
|
wheel: wheel,
|
||||||
|
pedalling: pedalling,
|
||||||
|
power: power,
|
||||||
|
speedSensors: speedSensors,
|
||||||
|
powerMeters: powerMeters,
|
||||||
plan: plan,
|
plan: plan,
|
||||||
planSteps: planSteps,
|
planSteps: planSteps,
|
||||||
planMaxHeartRate: planMaxHeartRate,
|
planMaxHeartRate: planMaxHeartRate,
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,11 @@ final class const LiveRecording({
|
||||||
final double? strokeRate,
|
final double? strokeRate,
|
||||||
final int? watts,
|
final int? watts,
|
||||||
|
|
||||||
|
/// On a bike with its sensors (D49): whether it has one, and the crank's
|
||||||
|
/// cadence of its last few seconds; its watts are [watts].
|
||||||
|
final bool bikeSensors = false,
|
||||||
|
final int? cadence,
|
||||||
|
|
||||||
/// The plan it runs; null without one (D42).
|
/// The plan it runs; null without one (D42).
|
||||||
final LivePlan? plan,
|
final LivePlan? plan,
|
||||||
|
|
||||||
|
|
@ -142,6 +147,8 @@ final class const LiveRecording({
|
||||||
split,
|
split,
|
||||||
strokeRate,
|
strokeRate,
|
||||||
watts,
|
watts,
|
||||||
|
bikeSensors,
|
||||||
|
cadence,
|
||||||
plan,
|
plan,
|
||||||
route,
|
route,
|
||||||
track,
|
track,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue