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

@ -11,8 +11,11 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _ida;
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
import 'package:halkyon_train_client/src/protocol/firmware/loop_firmware.dart'
as _ikc7xqe5;
import 'package:halkyon_train_client/src/protocol/map/map_source.dart'
as _iebj1x2g;
import 'package:http/http.dart' as _i85jenna;
@ -28,6 +31,53 @@ import 'protocol.dart' as _il2as5qe;
import 'sync_tables.dart' as _ii3f3u05;
import 'package:halkyon_train_client/migrations/migration_registry.dart';
/// The Polar Loop's firmware through the instance (ADR 0030, D10, D40), for
/// the people signed in: the instance asks Polar in their place, so Polar
/// learns its address rather than theirs.
/// {@category Endpoint}
class EndpointFirmware extends _isc.EndpointRef {
EndpointFirmware(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'firmware';
/// Whether the instance relays firmware.
_ida.Future<bool> offered() => caller.callServerEndpoint<bool>(
'firmware',
'offered',
{},
);
/// The update for the band of [uuid] (Polar's UUID made from its ID) at
/// [firmwareVersion] with [hardwareCode]; null where there is none.
_ida.Future<_ikc7xqe5.LoopFirmware?> check(
String uuid,
String firmwareVersion,
String hardwareCode,
) => caller.callServerEndpoint<_ikc7xqe5.LoopFirmware?>(
'firmware',
'check',
{
'uuid': uuid,
'firmwareVersion': firmwareVersion,
'hardwareCode': hardwareCode,
},
);
/// The file of [version] for bands of [hardwareCode], as a check named it.
_ida.Future<_idt.ByteData> download(
String hardwareCode,
String version,
) => caller.callServerEndpoint<_idt.ByteData>(
'firmware',
'download',
{
'hardwareCode': hardwareCode,
'version': version,
},
);
}
/// The map workouts are drawn on (ADR 0030, D11, D22), for the people signed
/// in: an operator's provider may have its key in the style's address.
/// {@category Endpoint}
@ -93,16 +143,22 @@ class Client extends _isc.ServerpodClientShared {
disconnectStreamsOnLostInternetConnection,
httpClientOverride: httpClientOverride,
) {
firmware = EndpointFirmware(this);
map = EndpointMap(this);
modules = Modules(this);
}
late final EndpointFirmware firmware;
late final EndpointMap map;
late final Modules modules;
@override
Map<String, _isc.EndpointRef> get endpointRefLookup => {'map': map};
Map<String, _isc.EndpointRef> get endpointRefLookup => {
'firmware': firmware,
'map': map,
};
@override
Map<String, _isc.ModuleEndpointCaller> get moduleLookup => {

View file

@ -0,0 +1,67 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A firmware update for the person's Polar Loop that the instance relays
/// from Polar's firmware service (ADR 0030, D10, D40): its version, whose file
/// the app then downloads (`firmware.download`).
abstract class LoopFirmware
implements _isc.SerializableModel, _isc.ProtocolSerialization {
LoopFirmware._({required this.version});
factory LoopFirmware({required String version}) = _LoopFirmwareImpl;
factory LoopFirmware.fromJson(Map<String, dynamic> jsonSerialization) {
return LoopFirmware(version: jsonSerialization['version'] as String);
}
/// The firmware's version, as Polar names it (`3.1.2`).
String version;
/// Returns a shallow copy of this [LoopFirmware]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
LoopFirmware copyWith({String? version});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'LoopFirmware',
'version': version,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'LoopFirmware',
'version': version,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _LoopFirmwareImpl extends LoopFirmware {
_LoopFirmwareImpl({required String version}) : super._(version: version);
/// Returns a shallow copy of this [LoopFirmware]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
LoopFirmware copyWith({String? version}) {
return LoopFirmware(version: version ?? this.version);
}
}

View file

@ -0,0 +1,71 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Why the instance relays no firmware (ADR 0030, D40): `off` where its
/// operator turned relaying off, `invalid` for a band's figures it doesn't
/// take, `unknown` for a file it wasn't told of (or no longer holds),
/// `unreachable` where Polar's service didn't answer.
abstract class LoopFirmwareRefused
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
LoopFirmwareRefused._({required this.reason});
factory LoopFirmwareRefused({required String reason}) =
_LoopFirmwareRefusedImpl;
factory LoopFirmwareRefused.fromJson(Map<String, dynamic> jsonSerialization) {
return LoopFirmwareRefused(reason: jsonSerialization['reason'] as String);
}
String reason;
/// Returns a shallow copy of this [LoopFirmwareRefused]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
LoopFirmwareRefused copyWith({String? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'LoopFirmwareRefused',
'reason': reason,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'LoopFirmwareRefused',
'reason': reason,
};
}
@override
String toString() {
return 'LoopFirmwareRefused(reason: $reason)';
}
}
class _LoopFirmwareRefusedImpl extends LoopFirmwareRefused {
_LoopFirmwareRefusedImpl({required String reason}) : super._(reason: reason);
/// Returns a shallow copy of this [LoopFirmwareRefused]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
LoopFirmwareRefused copyWith({String? reason}) {
return LoopFirmwareRefused(reason: reason ?? this.reason);
}
}

View file

@ -24,6 +24,8 @@ import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart
import 'day/day.dart' as _ikmv9f3y;
import 'day/day_series.dart' as _ir1ofny6;
import 'day/night.dart' as _i17cgxdf;
import 'firmware/loop_firmware.dart' as _i4qollgq;
import 'firmware/loop_firmware_refused.dart' as _ipcwdy0l;
import 'map/map_source.dart' as _ikw0qg8x;
import 'privacy/content_key.dart' as _imed3x5d;
import 'profile/profile.dart' as _ia5j8fch;
@ -32,6 +34,8 @@ import 'workout/workout_series.dart' as _i92mefto;
export 'day/day.dart';
export 'day/day_series.dart';
export 'day/night.dart';
export 'firmware/loop_firmware.dart';
export 'firmware/loop_firmware_refused.dart';
export 'map/map_source.dart';
export 'privacy/content_key.dart';
export 'profile/profile.dart';
@ -629,6 +633,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _i17cgxdf.Night) {
return _i17cgxdf.Night.fromJson(data) as T;
}
if (t == _i4qollgq.LoopFirmware) {
return _i4qollgq.LoopFirmware.fromJson(data) as T;
}
if (t == _ipcwdy0l.LoopFirmwareRefused) {
return _ipcwdy0l.LoopFirmwareRefused.fromJson(data) as T;
}
if (t == _ikw0qg8x.MapSource) {
return _ikw0qg8x.MapSource.fromJson(data) as T;
}
@ -653,6 +663,15 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_i17cgxdf.Night?>()) {
return (data != null ? _i17cgxdf.Night.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i4qollgq.LoopFirmware?>()) {
return (data != null ? _i4qollgq.LoopFirmware.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ipcwdy0l.LoopFirmwareRefused?>()) {
return (data != null
? _ipcwdy0l.LoopFirmwareRefused.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_ikw0qg8x.MapSource?>()) {
return (data != null ? _ikw0qg8x.MapSource.fromJson(data) : null) as T;
}
@ -692,6 +711,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
_ikmv9f3y.Day => 'Day',
_ir1ofny6.DaySeries => 'DaySeries',
_i17cgxdf.Night => 'Night',
_i4qollgq.LoopFirmware => 'LoopFirmware',
_ipcwdy0l.LoopFirmwareRefused => 'LoopFirmwareRefused',
_ikw0qg8x.MapSource => 'MapSource',
_imed3x5d.ContentKey => 'ContentKey',
_ia5j8fch.Profile => 'Profile',
@ -720,6 +741,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'DaySeries';
case _i17cgxdf.Night():
return 'Night';
case _i4qollgq.LoopFirmware():
return 'LoopFirmware';
case _ipcwdy0l.LoopFirmwareRefused():
return 'LoopFirmwareRefused';
case _ikw0qg8x.MapSource():
return 'MapSource';
case _imed3x5d.ContentKey():
@ -775,6 +800,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'Night') {
return deserialize<_i17cgxdf.Night>(data['data']);
}
if (dataClassName == 'LoopFirmware') {
return deserialize<_i4qollgq.LoopFirmware>(data['data']);
}
if (dataClassName == 'LoopFirmwareRefused') {
return deserialize<_ipcwdy0l.LoopFirmwareRefused>(data['data']);
}
if (dataClassName == 'MapSource') {
return deserialize<_ikw0qg8x.MapSource>(data['data']);
}