feat(train): the Loop's training sessions as workouts (ADR 0030, D39)
Each read of the band lists the sessions it holds, reads those from the read's first day on (`LoopDays.kt`, one line per exercise: its start with the band's offset, length, Polar's sport ID, figures and heart rate at its interval), and deletes those up to the day kept, as the days. The app keeps each as a workout of origin Polar Loop with its heart rate (`takeLoopSessions`), Polar's running and cycling as Train's and every other sport as the new `Activity.other`, which a workout's edit sheet now changes; a session overlapping a workout Train recorded gives it its heart rate where it has none instead. The sessions taken are remembered in `loop.json`, so one deleted in Train isn't brought back. Health Connect writes the Loop's workouts as Train's own. The SDK gives sessions as its protobuf messages: protobuf-javalite 3.20.0, the SDK's own, is named for compiling only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
06cc39025b
commit
2c5467a282
6 changed files with 794 additions and 32 deletions
|
|
@ -57,6 +57,10 @@ dependencies {
|
|||
implementation("androidx.health.connect:connect-client:1.1.0")
|
||||
// The Polar Loop (ADR 0030, D32, D34): Polar's BLE SDK, under Polar's licence.
|
||||
implementation("com.github.polarofficial:polar-ble-sdk:8.3.0")
|
||||
// The protobuf runtime the SDK brings at run time, at its version, to read
|
||||
// the training sessions it gives as Polar's messages (ADR 0030, D39).
|
||||
compileOnly("com.google.protobuf:protobuf-javalite:3.20.0")
|
||||
testImplementation("com.google.protobuf:protobuf-javalite:3.20.0")
|
||||
// The Loop read every few hours while Train is closed (ADR 0030, D37).
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.1")
|
||||
// The recorder's pure Kotlin parts (auto-pause) tested on the JVM.
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
|||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TRAINING_DATA,
|
||||
),
|
||||
).apply {
|
||||
holding = true
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import com.polar.sdk.api.model.activity.PolarActiveTime
|
|||
import com.polar.sdk.api.model.activity.PolarDailySummaryData
|
||||
import com.polar.sdk.api.model.sleep.PolarNightlyRechargeData
|
||||
import com.polar.sdk.api.model.sleep.PolarSleepData
|
||||
import com.polar.sdk.api.model.trainingsession.PolarExercise
|
||||
import com.polar.sdk.api.model.trainingsession.PolarTrainingSession
|
||||
import com.polar.sdk.api.model.trainingsession.PolarTrainingSessionReference
|
||||
import fi.polar.remote.representation.protobuf.Types
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
|
|
@ -28,6 +34,13 @@ interface LoopSource {
|
|||
suspend fun skinTemperatures(from: LocalDate, to: LocalDate): List<PolarSkinTemperatureData>
|
||||
|
||||
suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate)
|
||||
|
||||
/** Every training session the band holds (D39): few, as kept ones go. */
|
||||
suspend fun sessions(): List<PolarTrainingSessionReference>
|
||||
|
||||
suspend fun session(reference: PolarTrainingSessionReference): PolarTrainingSession
|
||||
|
||||
suspend fun deleteSession(reference: PolarTrainingSessionReference)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,8 +87,19 @@ object LoopDays {
|
|||
source.skinTemperatures(from, today).mapNotNullTo(lines, ::skinOf)
|
||||
}
|
||||
val until = keptUntil?.let { minOf(it, today.minusDays(1)) }
|
||||
// Every session the band holds is listed, those from [from] on read,
|
||||
// those up to [keptUntil] deleted below, as the days are (D39).
|
||||
val sessions = source.sessions()
|
||||
for (reference in sessions) {
|
||||
if (!reference.date.isBefore(from) && !reference.date.isAfter(today)) {
|
||||
lines += sessionsOf(source.session(reference))
|
||||
}
|
||||
}
|
||||
if (until != null) {
|
||||
for (type in deletedTypes) source.delete(type, until)
|
||||
for (reference in sessions) {
|
||||
if (!reference.date.isAfter(until)) source.deleteSession(reference)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
source.stopSync()
|
||||
|
|
@ -174,6 +198,57 @@ object LoopDays {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A training session, one line per exercise (a multisport session has
|
||||
* several): its band reference and index as its ID, its start with the
|
||||
* band's offset, its length, Polar's sport ID, its figures and its samples
|
||||
* at their interval (heart rate in bpm, 0 where none was read; speed in
|
||||
* km/h; cadence).
|
||||
*/
|
||||
fun sessionsOf(session: PolarTrainingSession): List<Map<String, Any?>> =
|
||||
session.exercises.mapNotNull { exercise -> exerciseOf(session, exercise) }
|
||||
|
||||
private fun exerciseOf(session: PolarTrainingSession, exercise: PolarExercise): Map<String, Any?>? {
|
||||
val base = exercise.exerciseSummary
|
||||
val summary = session.sessionSummary
|
||||
val start = (base?.takeIf { it.hasStart() }?.start ?: summary?.takeIf { it.hasStart() }?.start) ?: return null
|
||||
val samples = exercise.samples
|
||||
return mapOf(
|
||||
"kind" to "session",
|
||||
"date" to dateOf(session.reference.date),
|
||||
"id" to "${session.reference.path}#${exercise.index}",
|
||||
"start" to startOf(start),
|
||||
"seconds" to (base?.takeIf { it.hasDuration() }?.duration ?: summary?.duration)?.let(::secondsOf),
|
||||
"sport" to (base?.takeIf { it.hasSport() }?.sport ?: summary?.takeIf { it.hasSport() }?.sport)?.value,
|
||||
"calories" to base?.takeIf { it.hasCalories() }?.calories,
|
||||
"metres" to base?.takeIf { it.hasDistance() }?.distance,
|
||||
"ascent" to base?.takeIf { it.hasAscent() }?.ascent,
|
||||
"descent" to base?.takeIf { it.hasDescent() }?.descent,
|
||||
"interval" to samples?.takeIf { it.hasRecordingInterval() }?.recordingInterval?.let(::secondsOf),
|
||||
"heart" to samples?.heartRateSamplesList,
|
||||
"speed" to samples?.speedSamplesList,
|
||||
"cadence" to samples?.cadenceSamplesList,
|
||||
)
|
||||
}
|
||||
|
||||
/** The band's local time with its offset, as ISO 8601. */
|
||||
fun startOf(time: Types.PbLocalDateTime): String {
|
||||
val local =
|
||||
LocalDateTime.of(
|
||||
time.date.year,
|
||||
time.date.month,
|
||||
time.date.day,
|
||||
time.time.hour,
|
||||
time.time.minute,
|
||||
time.time.seconds,
|
||||
time.time.millis * 1_000_000,
|
||||
)
|
||||
return local.atOffset(ZoneOffset.ofTotalSeconds(time.timeZoneOffset * 60)).toString()
|
||||
}
|
||||
|
||||
private fun secondsOf(duration: Types.PbDuration): Double =
|
||||
duration.hours * 3600.0 + duration.minutes * 60 + duration.seconds + duration.millis / 1000.0
|
||||
|
||||
private fun secondsOf(time: PolarActiveTime?): Int? =
|
||||
time?.let { it.hours * 3600 + it.minutes * 60 + it.seconds }
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class LoopWorker(context: Context, parameters: WorkerParameters) : CoroutineWork
|
|||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TRAINING_DATA,
|
||||
),
|
||||
)
|
||||
api.setApiCallback(ReadyCallback(config.id, ready))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import com.polar.sdk.api.PolarBleApi
|
||||
import com.polar.sdk.api.model.trainingsession.PolarTrainingSessionReference
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.flow.toList
|
||||
|
||||
/** [LoopSource] on Polar's SDK, for the band [id] (ADR 0030, D37). */
|
||||
class PolarLoopSource(private val api: PolarBleApi, private val id: String) : LoopSource {
|
||||
|
|
@ -26,4 +28,12 @@ class PolarLoopSource(private val api: PolarBleApi, private val id: String) : Lo
|
|||
override suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate) {
|
||||
api.deleteStoredDeviceData(id, type, until)
|
||||
}
|
||||
|
||||
override suspend fun sessions() = api.getTrainingSessionReferences(id).toList()
|
||||
|
||||
override suspend fun session(reference: PolarTrainingSessionReference) = api.getTrainingSession(id, reference)
|
||||
|
||||
override suspend fun deleteSession(reference: PolarTrainingSessionReference) {
|
||||
api.deleteTrainingSession(id, reference)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ import com.polar.sdk.api.model.sleep.SleepRating
|
|||
import com.polar.sdk.api.model.sleep.SleepSkinTemperatureResult
|
||||
import com.polar.sdk.api.model.sleep.SleepWakePhase
|
||||
import com.polar.sdk.api.model.sleep.SleepWakeState
|
||||
import com.polar.sdk.api.model.trainingsession.PolarExercise
|
||||
import com.polar.sdk.api.model.trainingsession.PolarTrainingSession
|
||||
import com.polar.sdk.api.model.trainingsession.PolarTrainingSessionReference
|
||||
import fi.polar.remote.representation.protobuf.ExerciseSamples
|
||||
import fi.polar.remote.representation.protobuf.Structures
|
||||
import fi.polar.remote.representation.protobuf.Training
|
||||
import fi.polar.remote.representation.protobuf.TrainingSession
|
||||
import fi.polar.remote.representation.protobuf.Types
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
|
|
@ -132,7 +140,35 @@ class LoopDaysTest {
|
|||
),
|
||||
)
|
||||
|
||||
private inner class FakeBand : LoopSource {
|
||||
private fun duration(seconds: Int) = Types.PbDuration.newBuilder().setHours(seconds / 3600).setMinutes(seconds / 60 % 60).setSeconds(seconds % 60).buildPartial()
|
||||
|
||||
private fun reference(date: LocalDate, path: String) = PolarTrainingSessionReference(date, path, emptyList(), emptyList(), 0)
|
||||
|
||||
/** A session the band detected: 45 minutes from 17:30 at +01:00, heart rate every second. */
|
||||
private fun session(reference: PolarTrainingSessionReference): PolarTrainingSession {
|
||||
val start =
|
||||
Types.PbLocalDateTime.newBuilder()
|
||||
.setDate(Types.PbDate.newBuilder().setYear(reference.date.year).setMonth(reference.date.monthValue).setDay(reference.date.dayOfMonth))
|
||||
.setTime(Types.PbTime.newBuilder().setHour(17).setMinute(30).setSeconds(0).setMillis(0))
|
||||
.setTimeZoneOffset(60)
|
||||
.buildPartial()
|
||||
val base =
|
||||
Training.PbExerciseBase.newBuilder()
|
||||
.setStart(start)
|
||||
.setDuration(duration(45 * 60))
|
||||
.setSport(Structures.PbSportIdentifier.newBuilder().setValue(16))
|
||||
.setCalories(380)
|
||||
.buildPartial()
|
||||
val samples =
|
||||
ExerciseSamples.PbExerciseSamples.newBuilder()
|
||||
.setRecordingInterval(duration(1))
|
||||
.addAllHeartRateSamples(listOf(0, 120, 135))
|
||||
.buildPartial()
|
||||
val exercise = PolarExercise(0, "${reference.path}/00", emptyList(), base, null, null, samples, null, emptyMap())
|
||||
return PolarTrainingSession(reference, TrainingSession.PbTrainingSession.newBuilder().buildPartial(), listOf(exercise))
|
||||
}
|
||||
|
||||
private inner class FakeBand(val held: List<PolarTrainingSessionReference> = emptyList()) : LoopSource {
|
||||
val calls = mutableListOf<String>()
|
||||
|
||||
override suspend fun startSync() {
|
||||
|
|
@ -156,6 +192,15 @@ class LoopDaysTest {
|
|||
override suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate) {
|
||||
calls += "delete $type $until"
|
||||
}
|
||||
|
||||
override suspend fun sessions() = held
|
||||
|
||||
override suspend fun session(reference: PolarTrainingSessionReference): PolarTrainingSession =
|
||||
this@LoopDaysTest.session(reference).also { calls += "session ${reference.path}" }
|
||||
|
||||
override suspend fun deleteSession(reference: PolarTrainingSessionReference) {
|
||||
calls += "deleteSession ${reference.path}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -261,4 +306,32 @@ class LoopDaysTest {
|
|||
JsonText.of(mapOf("kind" to "read", "n" to listOf(1, 2.5, null, true), "s" to "a\"b")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session the band recorded is a line of its own, its start with the band's offset`() {
|
||||
val line = LoopDays.sessionsOf(session(reference(yesterday, "/U/0/20261027/E/173000"))).single()
|
||||
|
||||
assertEquals("session", line["kind"])
|
||||
assertEquals("2026-10-27", line["date"])
|
||||
assertEquals("/U/0/20261027/E/173000#0", line["id"])
|
||||
assertEquals("2026-10-27T17:30+01:00", line["start"])
|
||||
assertEquals(2700.0, line["seconds"])
|
||||
assertEquals(16L, line["sport"])
|
||||
assertEquals(380, line["calories"])
|
||||
assertEquals(1.0, line["interval"])
|
||||
assertEquals(listOf(0, 120, 135), line["heart"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sessions from the read's first day are read, those kept deleted, never today's`() {
|
||||
val kept = reference(yesterday.minusDays(1), "/kept")
|
||||
val new = reference(yesterday, "/new")
|
||||
val now = reference(today, "/today")
|
||||
val band = FakeBand(listOf(kept, new, now))
|
||||
|
||||
val lines = runBlocking { LoopDays.read(band, yesterday, today, keptUntil = yesterday) }
|
||||
|
||||
assertEquals(listOf("/new#0", "/today#0"), lines.filter { it["kind"] == "session" }.map { it["id"] })
|
||||
assertEquals(listOf("deleteSession /kept", "deleteSession /new"), band.calls.filter { it.startsWith("deleteSession") })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue