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

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