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
4 changed files with 2318 additions and 45 deletions
|
|
@ -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)', () {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'}');
|
||||
|
|
|
|||
Loading…
Reference in a new issue