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
8 changed files with 400 additions and 29 deletions

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