feat(train): a ride's watts, cadences and wheel to Health Connect, and auto-pause by the wheel (ADR 0030, D51)

Health Connect gets the power meter's watts, the crank's cadence
(CyclingPedalingCadenceRecord), a run's step cadence (StepsCadenceRecord)
and, where the wheel measured the ride, the wheel's speed. The cadences'
permissions are asked with writing's and each record is left out where
its permission isn't granted; healthRecordsVersion in the fingerprint
writes every workout once more. On a bike, the wheel's speed decides
auto-pause while its sensor sends, GPS's once it has been silent 5 s.

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 17:53:54 +01:00
commit 64366d5420
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 400 additions and 29 deletions

File diff suppressed because one or more lines are too long

View file

@ -38,6 +38,9 @@
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
<uses-permission android:name="android.permission.health.WRITE_SPEED"/>
<uses-permission android:name="android.permission.health.WRITE_POWER"/>
<!-- A ride's and a run's cadence (ADR 0030, D51), asked for with writing. -->
<uses-permission android:name="android.permission.health.WRITE_EXERCISE"/>
<uses-permission android:name="android.permission.health.WRITE_STEPS"/>
<uses-permission android:name="android.permission.health.WRITE_DISTANCE"/>
<uses-permission android:name="android.permission.health.WRITE_ELEVATION_GAINED"/>
<uses-permission android:name="android.permission.health.READ_EXERCISE"/>

View file

