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:
parent
7c5e539df5
commit
388b3b1ae6
1 changed files with 2318 additions and 45 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue