feat(train): setting up and keeping the Polar Loop (ADR 0030, D34–D36)
On Android, the Loop through Polar's BLE SDK 8.3.0 (from JitPack, asked for Polar's group alone, its files pinned by SHA-256 in Gradle's verification metadata): LoopBridge.kt over app.halkyon.train/loop, the first-time use's figures mapped and checked in LoopSetup.kt, tested on the JVM. PolarLoop per person remembers the band in loop.json, connects while the app is open, sets its clock at each connection, keeps what it tells (battery, charging, firmware), offers a changed profile and sends it only when asked (D35: first-time use writes a new user identifier each time), and lets the band go by a factory reset after asking. The setup sheet finds Loops nearest first and asks only for what the profile lacks; Polar's defaults (60, 220 less the age, a VO2max from Polar's fitness tables) go to the band alone. Polar's SDK is not free software (D36, correcting D32): the app's AGPL gains a section 7 permission for linking it, LICENSE.md names it, and its licence and bundled notices join the licence list on Android. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
de6fe5c15a
commit
cd72cf6ccc
7 changed files with 3485 additions and 99 deletions
|
|
@ -55,6 +55,8 @@ dependencies {
|
|||
implementation("androidx.core:core:1.18.0")
|
||||
// Health Connect (ADR 0030, D30, D31): AndroidX's client, no Play services.
|
||||
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 recorder's pure Kotlin parts (auto-pause) tested on the JVM.
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.content.Context
|
||||
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 io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The Polar Loop for the app (ADR 0030, D32, D34), through Polar's BLE SDK,
|
||||
* over `app.halkyon.train/loop`: Loops nearby (`…/loop/search`), connecting
|
||||
* to the one the person chose, its first-time use, its clock, letting it go,
|
||||
* and what it tells of itself as it connects (`…/loop/state`). The app
|
||||
* decides when; this only speaks to the band.
|
||||
*/
|
||||
class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler {
|
||||
private val scope: CoroutineScope = MainScope()
|
||||
private var events: EventChannel.EventSink? = null
|
||||
|
||||
// The SDK made on first use, so that an app without a Loop never starts it.
|
||||
// Its telemetry stays off: FEATURE_TELEMETRY is never asked for.
|
||||
private val made = lazy {
|
||||
PolarBleApiDefaultImpl.defaultImplementation(
|
||||
context.applicationContext,
|
||||
setOf(
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_DEVICE_INFO,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_BATTERY_INFO,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FILE_TRANSFER,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_CONTROL,
|
||||
),
|
||||
).apply {
|
||||
setAutomaticReconnection(true)
|
||||
setApiCallback(callback)
|
||||
}
|
||||
}
|
||||
private val api: PolarBleApi by made
|
||||
|
||||
/** What the band tells, sent on as `{id, event, …}`. */
|
||||
val state =
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
|
||||
events = sink
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
events = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Loops nearby while the setup sheet looks, each as `{id, name, rssi}`. */
|
||||
val search =
|
||||
object : EventChannel.StreamHandler {
|
||||
private var looking: Job? = null
|
||||
|
||||
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
|
||||
looking?.cancel()
|
||||
looking =
|
||||
scope.launch {
|
||||
try {
|
||||
api.searchForDevice("Polar").collect { device ->
|
||||
if (LoopSetup.isLoop(device.name)) {
|
||||
sink.success(mapOf("id" to device.deviceId, "name" to device.name, "rssi" to device.rssi))
|
||||
}
|
||||
}
|
||||
} catch (error: SecurityException) {
|
||||
sink.error("refused", error.message, null)
|
||||
} catch (error: Exception) {
|
||||
sink.error("failed", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
looking?.cancel()
|
||||
looking = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
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)
|
||||
}
|
||||
"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)
|
||||
} catch (error: Exception) {
|
||||
result.error("failed", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets the band go from the SDK as the app closes. */
|
||||
fun dispose() {
|
||||
scope.cancel()
|
||||
events = null
|
||||
if (made.isInitialized()) api.shutDown()
|
||||
}
|
||||
|
||||
private fun send(id: String, event: String, vararg values: Pair<String, Any?>) {
|
||||
events?.success(mapOf("id" to id, "event" to event, *values))
|
||||
}
|
||||
|
||||
private val callback =
|
||||
object : PolarBleApiCallbackProvider {
|
||||
override fun blePowerStateChanged(powered: Boolean) {}
|
||||
|
||||
override fun deviceConnecting(polarDeviceInfo: PolarDeviceInfo) =
|
||||
send(polarDeviceInfo.deviceId, "connecting")
|
||||
|
||||
override fun deviceConnected(polarDeviceInfo: PolarDeviceInfo) =
|
||||
send(polarDeviceInfo.deviceId, "connected", "name" to polarDeviceInfo.name)
|
||||
|
||||
override fun deviceDisconnected(polarDeviceInfo: PolarDeviceInfo) =
|
||||
send(polarDeviceInfo.deviceId, "disconnected")
|
||||
|
||||
override fun bleSdkFeatureReady(identifier: String, feature: PolarBleApi.PolarBleSdkFeature) {
|
||||
// The clock and the first-time use go through the band's files.
|
||||
if (feature == PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP) send(identifier, "ready")
|
||||
}
|
||||
|
||||
override fun bleSdkFeaturesReadiness(
|
||||
identifier: String,
|
||||
ready: List<PolarBleApi.PolarBleSdkFeature>,
|
||||
unavailable: List<PolarBleApi.PolarBleSdkFeature>,
|
||||
) {}
|
||||
|
||||
override fun disInformationReceived(identifier: String, uuid: UUID, value: String) {
|
||||
if (uuid == FIRMWARE_REVISION) send(identifier, "firmware", "version" to value)
|
||||
}
|
||||
|
||||
override fun disInformationReceived(identifier: String, disInfo: DisInfo) {}
|
||||
|
||||
override fun batteryLevelReceived(identifier: String, level: Int) =
|
||||
send(identifier, "battery", "level" to level)
|
||||
|
||||
override fun batteryChargingStatusReceived(identifier: String, chargingStatus: ChargeState) =
|
||||
send(identifier, "charging", "charging" to (chargingStatus == ChargeState.CHARGING))
|
||||
|
||||
override fun powerSourcesStateReceived(identifier: String, powerSourcesState: PowerSourcesState) {}
|
||||
|
||||
override fun hrNotificationReceived(identifier: String, data: PolarHrData.PolarHrSample) {}
|
||||
|
||||
override fun htsNotificationReceived(identifier: String, data: PolarHealthThermometerData) {}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Device Information's Firmware Revision String (0x2A26). */
|
||||
val FIRMWARE_REVISION: UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import com.polar.sdk.api.model.PolarFirstTimeUseConfig
|
||||
import java.time.LocalDate
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* The app's figures for a Loop's first-time use (ADR 0030, D34) as Polar's
|
||||
* SDK takes them: the app sends every one, its defaults filled in already;
|
||||
* this only maps names and checks what the SDK would refuse.
|
||||
*/
|
||||
object LoopSetup {
|
||||
/** Polar's six training backgrounds, by the app's names. */
|
||||
val trainingBackgrounds =
|
||||
mapOf(
|
||||
"occasional" to 10,
|
||||
"regular" to 20,
|
||||
"frequent" to 30,
|
||||
"heavy" to 40,
|
||||
"semiPro" to 50,
|
||||
"pro" to 60,
|
||||
)
|
||||
|
||||
/** Whether a device found under [name] is a Loop. */
|
||||
fun isLoop(name: String?): Boolean = name != null && name.trim().startsWith("Polar Loop", ignoreCase = true)
|
||||
|
||||
/** [figures] as the SDK's configuration, the band's clock at [now]; throws IllegalArgumentException where one is missing or out of range. */
|
||||
fun configOf(figures: Map<String, Any?>, now: ZonedDateTime): PolarFirstTimeUseConfig {
|
||||
fun number(key: String): Number = figures[key] as? Number ?: throw IllegalArgumentException("$key missing")
|
||||
return PolarFirstTimeUseConfig(
|
||||
gender =
|
||||
when (figures["sex"]) {
|
||||
"female" -> PolarFirstTimeUseConfig.Gender.FEMALE
|
||||
"male" -> PolarFirstTimeUseConfig.Gender.MALE
|
||||
else -> throw IllegalArgumentException("sex missing")
|
||||
},
|
||||
birthDate = LocalDate.parse(figures["birthDate"] as? String ?: throw IllegalArgumentException("birthDate missing")),
|
||||
height = number("heightCm").toFloat(),
|
||||
weight = number("weightKg").toFloat(),
|
||||
maxHeartRate = number("maxHeartRate").toInt(),
|
||||
vo2Max = number("vo2Max").toInt(),
|
||||
restingHeartRate = number("restingHeartRate").toInt(),
|
||||
trainingBackground =
|
||||
trainingBackgrounds[figures["trainingBackground"]] ?: throw IllegalArgumentException("trainingBackground missing"),
|
||||
deviceTime = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
|
||||
typicalDay =
|
||||
when (figures["typicalDay"]) {
|
||||
"mostlySitting" -> PolarFirstTimeUseConfig.TypicalDay.MOSTLY_SITTING
|
||||
"mostlyStanding" -> PolarFirstTimeUseConfig.TypicalDay.MOSTLY_STANDING
|
||||
"mostlyMoving" -> PolarFirstTimeUseConfig.TypicalDay.MOSTLY_MOVING
|
||||
else -> throw IllegalArgumentException("typicalDay missing")
|
||||
},
|
||||
sleepGoalMinutes = number("sleepGoalMinutes").toInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,9 @@ class MainActivity : FlutterActivity() {
|
|||
/** Health Connect's side (ADR 0030, D30, D31). */
|
||||
private val health by lazy { HealthConnectBridge(this) }
|
||||
|
||||
/** The Polar Loop's side (ADR 0030, D34). */
|
||||
private val loop by lazy { LoopBridge(this) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
health.onIntent(intent)
|
||||
|
|
@ -71,6 +74,9 @@ class MainActivity : FlutterActivity() {
|
|||
.setStreamHandler(DeviceScanner(this, RowerData.SERVICE, ::mayBeRower))
|
||||
MethodChannel(messenger, "app.halkyon.train/health").setMethodCallHandler(health)
|
||||
EventChannel(messenger, "app.halkyon.train/health/rationale").setStreamHandler(health.rationaleRequests)
|
||||
MethodChannel(messenger, "app.halkyon.train/loop").setMethodCallHandler(loop)
|
||||
EventChannel(messenger, "app.halkyon.train/loop/state").setStreamHandler(loop.state)
|
||||
EventChannel(messenger, "app.halkyon.train/loop/search").setStreamHandler(loop.search)
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
|
|
@ -90,6 +96,7 @@ class MainActivity : FlutterActivity() {
|
|||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
RecorderService.lines = null
|
||||
health.dispose()
|
||||
loop.dispose()
|
||||
super.cleanUpFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import com.polar.sdk.api.model.PolarFirstTimeUseConfig
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
// A Loop's first-time use from the app's figures (ADR 0030, D34): Polar's
|
||||
// names for the app's, and nothing sent that the SDK would refuse.
|
||||
class LoopSetupTest {
|
||||
private val now = ZonedDateTime.of(2026, 10, 28, 18, 30, 0, 0, ZoneOffset.ofHours(1))
|
||||
|
||||
private val figures =
|
||||
mapOf<String, Any?>(
|
||||
"sex" to "female",
|
||||
"birthDate" to "1990-04-02",
|
||||
"heightCm" to 172,
|
||||
"weightKg" to 64.5,
|
||||
"restingHeartRate" to 58,
|
||||
"maxHeartRate" to 186,
|
||||
"vo2Max" to 42,
|
||||
"trainingBackground" to "semiPro",
|
||||
"typicalDay" to "mostlyStanding",
|
||||
"sleepGoalMinutes" to 480,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the app's figures become Polar's`() {
|
||||
val config = LoopSetup.configOf(figures, now)
|
||||
|
||||
assertEquals(PolarFirstTimeUseConfig.Gender.FEMALE, config.gender)
|
||||
assertEquals(LocalDate.of(1990, 4, 2), config.birthDate)
|
||||
assertEquals(172f, config.height)
|
||||
assertEquals(64.5f, config.weight)
|
||||
assertEquals(58, config.restingHeartRate)
|
||||
assertEquals(186, config.maxHeartRate)
|
||||
assertEquals(42, config.vo2Max)
|
||||
assertEquals(50, config.trainingBackground)
|
||||
assertEquals(PolarFirstTimeUseConfig.TypicalDay.MOSTLY_STANDING, config.typicalDay)
|
||||
assertEquals(480, config.sleepGoalMinutes)
|
||||
assertEquals("2026-10-28T18:30:00+01:00", config.deviceTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each of the six training backgrounds is one the SDK takes`() {
|
||||
for ((name, value) in LoopSetup.trainingBackgrounds) {
|
||||
val config = LoopSetup.configOf(figures + ("trainingBackground" to name), now)
|
||||
assertEquals(value, config.trainingBackground)
|
||||
}
|
||||
assertEquals(6, LoopSetup.trainingBackgrounds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a figure missing is refused`() {
|
||||
for (key in figures.keys) {
|
||||
assertThrows(key, IllegalArgumentException::class.java) {
|
||||
LoopSetup.configOf(figures - key, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a figure out of Polar's range is refused`() {
|
||||
for ((key, value) in listOf("heightCm" to 250, "restingHeartRate" to 15, "sleepGoalMinutes" to 720)) {
|
||||
assertThrows(key, IllegalArgumentException::class.java) {
|
||||
LoopSetup.configOf(figures + (key to value), now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a Loop is taken for one`() {
|
||||
assertTrue(LoopSetup.isLoop("Polar Loop 1A2B3C4D"))
|
||||
assertTrue(LoopSetup.isLoop("POLAR LOOP"))
|
||||
assertFalse(LoopSetup.isLoop("Polar 360 1A2B3C4D"))
|
||||
assertFalse(LoopSetup.isLoop("Polar H10 1A2B3C4D"))
|
||||
assertFalse(LoopSetup.isLoop(null))
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,12 @@ allprojects {
|
|||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
// Polar's BLE SDK for the Loop (ADR 0030, D34), which Polar publishes
|
||||
// through JitPack alone: asked for Polar's package and nothing else.
|
||||
exclusiveContent {
|
||||
forRepository { maven("https://jitpack.io") }
|
||||
filter { includeGroup("com.github.polarofficial") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Polar's BLE SDK comes from JitPack (ADR 0030, D34), which builds it from
|
||||
Polar's repository itself: its files are pinned here by their SHA-256, as
|
||||
JitPack served them on 28 Oct 2026, so that a file changed there fails the
|
||||
build. Everything else comes from Google's and Maven Central's repositories
|
||||
and is trusted as before.
|
||||
-->
|
||||
<verification-metadata xmlns="https://schema.gradle.org/dependency-verification"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="https://schema.gradle.org/dependency-verification https://schema.gradle.org/dependency-verification/dependency-verification-1.3.xsd">
|
||||
<configuration>
|
||||
<verify-metadata>true</verify-metadata>
|
||||
<verify-signatures>false</verify-signatures>
|
||||
<trusted-artifacts>
|
||||
<trust group="^(?!com[.]github[.]polarofficial$).*$" regex="true"/>
|
||||
</trusted-artifacts>
|
||||
</configuration>
|
||||
<components>
|
||||
<component group="com.github.polarofficial" name="polar-ble-sdk" version="8.3.0">
|
||||
<artifact name="polar-ble-sdk-8.3.0.aar">
|
||||
<sha256 value="f207c8b4a1812890e4737ac5c72b46bbcb1889826ef89d82ddc66c5772a826d0" origin="JitPack, 28 Oct 2026"/>
|
||||
</artifact>
|
||||
<artifact name="polar-ble-sdk-8.3.0.module">
|
||||
<sha256 value="00ddeeac9e681cdfa411dd4cb6834968c0b08c18efff7debbd9d6379200293fc" origin="JitPack, 28 Oct 2026"/>
|
||||
</artifact>
|
||||
<artifact name="polar-ble-sdk-8.3.0.pom">
|
||||
<sha256 value="1bceb6bf7e24262b196a3a7d55b94dadbfbe9428fe5aa998e20d4e6b4a4e1285" origin="JitPack, 28 Oct 2026"/>
|
||||
</artifact>
|
||||
</component>
|
||||
</components>
|
||||
</verification-metadata>
|
||||
Loading…
Reference in a new issue