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
8 changed files with 400 additions and 29 deletions
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue