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
4 changed files with 2318 additions and 45 deletions

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'}');