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:
parent
edd2df79bd
commit
64366d5420
15 changed files with 400 additions and 29 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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") }
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>>{};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue