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
|
||||
},
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -519,5 +519,26 @@
|
|||
"workoutExportTitle": "Training exportieren",
|
||||
"workoutExportGpxHint": "Die Strecke, für Karten und andere Apps",
|
||||
"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",
|
||||
"workoutExportGpxHint": "The track, for maps and other apps",
|
||||
"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:
|
||||
/// **'What Strava, Garmin and intervals.icu read best'**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1889,4 +1889,74 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get workoutExportFitHint =>
|
||||
'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
|
||||
String get workoutExportFitHint =>
|
||||
'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`).
|
||||
|
|
|
|||
|
|
@ -143,8 +143,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
|
|||
recording.platform,
|
||||
train.workouts,
|
||||
recording.file,
|
||||
recording.straps,
|
||||
recording.rowers,
|
||||
recording.devices,
|
||||
notice: (live) => noticeOf(
|
||||
AppLocalizations.of(context),
|
||||
Localizations.localeOf(context).toString(),
|
||||
|
|
|
|||
|
|
@ -46,13 +46,13 @@ import 'package:serverpod_flutter/serverpod_flutter.dart';
|
|||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// Where a device records workouts (ADR 0030, D13, D18, D27, D28): its
|
||||
/// recorder, and the person's recording file, strap and rower in their
|
||||
/// folder, and the activities they have announced (D49).
|
||||
/// recorder, and the person's recording file, their devices (strap, rower,
|
||||
/// the bike's sensors, D49) in their folder, and the activities they have
|
||||
/// announced (D49).
|
||||
typedef RecordingDevice = ({
|
||||
RecorderPlatform platform,
|
||||
File file,
|
||||
DeviceStore straps,
|
||||
DeviceStore rowers,
|
||||
Map<DeviceKind, DeviceStore> devices,
|
||||
AnnounceStore announces,
|
||||
});
|
||||
|
||||
|
|
@ -452,8 +452,15 @@ Future<TrainPerson> _openPersonData(
|
|||
? (
|
||||
platform: recorder,
|
||||
file: File(p.join(folder.path, 'recording.txt')),
|
||||
straps: DeviceStore(File(p.join(folder.path, 'strap.json'))),
|
||||
rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))),
|
||||
devices: {
|
||||
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(
|
||||
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
|
||||
/// 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 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
|
||||
/// 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
|
||||
|
|
@ -40,9 +41,9 @@ class RecordingBloc(
|
|||
/// The person's recording file, in their folder.
|
||||
final File _file,
|
||||
|
||||
/// The person's strap and rower on this device, in their folder.
|
||||
final DeviceStore _straps,
|
||||
final DeviceStore _rowers, {
|
||||
/// The person's devices on this device, each kind in a file of their
|
||||
/// folder: the strap, the rower, the bike's sensors (D49).
|
||||
final Map<DeviceKind, DeviceStore> _devices, {
|
||||
|
||||
/// What the notification shows of [LiveRecording], in the app's language.
|
||||
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.
|
||||
(String, String, bool)? _shown;
|
||||
|
||||
DeviceStore _storeOf(DeviceKind kind) => switch (kind) {
|
||||
DeviceKind.strap => _straps,
|
||||
DeviceKind.rower => _rowers,
|
||||
};
|
||||
DeviceStore _storeOf(DeviceKind kind) => _devices[kind]!;
|
||||
|
||||
Future<void> _onOpened(
|
||||
RecordingEventOpened event,
|
||||
Emitter<RecordingState> emit,
|
||||
) async {
|
||||
final lastActivity = await _lastActivity();
|
||||
final strap = _straps.read();
|
||||
final rower = _rowers.read();
|
||||
final devices = {
|
||||
for (final MapEntry(:key, :value) in _devices.entries) key: ?value.read(),
|
||||
};
|
||||
if (await _platform.isRecording()) {
|
||||
_follow();
|
||||
emit(
|
||||
RecordingState(
|
||||
phase: RecordingPhase.recording,
|
||||
lastActivity: lastActivity,
|
||||
strap: strap,
|
||||
rower: rower,
|
||||
devices: devices,
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
|
@ -154,8 +152,7 @@ class RecordingBloc(
|
|||
},
|
||||
lastActivity: lastActivity,
|
||||
recording: kept,
|
||||
strap: strap,
|
||||
rower: rower,
|
||||
devices: devices,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -238,6 +235,12 @@ class RecordingBloc(
|
|||
notice: notice,
|
||||
strap: event.useStrap ? state.strap?.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(),
|
||||
plan: plan,
|
||||
route: route,
|
||||
|
|
@ -251,8 +254,7 @@ class RecordingBloc(
|
|||
phase: RecordingPhase.recording,
|
||||
lastActivity: activity,
|
||||
live: liveOf(Recording(activity: activity, startedAt: now), now),
|
||||
strap: state.strap,
|
||||
rower: state.rower,
|
||||
devices: state.devices,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -269,8 +271,7 @@ class RecordingBloc(
|
|||
phase: recording == null ? RecordingPhase.idle : RecordingPhase.stopped,
|
||||
lastActivity: state.lastActivity,
|
||||
recording: recording,
|
||||
strap: state.strap,
|
||||
rower: state.rower,
|
||||
devices: state.devices,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -297,8 +298,7 @@ class RecordingBloc(
|
|||
RecordingState(
|
||||
phase: RecordingPhase.idle,
|
||||
lastActivity: recording.activity,
|
||||
strap: state.strap,
|
||||
rower: state.rower,
|
||||
devices: state.devices,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -312,8 +312,7 @@ class RecordingBloc(
|
|||
RecordingState(
|
||||
phase: RecordingPhase.idle,
|
||||
lastActivity: state.lastActivity,
|
||||
strap: state.strap,
|
||||
rower: state.rower,
|
||||
devices: state.devices,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -440,11 +439,15 @@ final class const RecordingState({
|
|||
/// Whether a row was to start without a rower chosen.
|
||||
final bool rowerMissing = false,
|
||||
|
||||
/// The person's strap and rower on this device, if they chose them (D27,
|
||||
/// D28).
|
||||
final KnownDevice? strap,
|
||||
final KnownDevice? rower,
|
||||
/// The person's devices on this device, those they chose: the strap and
|
||||
/// the rower (D27, D28), the bike's sensors (D49).
|
||||
final Map<DeviceKind, KnownDevice> devices = const {},
|
||||
}) 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({
|
||||
LiveRecording? live,
|
||||
bool? locationRefused,
|
||||
|
|
@ -460,8 +463,7 @@ final class const RecordingState({
|
|||
saveFailed: saveFailed ?? this.saveFailed,
|
||||
bluetoothRefused: bluetoothRefused ?? this.bluetoothRefused,
|
||||
rowerMissing: rowerMissing ?? this.rowerMissing,
|
||||
strap: strap,
|
||||
rower: rower,
|
||||
devices: devices,
|
||||
);
|
||||
|
||||
RecordingState _withDevice(DeviceKind kind, KnownDevice? device) =>
|
||||
|
|
@ -474,8 +476,11 @@ final class const RecordingState({
|
|||
saveFailed: saveFailed,
|
||||
bluetoothRefused: bluetoothRefused,
|
||||
rowerMissing: rowerMissing,
|
||||
strap: kind == DeviceKind.strap ? device : strap,
|
||||
rower: kind == DeviceKind.rower ? device : rower,
|
||||
devices: {
|
||||
for (final MapEntry(:key, :value) in devices.entries)
|
||||
if (key != kind) key: value,
|
||||
kind: ?device,
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -488,8 +493,7 @@ final class const RecordingState({
|
|||
saveFailed,
|
||||
bluetoothRefused,
|
||||
rowerMissing,
|
||||
strap,
|
||||
rower,
|
||||
devices,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
static const _scans = {
|
||||
DeviceKind.strap: EventChannel('app.halkyon.train/recorder/straps'),
|
||||
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
|
||||
|
|
@ -41,6 +47,8 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
Map<String, Object?>? route,
|
||||
Map<String, Object?>? music,
|
||||
Map<String, Object?>? announce,
|
||||
String? speedSensor,
|
||||
String? powerMeter,
|
||||
}) => _channel.invokeMethod<void>('start', {
|
||||
'path': path,
|
||||
'activity': activity.name,
|
||||
|
|
@ -53,6 +61,8 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
'route': route == null ? null : jsonEncode(route),
|
||||
'music': music == null ? null : jsonEncode(music),
|
||||
'announce': announce == null ? null : jsonEncode(announce),
|
||||
'speedSensor': speedSensor,
|
||||
'powerMeter': powerMeter,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -57,6 +57,12 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
|||
final lastRowing = recording.rowing.lastOrNull;
|
||||
final rowingFresh =
|
||||
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 = [
|
||||
for (final fix in recording.fixes)
|
||||
if (fix.accuracyCm <= maxAccuracyCm)
|
||||
|
|
@ -87,7 +93,10 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
|||
rower: recording.rowers.lastOrNull?.state,
|
||||
split: paused ? null : _splitOf(recording, now),
|
||||
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),
|
||||
route: _routeOf(recording, track.lastOrNull),
|
||||
track: track,
|
||||
|
|
|
|||
|
|
@ -229,6 +229,18 @@ List<SeriesChunk> seriesOf(Recording recording) {
|
|||
final pauses = recording.pauses;
|
||||
final rowing = recording.rowing;
|
||||
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 [
|
||||
...chunksOf(
|
||||
SeriesKind.position,
|
||||
|
|
@ -284,6 +296,9 @@ List<SeriesChunk> seriesOf(Recording recording) {
|
|||
'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(
|
||||
SeriesKind.pauses,
|
||||
start: start,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ enum DeviceKind() {
|
|||
|
||||
/// A rower (the Fitness Machine Service).
|
||||
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.
|
||||
|
|
@ -118,6 +125,10 @@ abstract interface class RecorderPlatform() {
|
|||
/// The words each distance passed is announced in (D49,
|
||||
/// `recorderAnnouncementsOf`); null: none is.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ const recordingFileMagic = 'halkyon-train-recording';
|
|||
/// strap <time> connecting|connected|lost
|
||||
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
|
||||
/// 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
|
||||
/// plan <time> <the plan's payload, JSON>
|
||||
/// step <time> <its index in the plan's run>|done
|
||||
|
|
@ -72,6 +77,11 @@ Recording? readRecording(String text) {
|
|||
final loops = <LinkMark>[];
|
||||
final rowing = <RowingSample>[];
|
||||
final rowers = <LinkMark>[];
|
||||
final wheel = <Reading>[];
|
||||
final pedalling = <Reading>[];
|
||||
final power = <Reading>[];
|
||||
final speedSensors = <LinkMark>[];
|
||||
final powerMeters = <LinkMark>[];
|
||||
PlanContent? plan;
|
||||
int? planMaxHeartRate;
|
||||
final planSteps = <StepMark>[];
|
||||
|
|
@ -166,14 +176,27 @@ Recording? readRecording(String text) {
|
|||
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
|
||||
);
|
||||
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?) {
|
||||
(switch (link) {
|
||||
'strap' => straps,
|
||||
'loop' => loops,
|
||||
'csc' => speedSensors,
|
||||
'power' => powerMeters,
|
||||
_ => rowers,
|
||||
}).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]:
|
||||
rowing.add(
|
||||
RowingSample(
|
||||
|
|
@ -200,6 +223,11 @@ Recording? readRecording(String text) {
|
|||
loops: loops,
|
||||
rowing: rowing,
|
||||
rowers: rowers,
|
||||
wheel: wheel,
|
||||
pedalling: pedalling,
|
||||
power: power,
|
||||
speedSensors: speedSensors,
|
||||
powerMeters: powerMeters,
|
||||
plan: plan,
|
||||
planSteps: planSteps,
|
||||
planMaxHeartRate: planMaxHeartRate,
|
||||
|
|
|
|||
|
|
@ -113,6 +113,11 @@ final class const LiveRecording({
|
|||
final double? strokeRate,
|
||||
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).
|
||||
final LivePlan? plan,
|
||||
|
||||
|
|
@ -142,6 +147,8 @@ final class const LiveRecording({
|
|||
split,
|
||||
strokeRate,
|
||||
watts,
|
||||
bikeSensors,
|
||||
cadence,
|
||||
plan,
|
||||
route,
|
||||
track,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue