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
20 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("")))
}
}

View file

@ -208,6 +208,20 @@
"loopBattery": "Akku {level} %",
"loopCharging": "Akku {level} %, lädt",
"loopFirmware": "Firmware {version}",
"loopFirmwareCheck": "Nach Firmware-Update suchen",
"loopFirmwareCurrent": "Die Firmware des Armbands ist aktuell.",
"loopFirmwareOffered": "Firmware {version} verfügbar",
"loopFirmwareViaPolar": "Direkt von Polar geholt: Polar erfährt die Adresse dieses Telefons (oder die seines VPN), dazu Firmware-Version und Hardware-Code des Armbands.",
"loopFirmwareViaInstance": "Über deine Instanz geholt: Polar erfährt die Adresse der Instanz, nicht die dieses Telefons, dazu Firmware-Version und Hardware-Code des Armbands.",
"loopFirmwareErases": "Das Update löscht das Armband. Train sichert zuerst, was darauf ist, und richtet es danach wieder mit deinem Profil ein.",
"loopFirmwareUpdate": "Aktualisieren",
"loopFirmwareUpdateTitle": "Firmware des Armbands aktualisieren?",
"loopFirmwareUpdateText": "Train sichert zuerst, was auf dem Armband ist, da das Update es löscht. Das Armband startet neu, was einige Minuten dauert, und Train richtet es danach wieder mit deinem Profil ein. Lass es bis dahin beim Telefon.",
"loopFirmwareStage": "{stage, select, reading{Sichere, was auf dem Armband ist …} fetching{Hole die Firmware …} preparing{Bereite das Armband vor …} writing{Schreibe die Firmware …} finalizing{Schließe das Update ab …} done{Geschrieben} restarting{Das Armband startet neu …} other{Aktualisiere …}}",
"loopFirmwareStageHint": "Lass das Armband beim Telefon und Train geöffnet.",
"loopFirmwareUpdated": "Firmware aktualisiert; das Armband ist wieder eingerichtet.",
"loopFirmwareThroughInstance": "Firmware über deine Instanz",
"loopFirmwareThroughInstanceHint": "Polar erfährt dann die Adresse der Instanz statt die dieses Telefons. Aus, fragt das Telefon Polar direkt.",
"loopProfileChanged": "Dein Körper hat sich geändert, seit das Band eingerichtet wurde",
"loopProfileChangedHint": "Senden richtet das Band mit deinen jetzigen Angaben neu ein.",
"loopSend": "Ans Band senden",

View file

@ -264,6 +264,22 @@
"@loopCharging": {"placeholders": {"level": {"type": "int"}}},
"loopFirmware": "Firmware {version}",
"@loopFirmware": {"placeholders": {"version": {"type": "String", "example": "1.2.3"}}},
"loopFirmwareCheck": "Check for a firmware update",
"loopFirmwareCurrent": "The band's firmware is up to date.",
"loopFirmwareOffered": "Firmware {version} available",
"@loopFirmwareOffered": {"placeholders": {"version": {"type": "String", "example": "2.1.0"}}},
"loopFirmwareViaPolar": "Fetched from Polar directly: Polar learns this phone's address (or its VPN's), with the band's firmware version and hardware code.",
"loopFirmwareViaInstance": "Fetched through your instance: Polar learns the instance's address, not this phone's, with the band's firmware version and hardware code.",
"loopFirmwareErases": "The update erases the band. Train first keeps what is on it, then sets it up again with your profile.",
"loopFirmwareUpdate": "Update",
"loopFirmwareUpdateTitle": "Update the band's firmware?",
"loopFirmwareUpdateText": "Train first keeps what is on the band, as the update erases it. The band restarts, which takes a few minutes, and Train then sets it up again with your profile. Keep it near the phone until then.",
"loopFirmwareStage": "{stage, select, reading{Keeping what is on the band…} fetching{Fetching the firmware…} preparing{Preparing the band…} writing{Writing the firmware…} finalizing{Finishing the update…} done{Written} restarting{The band restarts…} other{Updating…}}",
"@loopFirmwareStage": {"placeholders": {"stage": {"type": "String"}}},
"loopFirmwareStageHint": "Keep the band near the phone, and Train open.",
"loopFirmwareUpdated": "Firmware updated; the band is set up again.",
"loopFirmwareThroughInstance": "Firmware through your instance",
"loopFirmwareThroughInstanceHint": "Polar then learns the instance's address rather than this phone's. Off, the phone asks Polar directly.",
"loopProfileChanged": "Your body changed since the band was set up",
"loopProfileChangedHint": "Sending sets the band up again with your figures as they are now.",
"loopSend": "Send to the band",

View file

