feat(train): the Loop's days read on the phone (ADR 0030, D37, D38)
The server gains Train's synced `day`, `day_series` (a day's heart rate, deleted with its day) and `night`, each a sealed payload alone; the migration `days` only adds their tables, indexes and foreign keys (to their sync space, and a chunk's to its day, deferred), and the person's deletion takes them with their spaces. On Android, `LoopDays.kt` reads a band's days from a date to today, framed by the SDK's sync start and stop, into lines of JSON, then deletes on the band what the app kept up to the date it names, never today; `LoopBridge` answers `readDays` with them, one band call at a time, and `background` schedules `LoopWorker`, a WorkManager job every four hours that appends the same lines to the person's `loop_days.txt` while the app doesn't hold the band. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
24de3c0a4e
commit
88e8693025
7 changed files with 24637 additions and 52 deletions
|
|
@ -57,6 +57,8 @@ 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 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.
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
package app.halkyon.train
|
||||
|
||||
/**
|
||||
* Maps, lists, strings, numbers, booleans and nulls as one line of JSON, for
|
||||
* the Loop's file (ADR 0030, D37): Android's `org.json` is a stub in JVM
|
||||
* tests, and nothing more is needed.
|
||||
*/
|
||||
object JsonText {
|
||||
fun of(value: Any?): String = StringBuilder().also { write(it, value) }.toString()
|
||||
|
||||
private fun write(out: StringBuilder, value: Any?) {
|
||||
when (value) {
|
||||
null -> out.append("null")
|
||||
is Boolean -> out.append(value)
|
||||
is Float -> number(out, value.toDouble())
|
||||
is Double -> number(out, value)
|
||||
is Number -> out.append(value.toLong())
|
||||
is String -> string(out, value)
|
||||
is Map<*, *> -> {
|
||||
out.append('{')
|
||||
var first = true
|
||||
for ((key, item) in value) {
|
||||
if (!first) out.append(',')
|
||||
first = false
|
||||
string(out, key.toString())
|
||||
out.append(':')
|
||||
write(out, item)
|
||||
}
|
||||
out.append('}')
|
||||
}
|
||||
is Iterable<*> -> {
|
||||
out.append('[')
|
||||
var first = true
|
||||
for (item in value) {
|
||||
if (!first) out.append(',')
|
||||
first = false
|
||||
write(out, item)
|
||||
}
|
||||
out.append(']')
|
||||
}
|
||||
else -> string(out, value.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun number(out: StringBuilder, value: Double) {
|
||||
if (value.isNaN() || value.isInfinite()) out.append("null") else out.append(value)
|
||||
}
|
||||
|
||||
private fun string(out: StringBuilder, value: String) {
|
||||
out.append('"')
|
||||
for (char in value) {
|
||||
when {
|
||||
char == '"' -> out.append("\\\"")
|
||||
char == '\\' -> out.append("\\\\")
|
||||
char == '\n' -> out.append("\\n")
|
||||
char == '\r' -> out.append("\\r")
|
||||
char == '\t' -> out.append("\\t")
|
||||
char < ' ' -> out.append(String.format("\\u%04x", char.code))
|
||||
else -> out.append(char)
|
||||
}
|
||||
}
|
||||
out.append('"')
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import com.polar.sdk.api.model.PolarHrData
|
|||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.UUID
|
||||
|
|
@ -21,6 +22,8 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* The Polar Loop for the app (ADR 0030, D32, D34), through Polar's BLE SDK,
|
||||
|
|
@ -31,6 +34,7 @@ import kotlinx.coroutines.launch
|
|||
*/
|
||||
class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler {
|
||||
private val scope: CoroutineScope = MainScope()
|
||||
private val calls = Mutex()
|
||||
private var events: EventChannel.EventSink? = null
|
||||
|
||||
// The SDK made on first use, so that an app without a Loop never starts it.
|
||||
|
|
@ -44,8 +48,12 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
|||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FILE_TRANSFER,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_CONTROL,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
|
||||
),
|
||||
).apply {
|
||||
holding = true
|
||||
setAutomaticReconnection(true)
|
||||
setApiCallback(callback)
|
||||
}
|
||||
|
|
@ -94,22 +102,40 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
|||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (call.method == "background") {
|
||||
// What the background job reads, and where (D37); none to stop it.
|
||||
LoopWorker.schedule(context, call.argument<Map<String, Any?>>("config"))
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
val id = call.argument<String>("id")!!
|
||||
when (call.method) {
|
||||
"connect" -> result.success(api.connectToDevice(id).let { null })
|
||||
"disconnect" -> result.success(api.disconnectFromDevice(id).let { null })
|
||||
"isSetUp" -> result.success(api.isFtuDone(id))
|
||||
"setUp" -> {
|
||||
val config = LoopSetup.configOf(call.argument<Map<String, Any?>>("figures")!!, ZonedDateTime.now())
|
||||
api.doFirstTimeUse(id, config)
|
||||
api.setLocalTime(id, LocalDateTime.now())
|
||||
result.success(null)
|
||||
// One call to the band at a time, as Polar's sync guideline asks.
|
||||
calls.withLock {
|
||||
when (call.method) {
|
||||
"connect" -> result.success(api.connectToDevice(id).let { null })
|
||||
"disconnect" -> result.success(api.disconnectFromDevice(id).let { null })
|
||||
"isSetUp" -> result.success(api.isFtuDone(id))
|
||||
"setUp" -> {
|
||||
val config = LoopSetup.configOf(call.argument<Map<String, Any?>>("figures")!!, ZonedDateTime.now())
|
||||
api.doFirstTimeUse(id, config)
|
||||
api.setLocalTime(id, LocalDateTime.now())
|
||||
result.success(null)
|
||||
}
|
||||
"setClock" -> result.success(api.setLocalTime(id, LocalDateTime.now()).let { null })
|
||||
"reset" -> result.success(api.doFactoryReset(id).let { null })
|
||||
"readDays" ->
|
||||
result.success(
|
||||
LoopDays.read(
|
||||
PolarLoopSource(api, id),
|
||||
from = LocalDate.parse(call.argument<String>("from")!!),
|
||||
today = LocalDate.now(),
|
||||
keptUntil = call.argument<String>("keptUntil")?.let(LocalDate::parse),
|
||||
),
|
||||
)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
"setClock" -> result.success(api.setLocalTime(id, LocalDateTime.now()).let { null })
|
||||
"reset" -> result.success(api.doFactoryReset(id).let { null })
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (error: SecurityException) {
|
||||
result.error("refused", error.message, null)
|
||||
|
|
@ -123,7 +149,10 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
|||
fun dispose() {
|
||||
scope.cancel()
|
||||
events = null
|
||||
if (made.isInitialized()) api.shutDown()
|
||||
if (made.isInitialized()) {
|
||||
api.shutDown()
|
||||
holding = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(id: String, event: String, vararg values: Pair<String, Any?>) {
|
||||
|
|
@ -173,8 +202,12 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
|||
override fun htsNotificationReceived(identifier: String, data: PolarHealthThermometerData) {}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
companion object {
|
||||
/** Whether the open app holds the SDK, so that the background job leaves the band alone. */
|
||||
@Volatile
|
||||
var holding = false
|
||||
|
||||
/** Device Information's Firmware Revision String (0x2A26). */
|
||||
val FIRMWARE_REVISION: UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")
|
||||
private val FIRMWARE_REVISION: UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import com.polar.sdk.api.PolarBleApi
|
||||
import com.polar.sdk.api.model.PolarSkinTemperatureData
|
||||
import com.polar.sdk.api.model.activity.Polar247HrSamplesData
|
||||
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 java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/** What a read of the Loop asks of the band, one call after the other (Polar's sync guideline). */
|
||||
interface LoopSource {
|
||||
/** Readies the band: it writes what it holds in memory to its files. */
|
||||
suspend fun startSync()
|
||||
|
||||
suspend fun stopSync()
|
||||
|
||||
suspend fun summaries(from: LocalDate, to: LocalDate): List<PolarDailySummaryData>
|
||||
|
||||
suspend fun heartRates(from: LocalDate, to: LocalDate): List<Polar247HrSamplesData>
|
||||
|
||||
suspend fun sleeps(from: LocalDate, to: LocalDate): List<PolarSleepData>
|
||||
|
||||
suspend fun recharges(from: LocalDate, to: LocalDate): List<PolarNightlyRechargeData>
|
||||
|
||||
suspend fun skinTemperatures(from: LocalDate, to: LocalDate): List<PolarSkinTemperatureData>
|
||||
|
||||
suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Loop's days and nights (ADR 0030, D37, D38): a read from a date to
|
||||
* today, as plain values for the app (a line of the person's file each, or a
|
||||
* method channel's answer), after which what the app has kept is deleted on
|
||||
* the band, never today.
|
||||
*/
|
||||
object LoopDays {
|
||||
/** What the band deletes of a day kept, type by type (D38). */
|
||||
val deletedTypes =
|
||||
listOf(
|
||||
PolarBleApi.PolarStoredDataType.ACTIVITY,
|
||||
PolarBleApi.PolarStoredDataType.DAILY_SUMMARY,
|
||||
PolarBleApi.PolarStoredDataType.AUTO_SAMPLE,
|
||||
PolarBleApi.PolarStoredDataType.SLEEP,
|
||||
PolarBleApi.PolarStoredDataType.SLEEP_SCORE,
|
||||
PolarBleApi.PolarStoredDataType.NIGHTLY_RECOVERY,
|
||||
PolarBleApi.PolarStoredDataType.SKIN_TEMP,
|
||||
PolarBleApi.PolarStoredDataType.SKIN_CONTACT_CHANGES,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reads the days from [from] to [today] off [source], a `read` line first
|
||||
* (when and what), then deletes on the band what the app kept up to
|
||||
* [keptUntil], which never reaches today.
|
||||
*/
|
||||
suspend fun read(
|
||||
source: LoopSource,
|
||||
from: LocalDate,
|
||||
today: LocalDate,
|
||||
keptUntil: LocalDate?,
|
||||
): List<Map<String, Any?>> {
|
||||
val lines = mutableListOf<Map<String, Any?>>(
|
||||
mapOf("kind" to "read", "on" to dateOf(today), "from" to dateOf(from), "to" to dateOf(today)),
|
||||
)
|
||||
source.startSync()
|
||||
try {
|
||||
if (!from.isAfter(today)) {
|
||||
source.summaries(from, today).mapTo(lines, ::summaryOf)
|
||||
source.heartRates(from, today).mapTo(lines, ::heartOf)
|
||||
source.sleeps(from, today).mapNotNullTo(lines, ::sleepOf)
|
||||
source.recharges(from, today).mapNotNullTo(lines, ::rechargeOf)
|
||||
source.skinTemperatures(from, today).mapNotNullTo(lines, ::skinOf)
|
||||
}
|
||||
val until = keptUntil?.let { minOf(it, today.minusDays(1)) }
|
||||
if (until != null) {
|
||||
for (type in deletedTypes) source.delete(type, until)
|
||||
}
|
||||
} finally {
|
||||
source.stopSync()
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
fun summaryOf(day: PolarDailySummaryData): Map<String, Any?> {
|
||||
val times = day.activityClassTimes
|
||||
return mapOf(
|
||||
"kind" to "summary",
|
||||
"date" to day.date?.let(::dateOf),
|
||||
"steps" to day.steps,
|
||||
"metres" to day.activityDistance,
|
||||
"activityCalories" to day.activityCalories,
|
||||
"trainingCalories" to day.trainingCalories,
|
||||
"bmrCalories" to day.bmrCalories,
|
||||
"goal" to day.activityGoalSummary?.activityGoal,
|
||||
"achieved" to day.activityGoalSummary?.achievedActivity,
|
||||
"times" to
|
||||
times?.let {
|
||||
mapOf(
|
||||
"nonWear" to secondsOf(it.timeNonWear),
|
||||
"sleep" to secondsOf(it.timeSleep),
|
||||
"sedentary" to secondsOf(it.timeSedentary),
|
||||
"light" to secondsOf(it.timeLightActivity),
|
||||
"continuousModerate" to secondsOf(it.timeContinuousModerateActivity),
|
||||
"intermittentModerate" to secondsOf(it.timeIntermittentModerateActivity),
|
||||
"continuousVigorous" to secondsOf(it.timeContinuousVigorousActivity),
|
||||
"intermittentVigorous" to secondsOf(it.timeIntermittentVigorousActivity),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A day's 24/7 heart rate: each measurement's start (seconds into the band's day), its kind and its samples. */
|
||||
fun heartOf(day: Polar247HrSamplesData): Map<String, Any?> =
|
||||
mapOf(
|
||||
"kind" to "heart",
|
||||
"date" to dateOf(day.date),
|
||||
"runs" to
|
||||
day.samples.map { run ->
|
||||
mapOf(
|
||||
"at" to run.startTime.toSecondOfDay(),
|
||||
"trigger" to run.triggerType.name.removePrefix("TRIGGER_TYPE_").lowercase(),
|
||||
"bpm" to run.hrSamples,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
fun sleepOf(night: PolarSleepData): Map<String, Any?>? {
|
||||
val result = night.result ?: return null
|
||||
return mapOf(
|
||||
"kind" to "sleep",
|
||||
"date" to dateOf(result.sleepResultDate ?: night.date ?: return null),
|
||||
"start" to result.sleepStartTime?.toOffsetDateTime()?.toString(),
|
||||
"end" to result.sleepEndTime?.toOffsetDateTime()?.toString(),
|
||||
"goalMinutes" to result.sleepGoalMinutes,
|
||||
"phases" to result.sleepWakePhases?.map { listOf(it.secondsFromSleepStart, it.state.name.lowercase()) },
|
||||
"cycles" to result.sleepCycles?.map { listOf(it.secondsFromSleepStart, it.sleepDepthStart) },
|
||||
"rating" to result.userSleepRating?.name?.removePrefix("SLEPT_")?.lowercase(),
|
||||
"batteryRanOut" to result.batteryRanOut,
|
||||
"skinCelsius" to result.sleepSkinTemperatureResult?.sleepSkinTemperatureCelsius,
|
||||
"skinDeviation" to result.sleepSkinTemperatureResult?.deviationFromBaseLine,
|
||||
)
|
||||
}
|
||||
|
||||
fun rechargeOf(recharge: PolarNightlyRechargeData): Map<String, Any?>? {
|
||||
val date = recharge.sleepResultDate ?: return null
|
||||
return mapOf(
|
||||
"kind" to "recharge",
|
||||
"date" to dateOf(date),
|
||||
"ansStatus" to recharge.ansStatus,
|
||||
"recovery" to recharge.recoveryIndicator,
|
||||
"recoverySubLevel" to recharge.recoveryIndicatorSubLevel,
|
||||
"ansRate" to recharge.ansRate,
|
||||
"rri" to recharge.meanNightlyRecoveryRRI,
|
||||
"rmssd" to recharge.meanNightlyRecoveryRMSSD,
|
||||
"breathInterval" to recharge.meanNightlyRecoveryRespirationInterval,
|
||||
"baselineRri" to recharge.meanBaselineRRI,
|
||||
"baselineRriSd" to recharge.sdBaselineRRI,
|
||||
"baselineRmssd" to recharge.meanBaselineRMSSD,
|
||||
"baselineRmssdSd" to recharge.sdBaselineRMSSD,
|
||||
"baselineBreathInterval" to recharge.meanBaselineRespirationInterval,
|
||||
"baselineBreathIntervalSd" to recharge.sdBaselineRespirationInterval,
|
||||
)
|
||||
}
|
||||
|
||||
fun skinOf(skin: PolarSkinTemperatureData): Map<String, Any?>? {
|
||||
val date = skin.date ?: return null
|
||||
val samples = skin.result?.skinTemperatureList ?: return null
|
||||
return mapOf(
|
||||
"kind" to "skin",
|
||||
"date" to dateOf(date),
|
||||
"samples" to samples.map { listOf(it.recordingTimeDeltaMs, it.temperature) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun secondsOf(time: PolarActiveTime?): Int? =
|
||||
time?.let { it.hours * 3600 + it.minutes * 60 + it.seconds }
|
||||
|
||||
private fun dateOf(date: LocalDate): String = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.polar.androidcommunications.api.ble.model.DisInfo
|
||||
import com.polar.androidcommunications.api.ble.model.gatt.client.ChargeState
|
||||
import com.polar.androidcommunications.api.ble.model.gatt.client.PowerSourcesState
|
||||
import com.polar.sdk.api.PolarBleApi
|
||||
import com.polar.sdk.api.PolarBleApiCallbackProvider
|
||||
import com.polar.sdk.api.PolarBleApiDefaultImpl
|
||||
import com.polar.sdk.api.model.PolarDeviceInfo
|
||||
import com.polar.sdk.api.model.PolarHealthThermometerData
|
||||
import com.polar.sdk.api.model.PolarHrData
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Reads the person's Loop every few hours while Train is closed (ADR 0030,
|
||||
* D37, D38): connects, reads the days since those the app kept into the
|
||||
* person's `loop_days.txt` (a line of JSON each, as [LoopDays] makes them),
|
||||
* deletes on the band what the app said it kept, and lets go. The app seals
|
||||
* the lines as it opens; this never seals, as only the app holds the key.
|
||||
* It leaves the band alone while the open app holds it.
|
||||
*/
|
||||
class LoopWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
if (LoopBridge.holding) return Result.success()
|
||||
val config = configOf(applicationContext) ?: return Result.success()
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
val api =
|
||||
PolarBleApiDefaultImpl.defaultImplementation(
|
||||
applicationContext,
|
||||
setOf(
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FILE_TRANSFER,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_CONTROL,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
|
||||
),
|
||||
)
|
||||
api.setApiCallback(ReadyCallback(config.id, ready))
|
||||
return try {
|
||||
api.connectToDevice(config.id)
|
||||
withTimeout(TimeUnit.SECONDS.toMillis(90)) { ready.await() }
|
||||
val lines =
|
||||
LoopDays.read(PolarLoopSource(api, config.id), config.from, LocalDate.now(), config.keptUntil)
|
||||
FileOutputStream(File(config.folder, FILE), true).use { out ->
|
||||
for (line in lines) out.write((JsonText.of(line) + "\n").toByteArray())
|
||||
out.fd.sync()
|
||||
}
|
||||
Result.success()
|
||||
} catch (error: Exception) {
|
||||
// Out of reach, or the band busy: the next run tries again.
|
||||
Log.i("Loop", "The Loop not read in the background: ${error.javaClass.simpleName}")
|
||||
Result.success()
|
||||
} finally {
|
||||
runCatching { api.disconnectFromDevice(config.id) }
|
||||
api.shutDown()
|
||||
}
|
||||
}
|
||||
|
||||
/** What is read and where: the band, the person's folder, from when, and what the app kept. */
|
||||
private class Config(val folder: File, val id: String, val from: LocalDate, val keptUntil: LocalDate?)
|
||||
|
||||
/** Ready once the band's activity can be read. */
|
||||
private class ReadyCallback(private val id: String, private val ready: CompletableDeferred<Unit>) :
|
||||
PolarBleApiCallbackProvider {
|
||||
override fun bleSdkFeatureReady(identifier: String, feature: PolarBleApi.PolarBleSdkFeature) {
|
||||
if (identifier == id && feature == PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA) {
|
||||
ready.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override fun blePowerStateChanged(powered: Boolean) {}
|
||||
|
||||
override fun deviceConnected(polarDeviceInfo: PolarDeviceInfo) {}
|
||||
|
||||
override fun deviceConnecting(polarDeviceInfo: PolarDeviceInfo) {}
|
||||
|
||||
override fun deviceDisconnected(polarDeviceInfo: PolarDeviceInfo) {}
|
||||
|
||||
override fun bleSdkFeaturesReadiness(
|
||||
identifier: String,
|
||||
ready: List<PolarBleApi.PolarBleSdkFeature>,
|
||||
unavailable: List<PolarBleApi.PolarBleSdkFeature>,
|
||||
) {}
|
||||
|
||||
override fun disInformationReceived(identifier: String, uuid: UUID, value: String) {}
|
||||
|
||||
override fun disInformationReceived(identifier: String, disInfo: DisInfo) {}
|
||||
|
||||
override fun batteryLevelReceived(identifier: String, level: Int) {}
|
||||
|
||||
override fun batteryChargingStatusReceived(identifier: String, chargingStatus: ChargeState) {}
|
||||
|
||||
override fun powerSourcesStateReceived(identifier: String, powerSourcesState: PowerSourcesState) {}
|
||||
|
||||
override fun hrNotificationReceived(identifier: String, data: PolarHrData.PolarHrSample) {}
|
||||
|
||||
override fun htsNotificationReceived(identifier: String, data: PolarHealthThermometerData) {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The lines read in the background, in the person's folder. */
|
||||
const val FILE = "loop_days.txt"
|
||||
|
||||
private const val CONFIG = "loop_background.json"
|
||||
private const val WORK = "loop-days"
|
||||
|
||||
/**
|
||||
* Reads the band every four hours with [config] (`folder`, `id`, `from`,
|
||||
* `keptUntil`), or no longer where it is null.
|
||||
*/
|
||||
fun schedule(context: Context, config: Map<String, Any?>?) {
|
||||
val file = File(context.filesDir, CONFIG)
|
||||
val work = WorkManager.getInstance(context)
|
||||
if (config == null) {
|
||||
file.delete()
|
||||
work.cancelUniqueWork(WORK)
|
||||
return
|
||||
}
|
||||
file.writeText(JsonText.of(config))
|
||||
work.enqueueUniquePeriodicWork(
|
||||
WORK,
|
||||
// A new config is read by the next run; the schedule stays.
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<LoopWorker>(4, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun configOf(context: Context): Config? {
|
||||
val file = File(context.filesDir, CONFIG)
|
||||
if (!file.exists()) return null
|
||||
return runCatching {
|
||||
val json = JSONObject(file.readText())
|
||||
Config(
|
||||
folder = File(json.getString("folder")),
|
||||
id = json.getString("id"),
|
||||
from = LocalDate.parse(json.getString("from")),
|
||||
keptUntil = json.optString("keptUntil").takeIf { it.isNotEmpty() && it != "null" }?.let(LocalDate::parse),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import com.polar.sdk.api.PolarBleApi
|
||||
import java.time.LocalDate
|
||||
|
||||
/** [LoopSource] on Polar's SDK, for the band [id] (ADR 0030, D37). */
|
||||
class PolarLoopSource(private val api: PolarBleApi, private val id: String) : LoopSource {
|
||||
override suspend fun startSync() {
|
||||
api.sendInitializationAndStartSyncNotifications(id)
|
||||
}
|
||||
|
||||
override suspend fun stopSync() {
|
||||
api.sendTerminateAndStopSyncNotifications(id)
|
||||
}
|
||||
|
||||
override suspend fun summaries(from: LocalDate, to: LocalDate) = api.getDailySummaryData(id, from, to)
|
||||
|
||||
override suspend fun heartRates(from: LocalDate, to: LocalDate) = api.get247HrSamples(id, from, to)
|
||||
|
||||
override suspend fun sleeps(from: LocalDate, to: LocalDate) = api.getSleep(id, from, to)
|
||||
|
||||
override suspend fun recharges(from: LocalDate, to: LocalDate) = api.getNightlyRecharge(id, from, to)
|
||||
|
||||
override suspend fun skinTemperatures(from: LocalDate, to: LocalDate) = api.getSkinTemperature(id, from, to)
|
||||
|
||||
override suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate) {
|
||||
api.deleteStoredDeviceData(id, type, until)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import com.polar.sdk.api.PolarBleApi
|
||||
import com.polar.sdk.api.model.PolarSkinTemperatureData
|
||||
import com.polar.sdk.api.model.PolarSkinTemperatureDataSample
|
||||
import com.polar.sdk.api.model.PolarSkinTemperatureResult
|
||||
import com.polar.sdk.api.model.SkinTemperatureMeasurementType
|
||||
import com.polar.sdk.api.model.SkinTemperatureSensorLocation
|
||||
import com.polar.sdk.api.model.activity.AutomaticSampleTriggerType
|
||||
import com.polar.sdk.api.model.activity.Polar247HrSamples
|
||||
import com.polar.sdk.api.model.activity.Polar247HrSamplesData
|
||||
import com.polar.sdk.api.model.activity.PolarActiveTime
|
||||
import com.polar.sdk.api.model.activity.PolarActiveTimeData
|
||||
import com.polar.sdk.api.model.activity.PolarActivityGoalSummary
|
||||
import com.polar.sdk.api.model.activity.PolarDailySummaryData
|
||||
import com.polar.sdk.api.model.sleep.PolarNightlyRechargeData
|
||||
import com.polar.sdk.api.model.sleep.PolarSleepAnalysisResult
|
||||
import com.polar.sdk.api.model.sleep.PolarSleepData
|
||||
import com.polar.sdk.api.model.sleep.SleepCycle
|
||||
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 java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
// The Loop's days and nights as the app takes them (ADR 0030, D37), and what
|
||||
// a read deletes on the band (D38): only what the app kept, never today.
|
||||
class LoopDaysTest {
|
||||
private val today = LocalDate.of(2026, 10, 28)
|
||||
private val yesterday = today.minusDays(1)
|
||||
|
||||
private fun time(minutes: Int) = PolarActiveTime(minutes / 60, minutes % 60, 0, 0)
|
||||
|
||||
private val summary =
|
||||
PolarDailySummaryData(
|
||||
yesterday,
|
||||
420,
|
||||
120,
|
||||
1500,
|
||||
9876,
|
||||
PolarActivityGoalSummary(100f, 87.5f, time(0), time(0), time(0)),
|
||||
PolarActiveTimeData(
|
||||
yesterday,
|
||||
time(30),
|
||||
time(450),
|
||||
time(600),
|
||||
time(180),
|
||||
time(40),
|
||||
time(20),
|
||||
time(10),
|
||||
time(5),
|
||||
),
|
||||
7012.5f,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
|
||||
private val heart =
|
||||
Polar247HrSamplesData(
|
||||
yesterday,
|
||||
listOf(
|
||||
Polar247HrSamples(LocalTime.of(0, 10), listOf(58, 57, 59), AutomaticSampleTriggerType.TRIGGER_TYPE_TIMED),
|
||||
Polar247HrSamples(LocalTime.of(17, 30, 5), listOf(141), AutomaticSampleTriggerType.TRIGGER_TYPE_HIGH_ACTIVITY),
|
||||
),
|
||||
)
|
||||
|
||||
private val zone = ZoneOffset.ofHours(1)
|
||||
|
||||
private val sleep =
|
||||
PolarSleepData(
|
||||
today,
|
||||
PolarSleepAnalysisResult(
|
||||
ZonedDateTime.of(2026, 10, 27, 23, 15, 0, 0, zone),
|
||||
ZonedDateTime.of(2026, 10, 28, 7, 5, 0, 0, zone),
|
||||
null,
|
||||
480,
|
||||
listOf(SleepWakePhase(0, SleepWakeState.WAKE), SleepWakePhase(600, SleepWakeState.NONREM12)),
|
||||
emptyList(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
SleepRating.SLEPT_SOMEWHAT_WELL,
|
||||
"A1",
|
||||
false,
|
||||
listOf(SleepCycle(600, 1.5f)),
|
||||
today,
|
||||
null,
|
||||
SleepSkinTemperatureResult(today, 33.4f, -0.2f),
|
||||
),
|
||||
)
|
||||
|
||||
private val recharge =
|
||||
PolarNightlyRechargeData(
|
||||
LocalDateTime.of(2026, 10, 28, 7, 30),
|
||||
null,
|
||||
2.5f,
|
||||
4,
|
||||
1,
|
||||
5,
|
||||
null,
|
||||
1050,
|
||||
48,
|
||||
3800,
|
||||
1000,
|
||||
60,
|
||||
45,
|
||||
8,
|
||||
3700,
|
||||
200,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
today,
|
||||
)
|
||||
|
||||
private val skin =
|
||||
PolarSkinTemperatureData(
|
||||
today,
|
||||
PolarSkinTemperatureResult(
|
||||
"A1",
|
||||
SkinTemperatureSensorLocation.values().first(),
|
||||
SkinTemperatureMeasurementType.values().first(),
|
||||
listOf(PolarSkinTemperatureDataSample(0, 33.1f), PolarSkinTemperatureDataSample(60000, 33.3f)),
|
||||
),
|
||||
)
|
||||
|
||||
private inner class FakeBand : LoopSource {
|
||||
val calls = mutableListOf<String>()
|
||||
|
||||
override suspend fun startSync() {
|
||||
calls += "start"
|
||||
}
|
||||
|
||||
override suspend fun stopSync() {
|
||||
calls += "stop"
|
||||
}
|
||||
|
||||
override suspend fun summaries(from: LocalDate, to: LocalDate) = listOf(summary).also { calls += "summaries $from $to" }
|
||||
|
||||
override suspend fun heartRates(from: LocalDate, to: LocalDate) = listOf(heart).also { calls += "heart" }
|
||||
|
||||
override suspend fun sleeps(from: LocalDate, to: LocalDate) = listOf(sleep).also { calls += "sleep" }
|
||||
|
||||
override suspend fun recharges(from: LocalDate, to: LocalDate) = listOf(recharge).also { calls += "recharge" }
|
||||
|
||||
override suspend fun skinTemperatures(from: LocalDate, to: LocalDate) = listOf(skin).also { calls += "skin" }
|
||||
|
||||
override suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate) {
|
||||
calls += "delete $type $until"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read says when it was and what it covers, framed by the sync calls`() {
|
||||
val band = FakeBand()
|
||||
val lines = runBlocking { LoopDays.read(band, yesterday, today, keptUntil = null) }
|
||||
|
||||
assertEquals(mapOf("kind" to "read", "on" to "2026-10-28", "from" to "2026-10-27", "to" to "2026-10-28"), lines.first())
|
||||
assertEquals(listOf("summary", "heart", "sleep", "recharge", "skin"), lines.drop(1).map { it["kind"] })
|
||||
assertEquals("start", band.calls.first())
|
||||
assertEquals("summaries 2026-10-27 2026-10-28", band.calls[1])
|
||||
assertEquals("stop", band.calls.last())
|
||||
assertTrue("nothing kept, nothing deleted", band.calls.none { it.startsWith("delete") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the day's summary keeps its figures and its time in each class in seconds`() {
|
||||
val line = LoopDays.summaryOf(summary)
|
||||
|
||||
assertEquals("2026-10-27", line["date"])
|
||||
assertEquals(9876, line["steps"])
|
||||
assertEquals(7012.5f, line["metres"])
|
||||
assertEquals(420, line["activityCalories"])
|
||||
assertEquals(87.5f, line["achieved"])
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val times = line["times"] as Map<String, Int?>
|
||||
assertEquals(450 * 60, times["sleep"])
|
||||
assertEquals(5 * 60, times["intermittentVigorous"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the day's heart rate keeps each measurement's start, kind and samples`() {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val runs = LoopDays.heartOf(heart)["runs"] as List<Map<String, Any?>>
|
||||
|
||||
assertEquals(mapOf("at" to 600, "trigger" to "timed", "bpm" to listOf(58, 57, 59)), runs[0])
|
||||
assertEquals(17 * 3600 + 30 * 60 + 5, runs[1]["at"])
|
||||
assertEquals("high_activity", runs[1]["trigger"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a night counts for the day it ended, with its times, phases and skin`() {
|
||||
val line = LoopDays.sleepOf(sleep)!!
|
||||
|
||||
assertEquals("2026-10-28", line["date"])
|
||||
assertEquals("2026-10-27T23:15+01:00", line["start"])
|
||||
assertEquals(listOf(listOf(0, "wake"), listOf(600, "nonrem12")), line["phases"])
|
||||
assertEquals("somewhat_well", line["rating"])
|
||||
assertEquals(33.4f, line["skinCelsius"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Nightly Recharge and skin temperature keep their figures`() {
|
||||
val recharge = LoopDays.rechargeOf(recharge)!!
|
||||
assertEquals("2026-10-28", recharge["date"])
|
||||
assertEquals(4, recharge["recovery"])
|
||||
assertEquals(48, recharge["rmssd"])
|
||||
|
||||
val skin = LoopDays.skinOf(skin)!!
|
||||
assertEquals(listOf(listOf(0L, 33.1f), listOf(60000L, 33.3f)), skin["samples"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `what the app kept is deleted after the read, type by type`() {
|
||||
val band = FakeBand()
|
||||
runBlocking { LoopDays.read(band, today, today, keptUntil = yesterday) }
|
||||
|
||||
val deleted = band.calls.filter { it.startsWith("delete") }
|
||||
assertEquals(LoopDays.deletedTypes.map { "delete $it 2026-10-27" }, deleted)
|
||||
assertTrue(band.calls.indexOf("skin") < band.calls.indexOf(deleted.first()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `today is never deleted, whatever the app says it kept`() {
|
||||
val band = FakeBand()
|
||||
runBlocking { LoopDays.read(band, today, today, keptUntil = today) }
|
||||
|
||||
assertTrue(band.calls.filter { it.startsWith("delete") }.all { it.endsWith("2026-10-27") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sync is stopped even when the band fails`() {
|
||||
val band =
|
||||
object : LoopSource by FakeBand() {
|
||||
val calls = mutableListOf<String>()
|
||||
|
||||
override suspend fun stopSync() {
|
||||
calls += "stop"
|
||||
}
|
||||
|
||||
override suspend fun heartRates(from: LocalDate, to: LocalDate): List<Polar247HrSamplesData> =
|
||||
throw IllegalStateException("gone")
|
||||
}
|
||||
runCatching { runBlocking { LoopDays.read(band, today, today, keptUntil = yesterday) } }
|
||||
|
||||
assertEquals(listOf("stop"), band.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lines are written as JSON`() {
|
||||
assertEquals(
|
||||
"""{"kind":"read","n":[1,2.5,null,true],"s":"a\"b"}""",
|
||||
JsonText.of(mapOf("kind" to "read", "n" to listOf(1, 2.5, null, true), "s" to "a\"b")),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue