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:
Tizian 「ティツィアーン」 2026-10-28 20:22:23 +01:00
commit 2c5467a282
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 794 additions and 32 deletions

View file

@ -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.

View file

@ -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

View file

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

View file

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

View file

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

View file

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