@ -1270,6 +1270,90 @@ abstract class AppLocalizations {
/// **'Firmware {version}'**
String loopFirmware(String version);
/// No description provided for @loopFirmwareCheck.
///
/// In en, this message translates to:
/// **'Check for a firmware update'**
String get loopFirmwareCheck;
/// No description provided for @loopFirmwareCurrent.
///
/// In en, this message translates to:
/// **'The band\'s firmware is up to date.'**
String get loopFirmwareCurrent;
/// No description provided for @loopFirmwareOffered.
///
/// In en, this message translates to:
/// **'Firmware {version} available'**
String loopFirmwareOffered(String version);
/// No description provided for @loopFirmwareViaPolar.
///
/// In en, this message translates to:
/// **'Fetched from Polar directly: Polar learns this phone\'s address (or its VPN\'s), with the band\'s firmware version and hardware code.'**
String get loopFirmwareViaPolar;
/// No description provided for @loopFirmwareViaInstance.
///
/// In en, this message translates to:
/// **'Fetched through your instance: Polar learns the instance\'s address, not this phone\'s, with the band\'s firmware version and hardware code.'**
String get loopFirmwareViaInstance;
/// No description provided for @loopFirmwareErases.
///
/// In en, this message translates to:
/// **'The update erases the band. Train first keeps what is on it, then sets it up again with your profile.'**
String get loopFirmwareErases;
/// No description provided for @loopFirmwareUpdate.
///
/// In en, this message translates to:
/// **'Update'**
String get loopFirmwareUpdate;
/// No description provided for @loopFirmwareUpdateTitle.
///
/// In en, this message translates to:
/// **'Update the band\'s firmware?'**
String get loopFirmwareUpdateTitle;
/// No description provided for @loopFirmwareUpdateText.
///
/// In en, this message translates to:
/// **'Train first keeps what is on the band, as the update erases it. The band restarts, which takes a few minutes, and Train then sets it up again with your profile. Keep it near the phone until then.'**
String get loopFirmwareUpdateText;
/// No description provided for @loopFirmwareStage.
///
/// In en, this message translates to:
/// **'{stage, select, reading{Keeping what is on the band…} fetching{Fetching the firmware…} preparing{Preparing the band…} writing{Writing the firmware…} finalizing{Finishing the update…} done{Written} restarting{The band restarts…} other{Updating…}}'**
String loopFirmwareStage(String stage);
/// No description provided for @loopFirmwareStageHint.
///
/// In en, this message translates to:
/// **'Keep the band near the phone, and Train open.'**
String get loopFirmwareStageHint;
/// No description provided for @loopFirmwareUpdated.
///
/// In en, this message translates to:
/// **'Firmware updated; the band is set up again.'**
String get loopFirmwareUpdated;
/// No description provided for @loopFirmwareThroughInstance.
///
/// In en, this message translates to:
/// **'Firmware through your instance'**
String get loopFirmwareThroughInstance;
/// No description provided for @loopFirmwareThroughInstanceHint.
///
/// In en, this message translates to:
/// **'Polar then learns the instance\'s address rather than this phone\'s. Off, the phone asks Polar directly.'**
String get loopFirmwareThroughInstanceHint;
/// No description provided for @loopProfileChanged.
///
/// In en, this message translates to:

View file

@ -702,6 +702,72 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Firmware $version';
}
@override
String get loopFirmwareCheck => 'Nach Firmware-Update suchen';
@override
String get loopFirmwareCurrent => 'Die Firmware des Armbands ist aktuell.';
@override
String loopFirmwareOffered(String version) {
return 'Firmware $version verfügbar';
}
@override
String get loopFirmwareViaPolar =>
'Direkt von Polar geholt: Polar erfährt die Adresse dieses Telefons (oder die seines VPN), dazu Firmware-Version und Hardware-Code des Armbands.';
@override
String get loopFirmwareViaInstance =>
'Über deine Instanz geholt: Polar erfährt die Adresse der Instanz, nicht die dieses Telefons, dazu Firmware-Version und Hardware-Code des Armbands.';
@override
String get loopFirmwareErases =>
'Das Update löscht das Armband. Train sichert zuerst, was darauf ist, und richtet es danach wieder mit deinem Profil ein.';
@override
String get loopFirmwareUpdate => 'Aktualisieren';
@override
String get loopFirmwareUpdateTitle => 'Firmware des Armbands aktualisieren?';
@override
String get loopFirmwareUpdateText =>
'Train sichert zuerst, was auf dem Armband ist, da das Update es löscht. Das Armband startet neu, was einige Minuten dauert, und Train richtet es danach wieder mit deinem Profil ein. Lass es bis dahin beim Telefon.';
@override
String loopFirmwareStage(String stage) {
String _temp0 = intl.Intl.selectLogic(
stage,
{
'reading': 'Sichere, was auf dem Armband ist …',
'fetching': 'Hole die Firmware …',
'preparing': 'Bereite das Armband vor …',
'writing': 'Schreibe die Firmware …',
'finalizing': 'Schließe das Update ab …',
'done': 'Geschrieben',
'restarting': 'Das Armband startet neu …',
'other': 'Aktualisiere …',
},
);
return '$_temp0';
}
@override
String get loopFirmwareStageHint =>
'Lass das Armband beim Telefon und Train geöffnet.';
@override
String get loopFirmwareUpdated =>
'Firmware aktualisiert; das Armband ist wieder eingerichtet.';
@override
String get loopFirmwareThroughInstance => 'Firmware über deine Instanz';
@override
String get loopFirmwareThroughInstanceHint =>
'Polar erfährt dann die Adresse der Instanz statt die dieses Telefons. Aus, fragt das Telefon Polar direkt.';
@override
String get loopProfileChanged =>
'Dein Körper hat sich geändert, seit das Band eingerichtet wurde';

View file

@ -698,6 +698,72 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Firmware $version';
}
@override
String get loopFirmwareCheck => 'Check for a firmware update';
@override
String get loopFirmwareCurrent => 'The band\'s firmware is up to date.';
@override
String loopFirmwareOffered(String version) {
return 'Firmware $version available';
}
@override
String get loopFirmwareViaPolar =>
'Fetched from Polar directly: Polar learns this phone\'s address (or its VPN\'s), with the band\'s firmware version and hardware code.';
@override
String get loopFirmwareViaInstance =>
'Fetched through your instance: Polar learns the instance\'s address, not this phone\'s, with the band\'s firmware version and hardware code.';
@override
String get loopFirmwareErases =>
'The update erases the band. Train first keeps what is on it, then sets it up again with your profile.';
@override
String get loopFirmwareUpdate => 'Update';
@override
String get loopFirmwareUpdateTitle => 'Update the band\'s firmware?';
@override
String get loopFirmwareUpdateText =>
'Train first keeps what is on the band, as the update erases it. The band restarts, which takes a few minutes, and Train then sets it up again with your profile. Keep it near the phone until then.';
@override
String loopFirmwareStage(String stage) {
String _temp0 = intl.Intl.selectLogic(
stage,
{
'reading': 'Keeping what is on the band…',
'fetching': 'Fetching the firmware…',
'preparing': 'Preparing the band…',
'writing': 'Writing the firmware…',
'finalizing': 'Finishing the update…',
'done': 'Written',
'restarting': 'The band restarts…',
'other': 'Updating…',
},
);
return '$_temp0';
}
@override
String get loopFirmwareStageHint =>
'Keep the band near the phone, and Train open.';
@override
String get loopFirmwareUpdated =>
'Firmware updated; the band is set up again.';
@override
String get loopFirmwareThroughInstance => 'Firmware through your instance';
@override
String get loopFirmwareThroughInstanceHint =>
'Polar then learns the instance\'s address rather than this phone\'s. Off, the phone asks Polar directly.';
@override
String get loopProfileChanged =>
'Your body changed since the band was set up';

View file

