feat(train): the Loop's firmware updates, directly or through the instance (ADR 0030, D10, D40)

Train's server relays Polar's firmware service for the people signed in
(`firmware`: offered, check, download), a fetched file kept in memory a
day per hardware and version, off with LOOP_FIRMWARE_RELAY=off. The app
asks on the day's first connection and on the band's screen, from Polar
or the instance as the person chose, never Polar in the instance's
place; an update runs only after asking, reads and keeps the band
first, and sets it up again with its figures and clock after.

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:52:53 +01:00
commit 388b3b1ae6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 2318 additions and 45 deletions

View file

@ -7,7 +7,9 @@ import com.polar.androidcommunications.api.ble.model.gatt.client.PowerSourcesSta
import com.polar.sdk.api.PolarBleApi
import com.polar.sdk.api.PolarBleApiCallbackProvider
import com.polar.sdk.api.PolarBleApiDefaultImpl
import com.polar.sdk.api.model.FirmwareUpdateStatus
import com.polar.sdk.api.model.PolarDeviceInfo
import com.polar.sdk.api.model.PolarDeviceUuid
import com.polar.sdk.api.model.PolarHealthThermometerData
import com.polar.sdk.api.model.PolarHrData
import io.flutter.plugin.common.EventChannel
@ -21,6 +23,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -52,6 +55,7 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TRAINING_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FIRMWARE_UPDATE,
),
).apply {
holding = true
@ -135,6 +139,27 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
keptUntil = call.argument<String>("keptUntil")?.let(LocalDate::parse),
),
)
// The firmware (D10, D40): what the relay asks with, Polar
// asked directly, and the update, its stages sent as they come.
"firmwareInfo" ->
result.success(
LoopFirmware.infoOf(
api.readFile(id, LoopFirmware.DEVICE_FILE) ?: throw IllegalStateException("no device file"),
PolarDeviceUuid.fromDeviceId(id),
),
)
"checkFirmware" -> result.success(LoopFirmware.offeredOf(api.checkFirmwareUpdate(id).first()))
"updateFirmware" -> {
val file = call.argument<String>("file")
val updates = if (file == null) api.updateFirmware(id) else api.updateFirmware(id, "file://$file")
var last = "none"
updates.collect { status ->
last = LoopFirmware.stageOf(status)
send(id, "firmware", "stage" to last)
if (status is FirmwareUpdateStatus.FwUpdateFailed) throw IllegalStateException(status.details)
}
result.success(last)
}
else -> result.notImplemented()
}
}

View file

@ -0,0 +1,46 @@
package app.halkyon.train
import com.polar.sdk.api.model.CheckFirmwareUpdateStatus
import com.polar.sdk.api.model.FirmwareUpdateStatus
import fi.polar.remote.representation.protobuf.Device
/**
* The Loop's firmware for the app (ADR 0030, D10, D40): what Polar's
* firmware service is asked with, as the SDK reads it from the band's
* `/DEVICE.BPB`, and how an update goes, as plain values.
*/
object LoopFirmware {
/** The band's file holding its firmware's version and hardware code. */
const val DEVICE_FILE = "/DEVICE.BPB"
/** [device] (the band's `/DEVICE.BPB`) with Polar's [uuid] of the band, as the relay asks with them. */
fun infoOf(device: ByteArray, uuid: String): Map<String, Any?> {
val info = Device.PbDeviceInfo.parseFrom(device)
val version = info.deviceVersion
return mapOf(
"uuid" to uuid,
"version" to "${version.major}.${version.minor}.${version.patch}",
"hardwareCode" to info.hardwareCode,
)
}
/** The version Polar offers; null where it offers none. Throws where the check failed. */
fun offeredOf(status: CheckFirmwareUpdateStatus): String? =
when (status) {
is CheckFirmwareUpdateStatus.CheckFwUpdateAvailable -> status.version
is CheckFirmwareUpdateStatus.CheckFwUpdateNotAvailable -> null
is CheckFirmwareUpdateStatus.CheckFwUpdateFailed -> throw IllegalStateException(status.details)
}
/** How far an update is, as the app shows it: `fetching`, `preparing`, `writing`, `finalizing`, `done`, `none`, or `failed`. */
fun stageOf(status: FirmwareUpdateStatus): String =
when (status) {
is FirmwareUpdateStatus.FetchingFwUpdatePackage -> "fetching"
is FirmwareUpdateStatus.PreparingDeviceForFwUpdate -> "preparing"
is FirmwareUpdateStatus.WritingFwUpdatePackage -> "writing"
is FirmwareUpdateStatus.FinalizingFwUpdate -> "finalizing"
is FirmwareUpdateStatus.FwUpdateCompletedSuccessfully -> "done"
is FirmwareUpdateStatus.FwUpdateNotAvailable -> "none"
is FirmwareUpdateStatus.FwUpdateFailed -> "failed"
}
}

View file

@ -0,0 +1,41 @@
package app.halkyon.train
import com.polar.sdk.api.model.CheckFirmwareUpdateStatus
import com.polar.sdk.api.model.FirmwareUpdateStatus
import fi.polar.remote.representation.protobuf.Device
import fi.polar.remote.representation.protobuf.Structures
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
// The Loop's firmware (ADR 0030, D10, D40): what the relay asks Polar with,
// as the SDK reads it from the band, and an update's stages.
class LoopFirmwareTest {
@Test
fun `the band's file gives its version and hardware code, with Polar's UUID of it`() {
val device =
Device.PbDeviceInfo.newBuilder()
.setDeviceVersion(Structures.PbVersion.newBuilder().setMajor(3).setMinor(0).setPatch(12))
.setHardwareCode("00123456.01")
.setModelName("Polar Loop")
.buildPartial()
.toByteArray()
assertEquals(
mapOf("uuid" to "uuid-of-A1", "version" to "3.0.12", "hardwareCode" to "00123456.01"),
LoopFirmware.infoOf(device, "uuid-of-A1"),
)
}
@Test
fun `a check names the version offered, or none`() {
assertEquals("3.1.2", LoopFirmware.offeredOf(CheckFirmwareUpdateStatus.CheckFwUpdateAvailable("3.1.2")))
assertNull(LoopFirmware.offeredOf(CheckFirmwareUpdateStatus.CheckFwUpdateNotAvailable("")))
}
@Test
fun `an update's stages are named for the app`() {
assertEquals("writing", LoopFirmware.stageOf(FirmwareUpdateStatus.WritingFwUpdatePackage("50 %")))
assertEquals("done", LoopFirmware.stageOf(FirmwareUpdateStatus.FwUpdateCompletedSuccessfully("")))
}
}