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
15 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_HEART_RATE"/>
<uses-permission android:name="android.permission.health.WRITE_SPEED"/> <uses-permission android:name="android.permission.health.WRITE_SPEED"/>
<uses-permission android:name="android.permission.health.WRITE_POWER"/> <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_DISTANCE"/>
<uses-permission android:name="android.permission.health.WRITE_ELEVATION_GAINED"/> <uses-permission android:name="android.permission.health.WRITE_ELEVATION_GAINED"/>
<uses-permission android:name="android.permission.health.READ_EXERCISE"/> <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 // 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; // 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); // 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 // Times in milliseconds; each call returns true where it paused, false where
// it went on, null where nothing changed. // it went on, null where nothing changed.
@ -32,6 +33,7 @@ class AutoPause(private val signal: Signal) {
private var slowSince: Long? = null private var slowSince: Long? = null
private var lastFixAt: Long? = null private var lastFixAt: Long? = null
private var lastFixFast = false 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). */ /** A step at [at]: two close together go on again (a lone one is a jostle). */
fun step(at: Long): Boolean? { fun step(at: Long): Boolean? {
@ -66,6 +68,20 @@ class AutoPause(private val signal: Signal) {
lastFixFast = kmh > 5 lastFixFast = kmh > 5
return null 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 if (byHand) return null
val (below, above) = if (signal == Signal.BIKE_SPEED) 3.0 to 5.0 else 2.0 to 3.0 val (below, above) = if (signal == Signal.BIKE_SPEED) 3.0 to 5.0 else 2.0 to 3.0
if (paused) { if (paused) {
@ -119,5 +135,6 @@ class AutoPause(private val signal: Signal) {
const val STILL_MS = 5_000L const val STILL_MS = 5_000L
const val STEPS_TO_GO_ON_MS = 2_000L const val STEPS_TO_GO_ON_MS = 2_000L
const val FRESH_FIX_MS = 3_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.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) 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.changes.UpsertionChange
import androidx.health.connect.client.contracts.ExerciseRouteRequestContract import androidx.health.connect.client.contracts.ExerciseRouteRequestContract
import androidx.health.connect.client.permission.HealthPermission 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.DistanceRecord
import androidx.health.connect.client.records.ElevationGainedRecord import androidx.health.connect.client.records.ElevationGainedRecord
import androidx.health.connect.client.records.ExerciseRouteResult 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.PowerRecord
import androidx.health.connect.client.records.Record import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.SpeedRecord 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.records.metadata.DataOrigin
import androidx.health.connect.client.request.ChangesTokenRequest import androidx.health.connect.client.request.ChangesTokenRequest
import androidx.health.connect.client.request.ReadRecordsRequest import androidx.health.connect.client.request.ReadRecordsRequest
@ -90,8 +92,12 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
"granted" -> result.success(granted()) "granted" -> result.success(granted())
"ask" -> ask(call.arguments<List<String>>().orEmpty(), result) "ask" -> ask(call.arguments<List<String>>().orEmpty(), result)
"write" -> { "write" -> {
val withRoute = HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE in grantedPermissions() val granted = grantedPermissions()
val records = HealthRecords.recordsOf(call.arguments<List<Map<String, Any?>>>().orEmpty(), withRoute) 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) if (records.isNotEmpty()) client.insertRecords(records)
result.success(null) 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) { 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) { if (wanted.isEmpty() || asking != null) {
result.success(granted()) result.success(granted())
return return
@ -308,5 +315,18 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
), ),
"history" to setOf(HealthPermission.PERMISSION_READ_HEALTH_DATA_HISTORY), "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 package app.halkyon.train
import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
import androidx.health.connect.client.records.DistanceRecord import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ElevationGainedRecord import androidx.health.connect.client.records.ElevationGainedRecord
import androidx.health.connect.client.records.ExerciseRoute 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.PowerRecord
import androidx.health.connect.client.records.Record import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.SpeedRecord 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.Device
import androidx.health.connect.client.records.metadata.Metadata import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.units.Length import androidx.health.connect.client.units.Length
@ -34,6 +36,8 @@ object HealthRecords {
"heartRate" to HeartRateRecord::class, "heartRate" to HeartRateRecord::class,
"speed" to SpeedRecord::class, "speed" to SpeedRecord::class,
"power" to PowerRecord::class, "power" to PowerRecord::class,
"pedalling" to CyclingPedalingCadenceRecord::class,
"stepsCadence" to StepsCadenceRecord::class,
"distance" to DistanceRecord::class, "distance" to DistanceRecord::class,
"elevation" to ElevationGainedRecord::class, "elevation" to ElevationGainedRecord::class,
) )
@ -75,8 +79,16 @@ object HealthRecords {
* Health Connect would refuse (a heart rate beyond 1 to 300, say) are * Health Connect would refuse (a heart rate beyond 1 to 300, say) are
* left out rather than failing the whole workout. * 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? { private fun recordOf(map: Map<String, Any?>, withRoute: Boolean): Record? {
val start = instant(map["start"]) val start = instant(map["start"])
@ -142,6 +154,30 @@ object HealthRecords {
.map { PowerRecord.Sample(instant(it[0]), Power.watts((it[1] as Number).toDouble())) }, .map { PowerRecord.Sample(instant(it[0]), Power.watts((it[1] as Number).toDouble())) },
metadata = metadata, metadata = metadata,
).takeIf { it.samples.isNotEmpty() } ).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" -> "distance" ->
DistanceRecord( DistanceRecord(
startTime = start, startTime = start,

View file

@ -165,6 +165,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var speedSensor: NotifyLink? = null private var speedSensor: NotifyLink? = null
private var powerMeter: NotifyLink? = null private var powerMeter: NotifyLink? = null
private val wheel = WheelCount() private val wheel = WheelCount()
private var wheelSpeed: WheelSpeed? = null
private val crank = CrankCadence() private val crank = CrankCadence()
private val powerCrank = CrankCadence() private val powerCrank = CrankCadence()
private var sensorCranks = false private var sensorCranks = false
@ -353,7 +354,11 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
} }
speedSensorAt?.let { address -> speedSensorAt?.let { address ->
// What the app works the wheel's distance out by (D49). // 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 = speedSensor =
NotifyLink( NotifyLink(
this, this,
@ -626,7 +631,13 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
*/ */
private fun sensed(csc: Csc) { private fun sensed(csc: Csc) {
val at = System.currentTimeMillis() 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) { if (csc.crankRevolutions != null && csc.crankTime != null) {
sensorCranks = true sensorCranks = true
crank.add(at, csc.crankRevolutions, csc.crankTime)?.let { write("c $at $it") } 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. // 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) 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 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.DistanceRecord
import androidx.health.connect.client.records.ExerciseSegment import androidx.health.connect.client.records.ExerciseSegment
import androidx.health.connect.client.records.ExerciseSessionRecord import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.PowerRecord import androidx.health.connect.client.records.PowerRecord
import androidx.health.connect.client.records.StepsCadenceRecord
import java.io.File
import java.time.Instant import java.time.Instant
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@ -138,4 +142,53 @@ class HealthRecordsTest {
assertEquals(activity, HealthRecords.activityOf(HealthRecords.exerciseOf(activity))) 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, key: gone,
}; };
/// A fingerprint of [payload] that stays the same across runs (FNV-1a, 32 /// What [healthRecordsOf] gives, counted up as it gives more (2: the
/// bits): only whether a workout changed since it was written, nothing to /// cadences, the power meter's watts and the wheel's speed, D51), so that
/// keep secret. /// 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) { int _fingerprintOf(ByteData payload) {
var hash = 0x811c9dc5; var hash = 0x811c9dc5;
hash = ((hash ^ healthRecordsVersion) * 0x01000193) & 0xffffffff;
for (var i = 0; i < payload.lengthInBytes; i++) { for (var i = 0; i < payload.lengthInBytes; i++) {
hash = ((hash ^ payload.getUint8(i)) * 0x01000193) & 0xffffffff; hash = ((hash ^ payload.getUint8(i)) * 0x01000193) & 0xffffffff;
} }

View file

@ -33,6 +33,12 @@ enum HealthRecordKind() {
heartRate, heartRate,
speed, speed,
power, power,
/// A ride's crank cadence (D51).
pedalling,
/// A run's or walk's steps a minute (D51).
stepsCadence,
distance, distance,
elevation, 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/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/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/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/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.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 /// The workout [id] as Health Connect's records (ADR 0030, D31), each at
/// [version]: its session (activity, name, notes, its pauses, its route as /// [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 /// its page draws it), its heart rate, speed (the wheel's where the
/// samples in records of [maxChunkLength] at most, its distance and /// distance is, D51), the rower's or the power meter's watts, the crank's
/// elevation gained as totals. Each record's client ID is the workout'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. /// with its kind and its stretch's number after it.
List<HealthRecord> healthRecordsOf( List<HealthRecord> healthRecordsOf(
String id, String id,
@ -34,16 +36,25 @@ List<HealthRecord> healthRecordsOf(
pauses.add((pausedAt, end)); pauses.add((pausedAt, end));
} }
final speeds = [ final speeds = rodeByWheel(recording)
for (final piece in analysis.track) ? _wheelSpeeds(recording)
for (var i = 1; i < piece.length; i++) : [
if (piece[i].time.difference(piece[i - 1].time) case final gap for (final piece in analysis.track)
when gap > Duration.zero) for (var i = 1; i < piece.length; i++)
( if (piece[i].time.difference(piece[i - 1].time) case final gap
piece[i].time, when gap > Duration.zero)
metresBetween(piece[i - 1], piece[i]) / (gap.inMilliseconds / 1000), (
), 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 /// [samples] of [kind] in records of [maxChunkLength] at most, numbered
/// by their stretch from the start. /// by their stretch from the start.
@ -99,7 +110,12 @@ List<HealthRecord> healthRecordsOf(
...stretched(HealthRecordKind.power, [ ...stretched(HealthRecordKind.power, [
for (final sample in recording.rowing) for (final sample in recording.rowing)
if (sample.watts case final watts?) (sample.time, watts), 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) if (content.distanceMetres case final metres? when metres > 0)
total(HealthRecordKind.distance, metres), total(HealthRecordKind.distance, metres),
if (analysis.elevationGainMetres case final metres? when metres > 0) 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. /// [records]' client IDs by their kind, as a deletion names them.
Map<HealthRecordKind, List<String>> clientIdsOf(List<HealthRecord> records) { Map<HealthRecordKind, List<String>> clientIdsOf(List<HealthRecord> records) {
final ids = <HealthRecordKind, List<String>>{}; final ids = <HealthRecordKind, List<String>>{};

View file

@ -173,6 +173,30 @@ void main() {
expect(platform.writes, writes + 1); 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 { test('what was written is remembered when the app opens again', () async {
await workouts.save(run(0), const []); await workouts.save(run(0), const []);
final before = open(); 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/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart'; import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What a workout becomes in Health Connect (ADR 0030, D31): its session /// What a workout becomes in Health Connect (ADR 0030, D31, D51): its
/// with its pauses and route, its heart rate, speed and the rower's watts /// session with its pauses and route, its heart rate, speed, watts and
/// as samples, its distance and elevation gained as totals, each record's /// cadences as samples, its distance and elevation gained as totals, each record's
/// client ID made from the workout's. /// client ID made from the workout's.
void main() { void main() {
final start = DateTime.utc(2026, 10, 28, 7, 30); final start = DateTime.utc(2026, 10, 28, 7, 30);
@ -192,4 +192,62 @@ void main() {
HealthRecordKind.distance: ['w1/distance'], 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');
});
} }

View file

@ -1281,6 +1281,35 @@ Strava. What was found, 27 Oct:
deploy, the size of Europe's database unpacked and Photon's memory on it, whether deploy, the size of Europe's database unpacked and Photon's memory on it, whether
1.3.0 reads GraphHopper's `1.0` build (the folder GraphHopper offers for Photon's 1.3.0 reads GraphHopper's `1.0` build (the folder GraphHopper offers for Photon's
1.x releases), the field on a phone. 1.x releases), the field on a phone.
- **D51 What a ride's sensors and a run's steps give Health Connect, and the wheel's
auto-pause** (recommended, the developer's; 29 Oct; step 13, what D49 left open):
- **Health Connect** (13a): a ride's power meter's watts as `PowerRecord` samples (as
the rower's), its crank's cadence as `CyclingPedalingCadenceRecord`, and where its
distance is the wheel's (D49) the wheel's speed as `SpeedRecord` in place of GPS's;
a run's or walk's steps' cadence as `StepsCadenceRecord` (the cadence series, D48),
over a ride's alone. The two cadences' permissions are asked with writing's, but
writing stays on without them, so that those who switched it on before keep it:
records Health Connect hasn't allowed are left out. Workouts written before are
written again with them, as any change to what a workout gives is.
- **Auto-pause by the wheel** (13b): while the speed sensor sends, the wheel's speed
decides with a bike's thresholds (paused below 3 km/h for 5 s, going on above
5 km/h); GPS's again once the wheel has been silent 5 s (over moving while either
says so, which never pauses on a trainer where GPS drifts).
- Built: 13a and 13b. `healthRecordsOf` adds the power meter's watts to the rower's,
the crank's cadence (`pedalling`) and the steps' (`stepsCadence`, the series' 10 s
windows), and on a ride whose distance is the wheel's its speed over stretches of
5 s at least (`_wheelSpeeds`), pauses left out. `HealthRecords.kt` maps them to
`CyclingPedalingCadenceRecord` and `StepsCadenceRecord`; the bridge asks for their
permissions (`WRITE_EXERCISE` and `WRITE_STEPS`, as Health Connect names them,
which a JVM test holds the manifest to for every kind written) with writing's
(`ASKED_WITH`), and leaves out the records not allowed. So that workouts written
before are written again, the fingerprint deciding it now folds in
`healthRecordsVersion` (2): every workout is written once more after the update.
`AutoPause.wheel` takes the wheel's speed (`WheelSpeed` in `CyclingSensors.kt`,
over at least half a second, past the 32-bit rollover, by the circumference the
ride starts with), and GPS's speed is ignored while the wheel sent within 5 s; a
ride without a circumference (no speed sensor) pauses by GPS as before. Not tried
with a real sensor or Health Connect.
## Consequences ## Consequences