@ -14,6 +14,7 @@ import 'package:halkyon_train_flutter/src/feature/health/data/android_health_pla
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/android_loop_platform.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/firmware_relay_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
@ -348,6 +349,7 @@ Future<TrainPerson> _openPersonData(
days: days,
workouts: workouts,
backgroundLines: File(p.join(folder.path, 'loop_days.txt')),
relay: ServerpodFirmwareRelayGateway(client),
)..start());
return TrainPerson(
person,

View file

@ -3,17 +3,24 @@ import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
/// What the band's screen asked last, and how it went.
enum LoopOutcome() {
sent,
letGo,
/// Asked for its firmware (D40): none newer, or one found.
firmwareCurrent,
firmwareFound,
updated,
failed,
}
/// The band's screen (ADR 0030, D34, D35): where the band stands, sending
/// a changed profile when the person says, and letting it go.
/// The band's screen (ADR 0030, D34, D35, D40): where the band stands,
/// sending a changed profile when the person says, its firmware asked for
/// and updated, and letting it go.
class LoopCubit(final PolarLoop _loop) extends Cubit<LoopState> {
this : super(LoopState(status: _loop.status));
@ -31,12 +38,28 @@ class LoopCubit(final PolarLoop _loop) extends Cubit<LoopState> {
Future<void> letGo() => _do(_loop.letGo, LoopOutcome.letGo);
Future<void> _do(Future<void> Function() action, LoopOutcome done) async {
Future<void> checkFirmware() => _ask(
() async => await _loop.checkFirmware() == null
? LoopOutcome.firmwareCurrent
: LoopOutcome.firmwareFound,
);
Future<void> updateFirmware() =>
_do(_loop.updateFirmware, LoopOutcome.updated);
void setFirmwareVia(FirmwareVia via) => _loop.setFirmwareVia(via);
Future<void> _do(Future<void> Function() action, LoopOutcome done) =>
_ask(() async {
await action();
return done;
});
Future<void> _ask(Future<LoopOutcome> Function() action) async {
emit(LoopState(status: state.status, busy: true));
LoopOutcome outcome;
try {
await action();
outcome = done;
outcome = await action();
} on LoopFailed {
outcome = LoopOutcome.failed;
}

View file

@ -75,6 +75,38 @@ class AndroidLoopPlatform(final RecorderPlatform _recorder)
}
}
@override
Future<LoopFirmwareInfo> firmwareInfo(String id) async {
try {
return switch (await _channel.invokeMethod<Object?>('firmwareInfo', {
'id': id,
})) {
{
'uuid': final String uuid,
'version': final String version,
'hardwareCode': final String hardwareCode,
} =>
(uuid: uuid, version: version, hardwareCode: hardwareCode),
_ => throw const LoopFailed('firmware unread'),
};
} on PlatformException catch (error) {
throw LoopFailed(error.message);
}
}
@override
Future<String?> checkFirmware(String id) async {
try {
return await _channel.invokeMethod<String>('checkFirmware', {'id': id});
} on PlatformException catch (error) {
throw LoopFailed(error.message);
}
}
@override
Future<void> updateFirmware(String id, {String? file}) =>
_call('updateFirmware', {'id': id, 'file': ?file});
@override
Future<void> background(LoopBackground? config) =>
_call('background', {'config': config?.toMap()});
@ -110,5 +142,7 @@ LoopEvent? loopEventOf(Object? event) => switch (event) {
'version': final String version,
} =>
LoopFirmware(id, version),
{'id': final String id, 'event': 'firmware', 'stage': final String stage} =>
LoopFirmwareStage(id, stage),
_ => null,
};

View file

@ -0,0 +1,44 @@
import 'dart:typed_data';
import 'package:halkyon_train_client/halkyon_train_client.dart' show Client;
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
/// The Loop's firmware through the instance (ADR 0030, D10, D40): Train's
/// server asks Polar in the person's place and hands the file on.
abstract interface class FirmwareRelayGateway() {
/// Whether the instance relays firmware (`LOOP_FIRMWARE_RELAY`).
Future<bool> offered();
/// The version Polar offers for the band [info] names; null where none.
Future<String?> check(LoopFirmwareInfo info);
/// The file of [version] for bands of [hardwareCode].
Future<Uint8List> download({
required String hardwareCode,
required String version,
});
}
/// [FirmwareRelayGateway] on Train's server (`firmware`).
class ServerpodFirmwareRelayGateway(final Client _client)
implements FirmwareRelayGateway {
@override
Future<bool> offered() => _client.firmware.offered();
@override
Future<String?> check(LoopFirmwareInfo info) async =>
(await _client.firmware.check(
info.uuid,
info.version,
info.hardwareCode,
))?.version;
@override
Future<Uint8List> download({
required String hardwareCode,
required String version,
}) async {
final file = await _client.firmware.download(hardwareCode, version);
return file.buffer.asUint8List(file.offsetInBytes, file.lengthInBytes);
}
}

View file

@ -30,6 +30,14 @@ final class LoopCharging(super.id, {required final bool charging})
/// Its firmware's version.
final class LoopFirmware(super.id, final String version) extends LoopEvent;
/// How far a firmware update is (D40): `fetching`, `preparing`, `writing`,
/// `finalizing`, `done`.
final class LoopFirmwareStage(super.id, final String stage) extends LoopEvent;
/// What Polar's firmware service is asked with (D10): Polar's UUID of the
/// band, its firmware's version and its hardware code.
typedef LoopFirmwareInfo = ({String uuid, String version, String hardwareCode});
/// The Loop through Polar's SDK on this phone (D34); each call throws a
/// [LoopFailed] where the band refused or wasn't there.
abstract interface class LoopPlatform() {
@ -66,6 +74,18 @@ abstract interface class LoopPlatform() {
DateTime? keptUntil,
});
/// What the band's firmware is, for the instance to ask Polar with (D40).
Future<LoopFirmwareInfo> firmwareInfo(String id);
/// Asks Polar directly for an update of the band's firmware; its version,
/// null where there is none.
Future<String?> checkFirmware(String id);
/// Updates the band's firmware, which erases it: fetched from Polar, or
/// from [file] (a package the instance relayed); its stages as
/// [LoopFirmwareStage]s. Throws [LoopFailed] where it failed.
Future<void> updateFirmware(String id, {String? file});
/// Has the band read every few hours while the app is closed, into
/// `loop_days.txt` in the person's folder, with what the app kept; or no longer,
/// where [config] is null.

View file

@ -6,6 +6,13 @@ import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
/// Where the band's firmware is asked for and fetched (ADR 0030, D10):
/// from Polar directly, or through the instance.
enum FirmwareVia() {
polar,
instance,
}
/// The person's Loop on this phone (ADR 0030, D34): which band, what it was
/// set up with, and what it told last. Never synced: one phone holds the
/// band (D3).
@ -33,6 +40,12 @@ final class const KnownLoop({
/// The band's training sessions taken in (D39), by ID, each with its
/// date, so that one isn't brought again, deleted in Train or not.
final Map<String, DateTime> takenSessions = const {},
/// Where its firmware comes from (D10), and the band's date it was last
/// asked for, once a day (D40); the update offered then, if any.
final FirmwareVia firmwareVia = FirmwareVia.polar,
final DateTime? firmwareChecked,
final String? firmwareOffered,
}) with Equatable {
KnownLoop copyWith({
String? name,
@ -44,6 +57,9 @@ final class const KnownLoop({
DateTime? keptUntil,
DateTime? lastRead,
Map<String, DateTime>? takenSessions,
FirmwareVia? firmwareVia,
DateTime? firmwareChecked,
String? Function()? firmwareOffered,
}) => KnownLoop(
id: id,
name: name ?? this.name,
@ -55,6 +71,11 @@ final class const KnownLoop({
keptUntil: keptUntil ?? this.keptUntil,
lastRead: lastRead ?? this.lastRead,
takenSessions: takenSessions ?? this.takenSessions,
firmwareVia: firmwareVia ?? this.firmwareVia,
firmwareChecked: firmwareChecked ?? this.firmwareChecked,
firmwareOffered: firmwareOffered == null
? this.firmwareOffered
: firmwareOffered(),
);
@override
@ -69,6 +90,9 @@ final class const KnownLoop({
keptUntil,
lastRead,
takenSessions,
firmwareVia,
firmwareChecked,
firmwareOffered,
];
}
@ -105,6 +129,11 @@ class LoopStore(final File _file) {
for (final MapEntry(:key, :value) in taken.entries)
key: ?dateOfKey(value),
},
firmwareVia: json['firmwareVia'] == FirmwareVia.instance.name
? FirmwareVia.instance
: FirmwareVia.polar,
firmwareChecked: dateOfKey(json['firmwareChecked']),
firmwareOffered: _as<String>(json['firmwareOffered']),
),
_ => null,
};
@ -133,6 +162,13 @@ class LoopStore(final File _file) {
for (final MapEntry(:key, :value) in loop.takenSessions.entries)
key: dayKey(value),
},
if (loop.firmwareVia != FirmwareVia.polar)
'firmwareVia': loop.firmwareVia.name,
'firmwareChecked': ?switch (loop.firmwareChecked) {
final date? => dayKey(date),
null => null,
},
'firmwareOffered': ?loop.firmwareOffered,
}),
);

View file

@ -7,6 +7,8 @@ import 'package:halkyon_core/halkyon_core.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_workouts.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/firmware_relay_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
@ -29,6 +31,10 @@ const _firstReadDays = 28;
/// the band has deleted it long since (D39).
const _takenKept = Duration(days: 7);
/// How long the band has to come back after its firmware was written: it
/// restarts, and may take a few minutes (D40).
const _backWithin = Duration(minutes: 5);
/// Where the person's Loop stands.
final class const LoopStatus({
/// The band remembered; null without one.
@ -38,9 +44,36 @@ final class const LoopStatus({
/// Whether the profile changed since the band was set up with it, and can
/// be sent (D35).
final bool profileChanged = false,
/// Whether the instance relays firmware (D40).
final bool relayOffered = false,
/// How far an update of its firmware is (a [LoopFirmwareStage]'s), or
/// `reading` before it; null while none runs.
final String? firmwareStage,
}) with Equatable {
LoopStatus copyWith({
KnownLoop? loop,
bool? connected,
bool? profileChanged,
bool? relayOffered,
String? Function()? firmwareStage,
}) => LoopStatus(
loop: loop ?? this.loop,
connected: connected ?? this.connected,
profileChanged: profileChanged ?? this.profileChanged,
relayOffered: relayOffered ?? this.relayOffered,
firmwareStage: firmwareStage == null ? this.firmwareStage : firmwareStage(),
);
@override
List<Object?> get props => [loop, connected, profileChanged];
List<Object?> get props => [
loop,
connected,
profileChanged,
relayOffered,
firmwareStage,
];
}
/// The person's Polar Loop on this phone (ADR 0030, D34, D35): set up with
@ -52,6 +85,9 @@ final class const LoopStatus({
/// most, and kept sealed in the person's database; what the background job
/// read meanwhile into [backgroundLines] is kept as the app opens. Once
/// kept, a day before today is deleted on the band by the next read (D38).
/// Its firmware (D10, D40) is asked for on the day's first connection and
/// when the person asks, from Polar or through the instance as they chose,
/// and updated only when they say.
class PolarLoop(
/// Polar's SDK.
final LoopPlatform platform,
@ -66,6 +102,9 @@ class PolarLoop(
/// writes what it reads.
required final File backgroundLines,
/// The instance's firmware relay; null where there is none to ask.
final FirmwareRelayGateway? relay,
/// Today, for the age the defaults take and the dates read.
final DateTime Function() today = DateTime.now,
final DateTime Function() now = DateTime.now,
@ -88,6 +127,17 @@ class PolarLoop(
/// Whether the next read goes to the band however recent the last.
var _readAsked = false;
late final _checking = CoalescingRunner(
_checkDaily,
onError: (error, stackTrace) =>
_failed('checked for firmware', error, stackTrace),
);
/// Whether its firmware is being updated, during which nothing else asks
/// the band; and whether it is being written, the stages it tells
/// shown only then, as one coming late would hide the restart's.
var _updating = false;
var _writing = false;
late final _clocking = CoalescingRunner(
_setClock,
onError: (error, stackTrace) =>
@ -109,6 +159,7 @@ class PolarLoop(
unawaited(_comparing.run());
// What the background job read while the app was closed.
unawaited(_reading.run());
unawaited(_askRelay());
final known = _status.loop;
if (known != null) {
unawaited(_schedule());
@ -121,6 +172,7 @@ class PolarLoop(
await _comparing.idle;
await _clocking.idle;
await _reading.idle;
await _checking.idle;
}
/// Reads the band's days now, where it is ready, however recent the last
@ -162,12 +214,19 @@ class PolarLoop(
keptUntil: same ? before?.keptUntil : null,
lastRead: same ? before?.lastRead : null,
takenSessions: same ? before!.takenSessions : const {},
firmwareChecked: same ? before?.firmwareChecked : null,
firmwareOffered: same ? before?.firmwareOffered : null,
// The person's choice, whichever band.
firmwareVia: before?.firmwareVia ?? FirmwareVia.polar,
),
connected: true,
relayOffered: _status.relayOffered,
),
);
await _comparing.run();
await _schedule();
// A band just set up is asked for its firmware as a connection is.
unawaited(_checking.run());
}
/// Sends the profile as it is now to the band (D35); throws [LoopFailed]
@ -183,15 +242,103 @@ class PolarLoop(
throw const LoopFailed('not connected');
}
await platform.setUp(known.id, figures);
_update(
LoopStatus(
loop: known.copyWith(figures: figures),
connected: _status.connected,
),
);
_update(_status.copyWith(loop: known.copyWith(figures: figures)));
await _comparing.run();
}
/// Where the band's firmware comes from from now on (D10).
void setFirmwareVia(FirmwareVia via) {
if (_status.loop case final known?) {
_update(_with(known.copyWith(firmwareVia: via)));
}
}
/// Asks for an update of the band's firmware now, the way the person
/// chose (D40); the version offered, null where there is none. Throws
/// [LoopFailed] where the band isn't ready or the asking failed.
Future<String?> checkFirmware() async {
final known = _status.loop;
if (known == null) {
return null;
}
if (!_ready.contains(known.id) || _updating) {
throw const LoopFailed('not connected');
}
return await _check(known);
}
/// Updates the band's firmware to the version offered (D40): reads and
/// keeps what is on it first, as the update erases it, then writes the
/// firmware, fetched from Polar or relayed by the instance, and sets the
/// band up again with the figures it had and its clock. Throws
/// [LoopFailed] where it isn't ready, the read or the update failed, or
/// it didn't come back; a failed read leaves the band untouched.
Future<void> updateFirmware() async {
final known = _status.loop;
final version = known?.firmwareOffered;
if (known == null || version == null) {
return;
}
if (!_ready.contains(known.id) || _updating) {
throw const LoopFailed('not connected');
}
final id = known.id;
_updating = true;
_update(_status.copyWith(firmwareStage: () => 'reading'));
final file = File(p.join(backgroundLines.parent.path, 'loop_firmware.zip'));
try {
await _reading.idle;
await _readBand(known);
if (known.firmwareVia == FirmwareVia.instance) {
final relay = _relay();
final info = await platform.firmwareInfo(id);
final offered = await _relayed(() => relay.check(info));
if (offered == null) {
_update(_with(_status.loop!.copyWith(firmwareOffered: () => null)));
throw const LoopFailed('no update');
}
final bytes = await _relayed(
() =>
relay.download(hardwareCode: info.hardwareCode, version: offered),
);
await file.writeAsBytes(bytes, flush: true);
_writing = true;
await platform.updateFirmware(id, file: file.path);
} else {
_writing = true;
await platform.updateFirmware(id);
}
_writing = false;
// It restarts: what it is set up with comes once it is back.
_ready.remove(id);
_update(_status.copyWith(firmwareStage: () => 'restarting'));
await platform.connect(id);
await _readyOf(id, within: _backWithin);
final figures = known.figures ?? _current;
if (figures != null) {
await platform.setUp(id, figures);
}
await platform.setClock(id);
_update(
_with(
_status.loop!.copyWith(
figures: figures,
firmware: version,
firmwareOffered: () => null,
),
),
);
await _comparing.run();
} finally {
_updating = false;
_writing = false;
if (file.existsSync()) {
file.deleteSync();
}
_update(_status.copyWith(firmwareStage: () => null));
}
}
/// Resets the band and forgets it, the only way it pairs elsewhere (D34);
/// throws [LoopFailed] where it isn't in reach, and keeps it then.
Future<void> letGo() async {
@ -208,7 +355,7 @@ class PolarLoop(
}
_ready.remove(known.id);
_store.forget();
_status = const LoopStatus();
_status = LoopStatus(relayOffered: _status.relayOffered);
_changes.add(_status);
}
@ -235,7 +382,7 @@ class PolarLoop(
}
}
Future<void> _readyOf(String id) {
Future<void> _readyOf(String id, {Duration within = _readyWithin}) {
if (_ready.contains(id)) {
return Future.value();
}
@ -243,7 +390,7 @@ class PolarLoop(
.putIfAbsent(id, Completer.new)
.future
.timeout(
_readyWithin,
within,
onTimeout: () => throw const LoopFailed('not ready'),
)
.whenComplete(() => _waiting.remove(id));
@ -266,22 +413,25 @@ class PolarLoop(
break;
case LoopConnected(:final name):
_update(
LoopStatus(
_status.copyWith(
loop: known.copyWith(name: name, lastSeen: () => null),
connected: true,
profileChanged: _status.profileChanged,
),
);
case LoopReady():
// Each connection sets the band's clock (D34), and reads its days
// where the last read is long enough ago (D37).
unawaited(_clocking.run());
unawaited(_reading.run());
// Each connection sets the band's clock (D34), reads its days where
// the last read is long enough ago (D37), and asks for its firmware
// on the day's first (D40); an update does all this itself.
if (!_updating) {
unawaited(_clocking.run());
unawaited(_reading.run());
unawaited(_checking.run());
}
case LoopDisconnected():
_update(
LoopStatus(
_status.copyWith(
loop: known.copyWith(lastSeen: () => DateTime.now().toUtc()),
profileChanged: _status.profileChanged,
connected: false,
),
);
case LoopBattery(:final level):
@ -290,6 +440,79 @@ class PolarLoop(
_update(_with(known.copyWith(charging: charging)));
case LoopFirmware(:final version):
_update(_with(known.copyWith(firmware: version)));
case LoopFirmwareStage(:final stage):
if (_writing) {
_update(_status.copyWith(firmwareStage: () => stage));
}
}
}
/// Asks the instance whether it relays firmware.
Future<void> _askRelay() async {
final relay = this.relay;
if (relay == null) {
return;
}
try {
final offered = await relay.offered();
_update(_status.copyWith(relayOffered: offered));
} on Object catch (error, st) {
_failed('asked about the relay', error, st);
}
}
/// Asks for the band's firmware where it wasn't asked for on the band's
/// date yet.
Future<void> _checkDaily() async {
final known = _status.loop;
if (known == null || !_ready.contains(known.id) || _updating) {
return;
}
if (known.firmwareChecked case final checked?
when dayKey(checked) == dayKey(today())) {
return;
}
await _check(known);
}
/// Asks for an update for [known] the way the person chose, and
/// remembers the answer; never Polar directly where the instance was
/// chosen.
Future<String?> _check(KnownLoop known) async {
final String? offered;
if (known.firmwareVia == FirmwareVia.instance) {
final relay = _relay();
final info = await platform.firmwareInfo(known.id);
offered = await _relayed(() => relay.check(info));
} else {
offered = await platform.checkFirmware(known.id);
}
final date = today();
if (_status.loop case final now? when now.id == known.id) {
_update(
_with(
now.copyWith(
firmwareChecked: DateTime.utc(date.year, date.month, date.day),
firmwareOffered: () => offered,
),
),
);
}
return offered;
}
FirmwareRelayGateway _relay() =>
relay ?? (throw const LoopFailed('no relay'));
/// [ask]'s answer from the instance; its failure (refused, offline) as a
/// [LoopFailed].
Future<T> _relayed<T>(Future<T> Function() ask) async {
try {
return await ask();
} on LoopFailed {
rethrow;
} on Object catch (error) {
throw LoopFailed('relay: ${error.runtimeType}');
}
}
@ -312,9 +535,15 @@ class PolarLoop(
return;
}
final last = known.lastRead;
if (!asked && last != null && now().difference(last) < _readEvery) {
if (_updating ||
!asked && last != null && now().difference(last) < _readEvery) {
return;
}
await _readBand(known);
}
/// Reads [known]'s days and keeps them; throws where the read failed.
Future<void> _readBand(KnownLoop known) async {
final lines = LoopLines();
(await platform.readDays(
known.id,
@ -438,11 +667,7 @@ class PolarLoop(
}
}
LoopStatus _with(KnownLoop loop) => LoopStatus(
loop: loop,
connected: _status.connected,
profileChanged: _status.profileChanged,
);
LoopStatus _with(KnownLoop loop) => _status.copyWith(loop: loop);
/// Works out the figures the profile gives now, and whether they differ
/// from the band's.
@ -452,9 +677,7 @@ class PolarLoop(
final sent = _status.loop?.figures;
final current = _current;
_update(
LoopStatus(
loop: _status.loop,
connected: _status.connected,
_status.copyWith(
profileChanged: sent != null && current != null && current != sent,
),
);

View file

@ -6,6 +6,7 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/loop/bloc/loop_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/loop/bloc/loop_setup_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
@ -31,9 +32,11 @@ Future<void> openLoopScreen(BuildContext context) {
);
}
/// The person's Polar Loop (ADR 0030, D34, D35): its name, battery,
/// The person's Polar Loop (ADR 0030, D34, D35, D40): its name, battery,
/// firmware, whether it is connected or when it was last; a changed profile
/// offered to it; set up again, or let go after asking.
/// offered to it; a firmware update offered with what Polar learns, made
/// after asking, and where the firmware comes from; set up again, or let go
/// after asking.
class const LoopScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -44,8 +47,10 @@ class const LoopScreen({super.key}) extends StatelessWidget {
listener: (context, state) {
final text = switch (state.outcome) {
LoopOutcome.sent => l10n.loopSent,
LoopOutcome.firmwareCurrent => l10n.loopFirmwareCurrent,
LoopOutcome.updated => l10n.loopFirmwareUpdated,
LoopOutcome.failed => l10n.loopFailed,
LoopOutcome.letGo || null => null,
LoopOutcome.letGo || LoopOutcome.firmwareFound || null => null,
};
if (text != null) {
// In place of the setup's, which would hold this one back.
@ -121,6 +126,7 @@ class const LoopScreen({super.key}) extends StatelessWidget {
leading: const Icon(LucideIcons.cpu),
title: Text(l10n.loopFirmware(version)),
),
..._firmware(context, l10n, state, known),
if (state.status.profileChanged)
Card(
margin: const EdgeInsets.all(16),
@ -175,6 +181,111 @@ class const LoopScreen({super.key}) extends StatelessWidget {
);
}
/// The firmware's part (D40): an update running, or one offered with
/// what Polar learns; asking for one; where it comes from, where the
/// instance relays it or was chosen.
List<Widget> _firmware(
BuildContext context,
AppLocalizations l10n,
LoopState state,
KnownLoop known,
) {
final theme = Theme.of(context);
final cubit = context.read<LoopCubit>();
final through = known.firmwareVia == FirmwareVia.instance;
final stage = state.status.firmwareStage;
final offered = known.firmwareOffered;
final card = switch ((stage, offered)) {
(final String stage, _) => [
Text(l10n.loopFirmwareStage(stage), style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(l10n.loopFirmwareStageHint),
const SizedBox(height: 12),
const LinearProgressIndicator(),
],
(null, final String version) => [
Text(
l10n.loopFirmwareOffered(version),
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
through ? l10n.loopFirmwareViaInstance : l10n.loopFirmwareViaPolar,
),
const SizedBox(height: 8),
Text(l10n.loopFirmwareErases),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(LucideIcons.download),
label: Text(l10n.loopFirmwareUpdate),
onPressed: state.busy || !state.status.connected
? null
: () => _update(context, l10n),
),
],
(null, null) => null,
};
return [
if (card != null)
Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: card,
),
),
),
ListTile(
leading: const Icon(LucideIcons.search),
title: Text(l10n.loopFirmwareCheck),
subtitle: Text(
through ? l10n.loopFirmwareViaInstance : l10n.loopFirmwareViaPolar,
),
onTap: state.busy || stage != null || !state.status.connected
? null
: cubit.checkFirmware,
),
if (state.status.relayOffered || through)
SwitchListTile(
secondary: const Icon(LucideIcons.server),
title: Text(l10n.loopFirmwareThroughInstance),
subtitle: Text(l10n.loopFirmwareThroughInstanceHint),
value: through,
onChanged: stage != null
? null
: (on) => cubit.setFirmwareVia(
on ? FirmwareVia.instance : FirmwareVia.polar,
),
),
];
}
Future<void> _update(BuildContext context, AppLocalizations l10n) async {
final cubit = context.read<LoopCubit>();
final sure = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.loopFirmwareUpdateTitle),
content: Text(l10n.loopFirmwareUpdateText),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.loopFirmwareUpdate),
),
],
),
);
if (sure ?? false) {
await cubit.updateFirmware();
}
}
Future<void> _letGo(BuildContext context, AppLocalizations l10n) async {
final cubit = context.read<LoopCubit>();
final sure = await showDialog<bool>(

View file

@ -14,6 +14,7 @@ import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/firmware_relay_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
@ -30,6 +31,7 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../support/fake_firmware_relay.dart';
import '../support/fake_health_platform.dart';
import '../support/fake_loop_platform.dart';
import '../support/fake_recorder_platform.dart';
@ -511,7 +513,10 @@ void main() {
}
/// Train signed in on a phone that holds a Loop, its screen open.
Future<void> openLoop(WidgetTester tester) async {
Future<void> openLoop(
WidgetTester tester, {
FirmwareRelayGateway? relay,
}) async {
loop = PolarLoop(
platform,
LoopStore(File('${folder.path}/loop.json')),
@ -519,6 +524,7 @@ void main() {
days: days,
workouts: WorkoutRepository(MemoryWorkoutGateway(), keys),
backgroundLines: File('${folder.path}/loop_days.txt'),
relay: relay,
);
await start(
tester,
@ -683,6 +689,65 @@ void main() {
expect(platform.calls, contains('reset A1'));
expect(find.text('Set up a Loop'), findsOneWidget);
});
testWidgets('a firmware update found says what Polar learns, and is '
'made only after asking (D40)', (tester) async {
platform.firmwareOffered = '2.1.0';
await tester.runAsync(() => profile.save(body));
await openLoop(tester);
await chooseBand(tester);
expect(find.text('Firmware 2.1.0 available'), findsOneWidget);
expect(
find.textContaining("Polar learns this phone's address"),
findsWidgets,
);
// No relay on this instance: the direct way alone.
expect(find.text('Firmware through your instance'), findsNothing);
await tester.tap(find.text('Update'));
await frames(tester);
expect(find.text("Update the band's firmware?"), findsOneWidget);
await tester.tap(find.text('Cancel'));
await settle(tester);
expect(platform.calls, isNot(contains('updateFirmware A1')));
platform.setUpWith.clear();
await tester.tap(find.text('Update'));
await frames(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Update').last);
await frames(tester);
await settle(tester);
expect(platform.calls, contains('updateFirmware A1'));
expect(platform.setUpWith['A1'], isNotNull);
expect(
find.text('Firmware updated; the band is set up again.'),
findsOneWidget,
);
expect(find.text('Firmware 2.1.0'), findsOneWidget);
expect(find.text('Firmware 2.1.0 available'), findsNothing);
});
testWidgets('an instance relaying firmware offers it; chosen, the update '
'is asked for there (D40)', (tester) async {
final relay = FakeFirmwareRelay()..version = '2.1.0';
await tester.runAsync(() => profile.save(body));
await openLoop(tester, relay: relay);
await chooseBand(tester);
await tester.tap(find.text('Firmware through your instance'));
await settle(tester);
await tester.tap(find.text('Check for a firmware update'));
await settle(tester);
expect(relay.checked, hasLength(1));
expect(find.text('Firmware 2.1.0 available'), findsOneWidget);
expect(
find.textContaining("Polar learns the instance's address"),
findsWidgets,
);
});
});
group('Health Connect (ADR 0030, D31)', () {

View file

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/firmware_relay_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
@ -14,6 +15,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platfo
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import '../../support/fake_firmware_relay.dart';
import '../../support/fake_loop_platform.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_day_gateway.dart';
@ -44,12 +46,16 @@ void main() {
late WorkoutRepository workouts;
late FakeLoopPlatform platform;
var now = DateTime.utc(2026, 10, 28, 9);
var day = today;
final opened = <PolarLoop>[];
/// What was asked of the band but its days, which their own group checks.
List<String> bandCalls() => [
for (final call in platform.calls)
if (!call.startsWith('readDays') && !call.startsWith('background')) call,
if (!call.startsWith('readDays') &&
!call.startsWith('background') &&
!call.startsWith('checkFirmware'))
call,
];
/// The reads of the band's days asked for, in order.
@ -60,7 +66,7 @@ void main() {
LoopStore store() => LoopStore(File('${folder.path}/loop.json'));
PolarLoop open() {
PolarLoop open({FirmwareRelayGateway? relay}) {
final loop = PolarLoop(
platform,
store(),
@ -68,7 +74,8 @@ void main() {
days: days,
workouts: workouts,
backgroundLines: File('${folder.path}/loop_days.txt'),
today: () => today,
relay: relay,
today: () => day,
now: () => now,
)..start();
opened.add(loop);
@ -97,6 +104,7 @@ void main() {
days = DayRepository(MemoryDayGateway(), keys, person: 'person');
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
now = DateTime.utc(2026, 10, 28, 9);
day = today;
platform = FakeLoopPlatform()..inReach.add('A1');
});
@ -428,4 +436,175 @@ void main() {
expect(platform.backgroundConfig, isNull);
});
});
group("the band's firmware (D10, D40)", () {
List<String> firmwareCalls() => [
for (final call in platform.calls)
if (call.contains('irmware')) call,
];
Future<PolarLoop> setUpBand({FirmwareRelayGateway? relay}) async {
final loop = open(relay: relay);
await loop.setUp(band, loopFiguresOf(body, today)!);
await settled(loop);
return loop;
}
test("Polar is asked on the day's first connection, not again that "
'day; an update found is remembered, never started', () async {
platform.firmwareOffered = '2.1.0';
final loop = await setUpBand();
expect(firmwareCalls(), ['checkFirmware A1']);
expect(store().read()?.firmwareOffered, '2.1.0');
platform
..goAway('A1')
..comeInReach('A1');
await settled(loop);
expect(firmwareCalls(), ['checkFirmware A1']);
day = today.add(const Duration(days: 1));
platform
..goAway('A1')
..comeInReach('A1');
await settled(loop);
expect(firmwareCalls(), ['checkFirmware A1', 'checkFirmware A1']);
expect(
platform.calls.where((c) => c.startsWith('updateFirmware')),
isEmpty,
);
});
test('asked for, Polar is asked again; none offered then forgets '
'the update', () async {
platform.firmwareOffered = '2.1.0';
final loop = await setUpBand();
platform.firmwareOffered = null;
expect(await loop.checkFirmware(), isNull);
expect(firmwareCalls(), ['checkFirmware A1', 'checkFirmware A1']);
expect(loop.status.loop?.firmwareOffered, isNull);
});
test('a band not connected is not asked', () async {
final loop = await setUpBand();
platform.goAway('A1');
await settled(loop);
await expectLater(loop.checkFirmware(), throwsA(isA<LoopFailed>()));
});
test('through the instance, the relay is asked and Polar never '
'directly', () async {
final relay = FakeFirmwareRelay()..version = '2.1.0';
final loop = await setUpBand(relay: relay);
expect(firmwareCalls(), ['checkFirmware A1']);
loop.setFirmwareVia(FirmwareVia.instance);
expect(await loop.checkFirmware(), '2.1.0');
expect(relay.checked, [
(uuid: 'uuid-A1', version: '1.0.0', hardwareCode: 'hw-1'),
]);
expect(firmwareCalls(), ['checkFirmware A1', 'firmwareInfo A1']);
expect(store().read()?.firmwareVia, FirmwareVia.instance);
});
test('a relay switched off refuses, and Polar is not asked in its '
'place', () async {
final relay = FakeFirmwareRelay()..on = false;
final loop = await setUpBand(relay: relay);
expect(loop.status.relayOffered, isFalse);
loop.setFirmwareVia(FirmwareVia.instance);
platform.calls.clear();
await expectLater(loop.checkFirmware(), throwsA(isA<LoopFailed>()));
expect(firmwareCalls(), ['firmwareInfo A1']);
});
test('the instance offering a relay is told', () async {
final loop = await setUpBand(relay: FakeFirmwareRelay());
expect(loop.status.relayOffered, isTrue);
});
test('an update reads and keeps the band first, then writes it and '
'sets the band up again with the figures it had', () async {
platform.firmwareOffered = '2.1.0';
final loop = await setUpBand();
platform.calls.clear();
final stages = <String?>[];
final following = loop.changes.listen(
(status) => stages.add(status.firmwareStage),
);
await loop.updateFirmware();
await settled(loop);
await following.cancel();
expect(
platform.calls,
containsAllInOrder([
'readDays A1 2026-09-30 -',
'updateFirmware A1',
'connect A1',
'setUp A1',
'setClock A1',
]),
);
expect(platform.setUpWith['A1'], loopFiguresOf(body, today));
expect(
stages,
containsAllInOrder([
'reading',
'fetching',
'writing',
'done',
'restarting',
null,
]),
);
expect(loop.status.firmwareStage, isNull);
final known = store().read()!;
expect(known.firmware, '2.1.0');
expect(known.firmwareOffered, isNull);
});
test('through the instance, the file relayed is written and gone '
'afterwards', () async {
final relay = FakeFirmwareRelay()..version = '2.1.0';
final loop = await setUpBand(relay: relay);
loop.setFirmwareVia(FirmwareVia.instance);
await loop.checkFirmware();
platform.calls.clear();
await loop.updateFirmware();
expect(relay.downloaded, ['hw-1/2.1.0']);
expect(platform.calls, contains('updateFirmware A1 file'));
expect(File('${folder.path}/loop_firmware.zip').existsSync(), isFalse);
});
test('a read failing leaves the band untouched', () async {
platform.firmwareOffered = '2.1.0';
final loop = await setUpBand();
platform.readFails = true;
await expectLater(loop.updateFirmware(), throwsA(isA<LoopFailed>()));
expect(
platform.calls.where((c) => c.startsWith('updateFirmware')),
isEmpty,
);
expect(loop.status.firmwareStage, isNull);
});
test('an update failing keeps the update offered', () async {
platform
..firmwareOffered = '2.1.0'
..firmwareFails = true;
final loop = await setUpBand();
await expectLater(loop.updateFirmware(), throwsA(isA<LoopFailed>()));
expect(loop.status.firmwareStage, isNull);
expect(store().read()?.firmwareOffered, '2.1.0');
});
});
}

View file

@ -0,0 +1,45 @@
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/loop/data/firmware_relay_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
/// The instance's firmware relay as tests want it: on or off, offering
/// [version]; what it was asked.
class FakeFirmwareRelay() implements FirmwareRelayGateway {
/// Whether the instance relays (`LOOP_FIRMWARE_RELAY`).
bool on = true;
/// The version offered; null for none.
String? version;
final checked = <LoopFirmwareInfo>[];
/// The files downloaded, as `hardware/version`.
final downloaded = <String>[];
@override
Future<bool> offered() async => on;
@override
Future<String?> check(LoopFirmwareInfo info) async {
_refusedWhenOff();
checked.add(info);
return version;
}
@override
Future<Uint8List> download({
required String hardwareCode,
required String version,
}) async {
_refusedWhenOff();
downloaded.add('$hardwareCode/$version');
return Uint8List.fromList([1, 2, 3]);
}
void _refusedWhenOff() {
if (!on) {
throw StateError('off');
}
}
}

View file

@ -1,5 +1,7 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
@ -24,12 +26,24 @@ class FakeLoopPlatform({
/// at a time; none left, nothing but the `read` line.
final days = <List<Map<String, Object?>>>[];
/// Whether reads of the days fail, the band in reach or not.
bool readFails = false;
/// The day reads happen on, as the band's calendar has it.
DateTime today = DateTime.utc(2026, 10, 28);
/// The background job's config, as last given; null where none runs.
LoopBackground? backgroundConfig;
/// The update Polar offers directly; null for none.
String? firmwareOffered;
/// Whether an update fails while it writes.
bool firmwareFails = false;
/// Whether a band comes back after its firmware was written.
bool comesBack = true;
/// The figures each band was set up with.
final setUpWith = <String, LoopFigures>{};
@ -111,7 +125,7 @@ class FakeLoopPlatform({
'readDays $id ${dayKey(from)} '
'${keptUntil == null ? '-' : dayKey(keptUntil)}',
);
if (!inReach.contains(id)) {
if (!inReach.contains(id) || readFails) {
throw const LoopFailed('not in reach');
}
return [
@ -125,6 +139,48 @@ class FakeLoopPlatform({
];
}
@override
Future<LoopFirmwareInfo> firmwareInfo(String id) async {
calls.add('firmwareInfo $id');
if (!inReach.contains(id)) {
throw const LoopFailed('not in reach');
}
return (uuid: 'uuid-$id', version: '1.0.0', hardwareCode: 'hw-1');
}
@override
Future<String?> checkFirmware(String id) async {
calls.add('checkFirmware $id');
if (!inReach.contains(id)) {
throw const LoopFailed('not in reach');
}
return firmwareOffered;
}
@override
Future<void> updateFirmware(String id, {String? file}) async {
calls.add('updateFirmware $id${file == null ? '' : ' file'}');
if (!inReach.contains(id)) {
throw const LoopFailed('not in reach');
}
_events
..add(LoopFirmwareStage(id, 'fetching'))
..add(LoopFirmwareStage(id, 'writing'));
// Told before the update returns, as the bridge does.
await pumpEventQueue();
if (firmwareFails) {
throw const LoopFailed('failed');
}
// It restarts, and connects again where it comes back.
_events
..add(LoopFirmwareStage(id, 'done'))
..add(LoopDisconnected(id));
await pumpEventQueue();
if (!comesBack) {
inReach.remove(id);
}
}
@override
Future<void> background(LoopBackground? config) async {
calls.add('background ${config == null ? 'off' : 'on'}');