@ -3,7 +3,8 @@ package app.halkyon.train
// Auto-pause by each activity's best signal (ADR 0030, D16), decided in the
// service, which runs while the app may not: steps on foot, confirmed by GPS;
// GPS speed on a bike; GPS speed on foot where steps aren't allowed (D19);
// the rower's strokes (D28).
// the rower's strokes (D28); on a bike the wheel's speed while its sensor
// sends, GPS's once it has been silent 5 s (D51).
// Times in milliseconds; each call returns true where it paused, false where
// it went on, null where nothing changed.
@ -32,6 +33,7 @@ class AutoPause(private val signal: Signal) {
private var slowSince: Long? = null
private var lastFixAt: Long? = null
private var lastFixFast = false
private var lastWheelAt: Long? = null
/** A step at [at]: two close together go on again (a lone one is a jostle). */
fun step(at: Long): Boolean? {
@ -66,6 +68,20 @@ class AutoPause(private val signal: Signal) {
lastFixFast = kmh > 5
return null
}
// The wheel decides while it sends (D51).
if (signal == Signal.BIKE_SPEED && lastWheelAt?.let { at - it <= WHEEL_FRESH_MS } == true) return null
return decide(at, kmh)
}
/** The bike's wheel at [at], turning at [metresPerSecond] (D51): it decides in GPS's place. */
fun wheel(at: Long, metresPerSecond: Double): Boolean? {
if (signal != Signal.BIKE_SPEED) return null
lastWheelAt = at
return decide(at, metresPerSecond * 3.6)
}
/** Going on above the activity's upper speed, slow below its lower. */
private fun decide(at: Long, kmh: Double): Boolean? {
if (byHand) return null
val (below, above) = if (signal == Signal.BIKE_SPEED) 3.0 to 5.0 else 2.0 to 3.0
if (paused) {
@ -119,5 +135,6 @@ class AutoPause(private val signal: Signal) {
const val STILL_MS = 5_000L
const val STEPS_TO_GO_ON_MS = 2_000L
const val FRESH_FIX_MS = 3_000L
const val WHEEL_FRESH_MS = 5_000L
}
}

View file

@ -107,6 +107,32 @@ class WheelCount {
}
}
/**
* The wheel's speed (ADR 0030, D51), in metres a second, from its
* revolutions over the 32-bit counter's rollover, each over at least half a
* second since the last, by its [mm] round; null for the first frame and
* those too soon after one.
*/
class WheelSpeed(private val mm: Int) {
private var lastRevolutions: Long? = null
private var lastAt = 0L
fun add(at: Long, revolutions: Long): Double? {
val before = lastRevolutions
if (before != null && at - lastAt < MIN_SPAN_MS) return null
lastRevolutions = revolutions
val span = at - lastAt
lastAt = at
if (before == null || span <= 0) return null
val grown = (revolutions - before) and 0xFFFF_FFFFL
return grown * mm / 1000.0 / (span / 1000.0)
}
private companion object {
const val MIN_SPAN_MS = 500L
}
}
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

@ -9,6 +9,7 @@ import androidx.health.connect.client.PermissionController
import androidx.health.connect.client.changes.UpsertionChange
import androidx.health.connect.client.contracts.ExerciseRouteRequestContract
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ElevationGainedRecord
import androidx.health.connect.client.records.ExerciseRouteResult
@ -17,6 +18,7 @@ import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.PowerRecord
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.StepsCadenceRecord
import androidx.health.connect.client.records.metadata.DataOrigin
import androidx.health.connect.client.request.ChangesTokenRequest
import androidx.health.connect.client.request.ReadRecordsRequest
@ -90,8 +92,12 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
"granted" -> result.success(granted())
"ask" -> ask(call.arguments<List<String>>().orEmpty(), result)
"write" -> {
val withRoute = HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE in grantedPermissions()
val records = HealthRecords.recordsOf(call.arguments<List<Map<String, Any?>>>().orEmpty(), withRoute)
val granted = grantedPermissions()
val withRoute = HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE in granted
val records =
HealthRecords.recordsOf(call.arguments<List<Map<String, Any?>>>().orEmpty(), withRoute) {
HealthPermission.getWritePermission(it::class) in granted
}
if (records.isNotEmpty()) client.insertRecords(records)
result.success(null)
}
@ -168,7 +174,8 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
}
private suspend fun ask(names: List<String>, result: MethodChannel.Result) {
val wanted = names.flatMap { PERMISSIONS[it].orEmpty() }.toSet() - grantedPermissions()
val wanted =
names.flatMap { PERMISSIONS[it].orEmpty() + ASKED_WITH[it].orEmpty() }.toSet() - grantedPermissions()
if (wanted.isEmpty() || asking != null) {
result.success(granted())
return
@ -308,5 +315,18 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
),
"history" to setOf(HealthPermission.PERMISSION_READ_HEALTH_DATA_HISTORY),
)
/**
* Asked for with a name's, but not needed for it to count as granted
* (D51): writing switched on before the cadences stays on without them.
*/
val ASKED_WITH: Map<String, Set<String>> =
mapOf(
"write" to
setOf(
HealthPermission.getWritePermission(CyclingPedalingCadenceRecord::class),
HealthPermission.getWritePermission(StepsCadenceRecord::class),
),
)
}
}

View file

