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:
Tizian 「ティツィアーン」 2026-10-29 15:11:20 +01:00
commit 5c13e6f3c5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
36 changed files with 1209 additions and 62 deletions

File diff suppressed because one or more lines are too long

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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))
}
}

View file

@ -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"
}

View file

@ -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"}}}
}

View file

@ -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

View file

@ -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';
}
}

View file

@ -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`).

View file

@ -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(),

View file

@ -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')),
),

View file

@ -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,
];
}

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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.

View file

@ -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,

View file

@ -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