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

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