@ -1,5 +1,6 @@
package app.halkyon.train
import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ElevationGainedRecord
import androidx.health.connect.client.records.ExerciseRoute
@ -10,6 +11,7 @@ import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.PowerRecord
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.StepsCadenceRecord
import androidx.health.connect.client.records.metadata.Device
import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.units.Length
@ -34,6 +36,8 @@ object HealthRecords {
"heartRate" to HeartRateRecord::class,
"speed" to SpeedRecord::class,
"power" to PowerRecord::class,
"pedalling" to CyclingPedalingCadenceRecord::class,
"stepsCadence" to StepsCadenceRecord::class,
"distance" to DistanceRecord::class,
"elevation" to ElevationGainedRecord::class,
)
@ -75,8 +79,16 @@ object HealthRecords {
* Health Connect would refuse (a heart rate beyond 1 to 300, say) are
* left out rather than failing the whole workout.
*/
fun recordsOf(written: List<Map<String, Any?>>, withRoute: Boolean): List<Record> =
written.mapNotNull { recordOf(it, withRoute) }
/**
* [written] as Health Connect's records, the session's route [withRoute]
* alone, and only those [allowed] (ADR 0030, D51: the cadences' permissions
* may be missing where writing was switched on before them).
*/
fun recordsOf(
written: List<Map<String, Any?>>,
withRoute: Boolean,
allowed: (Record) -> Boolean = { true },
): List<Record> = written.mapNotNull { recordOf(it, withRoute) }.filter(allowed)
private fun recordOf(map: Map<String, Any?>, withRoute: Boolean): Record? {
val start = instant(map["start"])
@ -142,6 +154,30 @@ object HealthRecords {
.map { PowerRecord.Sample(instant(it[0]), Power.watts((it[1] as Number).toDouble())) },
metadata = metadata,
).takeIf { it.samples.isNotEmpty() }
"pedalling" ->
CyclingPedalingCadenceRecord(
startTime = start,
startZoneOffset = offsetAt(start),
endTime = end,
endZoneOffset = offsetAt(end),
samples =
samples
.filter { (it[1] as Number).toDouble() in 0.0..10_000.0 }
.map { CyclingPedalingCadenceRecord.Sample(instant(it[0]), (it[1] as Number).toDouble()) },
metadata = metadata,
).takeIf { it.samples.isNotEmpty() }
"stepsCadence" ->
StepsCadenceRecord(
startTime = start,
startZoneOffset = offsetAt(start),
endTime = end,
endZoneOffset = offsetAt(end),
samples =
samples
.filter { (it[1] as Number).toDouble() in 0.0..10_000.0 }
.map { StepsCadenceRecord.Sample(instant(it[0]), (it[1] as Number).toDouble()) },
metadata = metadata,
).takeIf { it.samples.isNotEmpty() }
"distance" ->
DistanceRecord(
startTime = start,

View file

@ -165,6 +165,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var speedSensor: NotifyLink? = null
private var powerMeter: NotifyLink? = null
private val wheel = WheelCount()
private var wheelSpeed: WheelSpeed? = null
private val crank = CrankCadence()
private val powerCrank = CrankCadence()
private var sensorCranks = false
@ -353,7 +354,11 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
}
speedSensorAt?.let { address ->
// What the app works the wheel's distance out by (D49).
intent.getIntExtra(EXTRA_WHEEL_MM, 0).takeIf { it > 0 }?.let { write("wheelsize $now $it") }
intent.getIntExtra(EXTRA_WHEEL_MM, 0).takeIf { it > 0 }?.let { mm ->
write("wheelsize $now $mm")
// And auto-pause by it (D51).
wheelSpeed = WheelSpeed(mm)
}
speedSensor =
NotifyLink(
this,
@ -626,7 +631,13 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
*/
private fun sensed(csc: Csc) {
val at = System.currentTimeMillis()
csc.wheelRevolutions?.let { revolutions -> wheel.add(revolutions)?.let { write("w $at $it") } }
csc.wheelRevolutions?.let { revolutions ->
wheel.add(revolutions)?.let { write("w $at $it") }
// Auto-pause by the wheel while it sends (D51).
wheelSpeed?.add(at, revolutions)?.let { metresPerSecond ->
autoPause?.wheel(at, metresPerSecond)?.let { paused -> mark(paused, auto = true, at = at) }
}
}
if (csc.crankRevolutions != null && csc.crankTime != null) {
sensorCranks = true
crank.add(at, csc.crankRevolutions, csc.crankTime)?.let { write("c $at $it") }

View file

@ -99,4 +99,38 @@ class AutoPauseTest {
// The one stroke at 15 is followed by none again: paused at 20.
assertEquals(mapOf(11 to true, 15 to false, 20 to true), changes)
}
@Test
fun `by bike the wheel decides while it sends, GPS once it has been silent 5 s`() {
val pause = AutoPause(AutoPause.Signal.BIKE_SPEED)
// GPS drifting fast on a trainer, the wheel still, a frame a second: paused.
for (at in 0L..5_000L step 1_000L) {
pause.wheel(at, 0.0)
assertNull(pause.speed(at, 3.0))
}
assertEquals(true, pause.tick(5_000))
// GPS fast says nothing while the wheel sends; the wheel turning goes on.
pause.wheel(6_000, 0.0)
assertNull(pause.speed(6_500, 3.0))
assertEquals(false, pause.wheel(7_000, 2.0))
// The wheel silent 5 s: GPS's slowness counts again.
assertNull(pause.speed(12_100, 0.5))
assertNull(pause.tick(16_000))
assertEquals(true, pause.tick(17_200))
}
@Test
fun `the wheel's speed is its turns by its round over the time between frames, past the rollover`() {
val speed = WheelSpeed(2_100)
assertNull(speed.add(0, 0xFFFF_FFFEL))
assertNull(speed.add(200, 0xFFFF_FFFFL))
assertEquals(8.4, speed.add(1_000, 2L)!!, 0.001)
assertEquals(0.0, speed.add(2_000, 2L)!!, 0.001)
}
@Test
fun `on foot the wheel says nothing`() {
assertNull(AutoPause(AutoPause.Signal.STEPS).wheel(0, 5.0))
assertNull(AutoPause(AutoPause.Signal.FOOT_SPEED).wheel(0, 0.0))
}
}

View file

@ -1,10 +1,14 @@
package app.halkyon.train
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ExerciseSegment
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.PowerRecord
import androidx.health.connect.client.records.StepsCadenceRecord
import java.io.File
import java.time.Instant
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@ -138,4 +142,53 @@ class HealthRecordsTest {
assertEquals(activity, HealthRecords.activityOf(HealthRecords.exerciseOf(activity)))
}
}
@Test
fun `a ride's pedalling and a run's steps go as cadence samples (D51)`() {
val records =
HealthRecords.recordsOf(
listOf(
mapOf(
"kind" to "pedalling", "id" to "w1/pedalling/0", "version" to 1, "start" to start, "end" to start + 1_000,
"samples" to listOf(listOf(start, 88), listOf(start + 1_000, -1)),
),
mapOf(
"kind" to "stepsCadence", "id" to "w1/stepsCadence/0", "version" to 1, "start" to start, "end" to start,
"samples" to listOf(listOf(start, 172)),
),
),
withRoute = true,
)
val pedalling = records[0] as CyclingPedalingCadenceRecord
assertEquals(listOf(88.0), pedalling.samples.map { it.revolutionsPerMinute })
assertEquals("w1/pedalling/0", pedalling.metadata.clientRecordId)
assertEquals(listOf(172.0), (records[1] as StepsCadenceRecord).samples.map { it.rate })
}
@Test
fun `records Health Connect hasn't allowed are left out`() {
val records =
HealthRecords.recordsOf(
listOf(
session(),
mapOf(
"kind" to "pedalling", "id" to "w1/pedalling/0", "version" to 1, "start" to start, "end" to start,
"samples" to listOf(listOf(start, 88)),
),
),
withRoute = true,
) { it !is CyclingPedalingCadenceRecord }
assertTrue(records.single() is ExerciseSessionRecord)
}
@Test
fun `the manifest declares the write permission of every kind written`() {
val manifest = File("src/main/AndroidManifest.xml").readText()
for ((kind, type) in HealthRecords.kinds) {
val permission = HealthPermission.getWritePermission(type)
assertTrue("$kind needs $permission", "android:name=\"$permission\"" in manifest)
}
}
}

View file

@ -368,11 +368,18 @@ Map<HealthRecordKind, List<String>> _without(
key: gone,
};
/// A fingerprint of [payload] that stays the same across runs (FNV-1a, 32
/// bits): only whether a workout changed since it was written, nothing to
/// keep secret.
/// What [healthRecordsOf] gives, counted up as it gives more (2: the
/// cadences, the power meter's watts and the wheel's speed, D51), so that
/// workouts written before are written again.
const healthRecordsVersion = 2;
/// A fingerprint of [payload] and [healthRecordsVersion] that stays the
/// same across runs (FNV-1a, 32 bits): only whether a workout, or what Train
/// writes of it, changed since it was written, nothing to keep secret. The
/// first version's fingerprints were of the payload alone.
int _fingerprintOf(ByteData payload) {
var hash = 0x811c9dc5;
hash = ((hash ^ healthRecordsVersion) * 0x01000193) & 0xffffffff;
for (var i = 0; i < payload.lengthInBytes; i++) {
hash = ((hash ^ payload.getUint8(i)) * 0x01000193) & 0xffffffff;
}

View file

@ -33,6 +33,12 @@ enum HealthRecordKind() {
heartRate,
speed,
power,
/// A ride's crank cadence (D51).
pedalling,
/// A run's or walk's steps a minute (D51).
stepsCadence,
distance,
elevation,
}

View file

@ -1,14 +1,16 @@
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The workout [id] as Health Connect's records (ADR 0030, D31), each at
/// [version]: its session (activity, name, notes, its pauses, its route as
/// its page draws it), its heart rate, speed and the rower's watts as
/// samples in records of [maxChunkLength] at most, its distance and
/// elevation gained as totals. Each record's client ID is the workout's,
/// its page draws it), its heart rate, speed (the wheel's where the
/// distance is, D51), the rower's or the power meter's watts, the crank's
/// cadence and the steps' as samples in records of [maxChunkLength] at
/// most, its distance and elevation gained as totals. Each record's client ID is the workout's,
/// with its kind and its stretch's number after it.
List<HealthRecord> healthRecordsOf(
String id,
@ -34,16 +36,25 @@ List<HealthRecord> healthRecordsOf(
pauses.add((pausedAt, end));
}
final speeds = [
for (final piece in analysis.track)
for (var i = 1; i < piece.length; i++)
if (piece[i].time.difference(piece[i - 1].time) case final gap
when gap > Duration.zero)
(
piece[i].time,
metresBetween(piece[i - 1], piece[i]) / (gap.inMilliseconds / 1000),
),
];
final speeds = rodeByWheel(recording)
? _wheelSpeeds(recording)
: [
for (final piece in analysis.track)
for (var i = 1; i < piece.length; i++)
if (piece[i].time.difference(piece[i - 1].time) case final gap
when gap > Duration.zero)
(
piece[i].time,
metresBetween(piece[i - 1], piece[i]) /
(gap.inMilliseconds / 1000),
),
];
final steps = [
for (final chunk in series)
if (chunk.kind == SeriesKind.cadence)
for (final (i, time) in chunk.absoluteTimes.indexed)
if (chunk.columns['spm']?[i] case final spm?) (time, spm),
]..sort((a, b) => a.$1.compareTo(b.$1));
/// [samples] of [kind] in records of [maxChunkLength] at most, numbered
/// by their stretch from the start.
@ -99,7 +110,12 @@ List<HealthRecord> healthRecordsOf(
...stretched(HealthRecordKind.power, [
for (final sample in recording.rowing)
if (sample.watts case final watts?) (sample.time, watts),
for (final reading in recording.power) (reading.time, reading.value),
]),
...stretched(HealthRecordKind.pedalling, [
for (final reading in recording.pedalling) (reading.time, reading.value),
]),
...stretched(HealthRecordKind.stepsCadence, steps),
if (content.distanceMetres case final metres? when metres > 0)
total(HealthRecordKind.distance, metres),
if (analysis.elevationGainMetres case final metres? when metres > 0)
@ -107,6 +123,37 @@ List<HealthRecord> healthRecordsOf(
];
}
/// The wheel's speed (D51), in metres a second, over each stretch of 5 s
/// at least between its readings, those while paused left out.
List<(DateTime, double)> _wheelSpeeds(Recording recording) {
final mm = recording.wheelMm;
if (mm == null) {
return const [];
}
final pauses = recording.pauses;
var next = 0;
var paused = false;
Reading? from;
final speeds = <(DateTime, double)>[];
for (final reading in recording.wheel) {
while (next < pauses.length && !pauses[next].time.isAfter(reading.time)) {
paused = pauses[next++].paused;
from = null;
}
if (paused) {
continue;
}
final start = from ??= reading;
final gap = reading.time.difference(start.time);
if (gap >= const Duration(seconds: 5)) {
final metres = (reading.value - start.value) * mm / 1000;
speeds.add((reading.time, metres / (gap.inMilliseconds / 1000)));
from = reading;
}
}
return speeds;
}
/// [records]' client IDs by their kind, as a deletion names them.
Map<HealthRecordKind, List<String>> clientIdsOf(List<HealthRecord> records) {
final ids = <HealthRecordKind, List<String>>{};

View file

@ -173,6 +173,30 @@ void main() {
expect(platform.writes, writes + 1);
});
test('written by an earlier Train, which wrote less of it, a workout is '
'written again as the app opens (D51)', () async {
await workouts.save(run(0), const []);
final before = open();
await before.setWriting(on: true);
await settled(before);
await before.dispose();
opened.remove(before);
// What Train before D51 kept: another fingerprint for the same workout.
final file = File('${folder.path}/health_connect.json');
file.writeAsStringSync(
file.readAsStringSync().replaceAllMapped(
RegExp(r'"fingerprint":(\d+)'),
(match) => '"fingerprint":${int.parse(match[1]!) ^ 1}',
),
);
final writes = platform.writes;
final after = open();
await settled(after);
expect(platform.writes, writes + 1);
});
test('what was written is remembered when the app opens again', () async {
await workouts.save(run(0), const []);
final before = open();

View file

@ -5,9 +5,9 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workou
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What a workout becomes in Health Connect (ADR 0030, D31): its session
/// with its pauses and route, its heart rate, speed and the rower's watts
/// as samples, its distance and elevation gained as totals, each record's
/// What a workout becomes in Health Connect (ADR 0030, D31, D51): its
/// session with its pauses and route, its heart rate, speed, watts and
/// cadences as samples, its distance and elevation gained as totals, each record's
/// client ID made from the workout's.
void main() {
final start = DateTime.utc(2026, 10, 28, 7, 30);
@ -192,4 +192,62 @@ void main() {
HealthRecordKind.distance: ['w1/distance'],
});
});
test("a ride's power meter, crank and wheel: watts, pedalling, and the "
"wheel's speed where GPS stood still (D51)", () {
final trainer = Recording(
activity: Activity.cycling,
startedAt: start,
fixes: [for (var s = 0; s <= 60; s += 10) north(s, 0)],
wheel: [for (var s = 0; s <= 60; s++) Reading(time: at(s), value: s * 4)],
wheelMm: 2100,
pedalling: [
for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 90),
],
power: [for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 210)],
endedAt: at(60),
);
final records = recordsOf(trainer);
final power = records.whereType<HealthSamples>().singleWhere(
(r) => r.kind == HealthRecordKind.power,
);
expect(power.samples.first.$2, 210);
expect(power.clientId, 'w1/power/0');
final pedalling = records.whereType<HealthSamples>().singleWhere(
(r) => r.kind == HealthRecordKind.pedalling,
);
expect(pedalling.samples, hasLength(61));
expect(pedalling.samples.first.$2, 90);
final speed = records.whereType<HealthSamples>().singleWhere(
(r) => r.kind == HealthRecordKind.speed,
);
// Four turns of 2.1 m a second.
expect(speed.samples, hasLength(12));
for (final (_, metresPerSecond) in speed.samples) {
expect(metresPerSecond, closeTo(8.4, 0.01));
}
});
test("a run's steps go as its cadence (D51)", () {
final runner = Recording(
activity: Activity.running,
startedAt: start,
fixes: [for (var s = 0; s <= 60; s += 10) north(s, s * 3)],
steps: [
for (var i = 0; i < 170; i++)
at(0).add(Duration(milliseconds: i * 353)),
],
endedAt: at(60),
);
final cadence = recordsOf(runner)
.whereType<HealthSamples>()
.singleWhere((r) => r.kind == HealthRecordKind.stepsCadence);
expect(cadence.samples, isNotEmpty);
for (final (_, spm) in cadence.samples) {
expect(spm, closeTo(170, 6));
}
expect(cadence.clientId, 'w1/stepsCadence/0');
});
}