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
36 changed files with 2318 additions and 45 deletions
|
|
@ -3,10 +3,10 @@
|
|||
What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Train needs the same thing.
|
||||
|
||||
- **A shell on the shared packages**, Todo's copied and renamed (decided 2026-10-28, ADR 0030, D12; what Train is to do: ADR 0030): signing in, the instances, the account and the look work, and since step 5a (28 Oct 2026) a person's workouts are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0030, D7, D14, D15), and on Android a workout is recorded (5b: ADR 0030, D13, D16, D18 to D21), and since 5c home lists them and each has its page with its map (D22 to D24). Packages: `halkyon_train_server`, `halkyon_train_client` (generated, never edited), `halkyon_train_flutter`; app ID `app.halkyon.train` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
|
||||
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, and `deleteTrainData` (`lib/src/train_data.dart`) as its `deletePersonData` hook, which deletes the person's sync spaces and so, by the sync engine's cascades, every row of theirs (`test/integration/workout/person_deletion_test.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Train holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-28): an initial one with Serverpod's tables, its auth modules' and the module's as they stand (Todo's grew one by one with the module; Train starts where they ended), then `workouts`, purely additive: the sync engine's tables and Train's first three, then `profile` and `days` (Train's `day`, `day_series` and `night`), each purely additive. **Synced models** (`database: sync`, the module `offline_sync`, `experimental_features: databaseSync` in `config/generator.yaml`): `content_key` (the person's key sealed to the account), `workout` and `workout_series` (a workout's series in chunks of at most ten minutes, deleted with it), `profile` (the body's figures, one row a person), `day`, `day_series` (a day's 24/7 heart rate in chunks of three hours, deleted with it) and `night` (one row a person and date each, ADR 0030, D37), each a sealed `payload` with its `keyId` and `nonce`, nothing else readable. Tests (`test/integration/train_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
|
||||
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, and `deleteTrainData` (`lib/src/train_data.dart`) as its `deletePersonData` hook, which deletes the person's sync spaces and so, by the sync engine's cascades, every row of theirs (`test/integration/workout/person_deletion_test.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Train holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. **The Loop's firmware relay** (ADR 0030, D10, D40, `lib/src/firmware/`): the `firmware` endpoint asks Polar's firmware service in the person's place and hands on the file, kept in memory a day per hardware and version (`FirmwareRelay`); on by default, off where the operator sets `LOOP_FIRMWARE_RELAY=off`, the app then offering the direct way alone; tested on a fake Polar (`test/integration/firmware/`). Migrations (2026-10-28): an initial one with Serverpod's tables, its auth modules' and the module's as they stand (Todo's grew one by one with the module; Train starts where they ended), then `workouts`, purely additive: the sync engine's tables and Train's first three, then `profile` and `days` (Train's `day`, `day_series` and `night`), each purely additive. **Synced models** (`database: sync`, the module `offline_sync`, `experimental_features: databaseSync` in `config/generator.yaml`): `content_key` (the person's key sealed to the account), `workout` and `workout_series` (a workout's series in chunks of at most ten minutes, deleted with it), `profile` (the body's figures, one row a person), `day`, `day_series` (a day's 24/7 heart rate in chunks of three hours, deleted with it) and `night` (one row a person and date each, ADR 0030, D37), each a sealed `payload` with its `keyId` and `nonce`, nothing else readable. Tests (`test/integration/train_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
|
||||
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app whose home lists their workouts (`WorkoutsCubit`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`).
|
||||
- **Recording** (ADR 0030, D13, D16, D18 to D21, D26 to D28) is Android's alone for now (iOS waits for a Mac, D25) (`TrainPerson.recording` null elsewhere): `RecorderService` (Kotlin, `android/app/src/main/kotlin/app/halkyon/train/`), a foreground service of the types `location` and `health` started from the open app, takes fixes from LocationManager's GPS (no Play services), steps from the step detector the heart rate from the strap and a row from the rower the person chose (ADR 0030, D26 to D28: `NotifyLink.kt` on Android's own Bluetooth API, no package, `HeartRateMeasurement.kt` parsing 0x2A37 and `RowerData.kt` FTMS's Rower Data 0x2AD1; `DeviceScanner.kt` finds them for the sheets over `…/recorder/straps` and `…/recorder/rowers`; each remembered in the person's `strap.json` and `rower.json`, `DeviceStore`), decides auto-pause (`AutoPause.kt`) and appends each sample as a line to the person's `recording.txt` (format in `recording_file.dart`), flushed every 5 s; `MainActivity` asks the permissions (never background location) and bridges `app.halkyon.train/recorder` (methods) and `…/recorder/lines` (the file so far, then each line). The app (`lib/src/feature/recording/`) follows those lines with `RecordingBloc`, works out what the screen and the notification show (`liveOf`, the saved totals' functions), and saves a recording as a workout and its series (`recordedWorkout`) or throws it away; one cut off, or stopped and not saved, is found as the person's app opens. Tests: the Dart side with `test/support/fake_recorder_platform.dart`; the Kotlin auto-pause and the strap's and the rower's parsers on the JVM, `JAVA_HOME=<Android Studio's jbr> ./gradlew :app:testDebugUnitTest` in `android/` after a first `flutter build apk` (which writes `gradlew`), not part of `tool/check.dart`, as CI's runner has no Android SDK.
|
||||
- **Health Connect** (ADR 0030, D30, D31) is Android's (`TrainPerson.health` null elsewhere): `HealthConnectBridge.kt` on AndroidX's `connect-client` 1.1.0 over `app.halkyon.train/health` (its records' mapping in `HealthRecords.kt`, tested on the JVM; the rationale's links from Health Connect in the manifest, `…/health/rationale` to the app), `HealthPlatform` in Dart (`lib/src/feature/health/`), `HealthConnect` per person: with writing on, every workout written as `healthRecordsOf` makes it (client IDs from the workout's ID, a version counted up as it changes, a fingerprint deciding whether it did), written again or deleted as the workouts change and as the app opens, what was written kept in the person's `health_connect.json` (`HealthStore`, never synced); with reading on, the sessions of the apps the person turned on brought in as workouts through the changes token (`importedWorkout` in `health_import.dart`, the workout's `origin` in its payload; a double of one Train has brings at most its heart rate, `isDouble`; one deleted in Train is remembered so it stays out; a track only when asked for on its page, `bringInTrack`), as the app opens and comes to the front (`HealthConnectListener`, which opens the rationale too); the screen from the account sheet (`HealthScreen`, `HealthCubit`). Tests on `test/support/fake_health_platform.dart`. Train needs Android 8 (API 26) for it.
|
||||
- **The Polar Loop** (ADR 0030, D32 to D36) is Android's (`TrainPerson.loop` null elsewhere): `LoopBridge.kt` on Polar's BLE SDK 8.3.0 (from JitPack, a repository asked for Polar's group alone, its files pinned by SHA-256 in `android/gradle/verification-metadata.xml`; a new version needs its checksums there) over `app.halkyon.train/loop`, `…/loop/state` and `…/loop/search`, the first-time use's figures mapped and checked in `LoopSetup.kt` (tested on the JVM); `LoopPlatform` in Dart (`lib/src/feature/loop/`), `PolarLoop` per person: the band remembered in the person's `loop.json` (`LoopStore`, never synced) with what it was set up with and what it told, connected while the app is open, its clock set as each connection is ready, a changed profile offered on the band's screen and sent only when the person says (D35), *Let the band go* a factory reset after asking; its days and nights (D37, D38, `lib/src/feature/day/`) read as each connection is ready (every half hour at most, or when the person pulls *Days*) and by a WorkManager job every four hours while the app is closed (`LoopWorker.kt`, into the person's `loop_days.txt`, which the app takes in as it opens, `LoopLines`), `LoopDays.kt` turning the SDK's answers into lines of JSON (tested on the JVM), `DayRepository` sealing them as `day`, `night` and `day_series` rows (payloads in `day_payload.dart`), and what is sealed, up to yesterday, deleted on the band by the next read (`KnownLoop.keptUntil`), never today; its training sessions (D39) read with the days and kept as workouts of origin *Polar Loop* (`loop_workouts.dart`: `takeLoopSessions`, Polar's sport 1 and 2 as running and cycling, the rest as `Activity.other`, which the edit sheet changes; a double of a recorded workout gives it its heart rate), remembered in `loop.json` (`takenSessions`), written to Health Connect as Train's own; home's *Days* tab and a day's page (`DaysCubit`, `DayCubit`); `loopFiguresOf` fills Polar's defaults for the band alone (`loop_figures.dart`), the setup sheet asks for what the profile lacks with `ProfileForm(only: …)`. The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (`halkyon_train_flutter/LICENSE-polar-exception.md`), its notices are in the licence list (`registerPolarLicense`, on Android), and an F-Droid listing of Train declares `NonFreeDep`. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
|
||||
- **The Polar Loop** (ADR 0030, D32 to D36) is Android's (`TrainPerson.loop` null elsewhere): `LoopBridge.kt` on Polar's BLE SDK 8.3.0 (from JitPack, a repository asked for Polar's group alone, its files pinned by SHA-256 in `android/gradle/verification-metadata.xml`; a new version needs its checksums there) over `app.halkyon.train/loop`, `…/loop/state` and `…/loop/search`, the first-time use's figures mapped and checked in `LoopSetup.kt` (tested on the JVM); `LoopPlatform` in Dart (`lib/src/feature/loop/`), `PolarLoop` per person: the band remembered in the person's `loop.json` (`LoopStore`, never synced) with what it was set up with and what it told, connected while the app is open, its clock set as each connection is ready, a changed profile offered on the band's screen and sent only when the person says (D35), *Let the band go* a factory reset after asking; its days and nights (D37, D38, `lib/src/feature/day/`) read as each connection is ready (every half hour at most, or when the person pulls *Days*) and by a WorkManager job every four hours while the app is closed (`LoopWorker.kt`, into the person's `loop_days.txt`, which the app takes in as it opens, `LoopLines`), `LoopDays.kt` turning the SDK's answers into lines of JSON (tested on the JVM), `DayRepository` sealing them as `day`, `night` and `day_series` rows (payloads in `day_payload.dart`), and what is sealed, up to yesterday, deleted on the band by the next read (`KnownLoop.keptUntil`), never today; its training sessions (D39) read with the days and kept as workouts of origin *Polar Loop* (`loop_workouts.dart`: `takeLoopSessions`, Polar's sport 1 and 2 as running and cycling, the rest as `Activity.other`, which the edit sheet changes; a double of a recorded workout gives it its heart rate), remembered in `loop.json` (`takenSessions`), written to Health Connect as Train's own; its firmware (D10, D40) asked for on the day's first connection and on the band's screen, from Polar directly (the SDK) or through the instance as the person chose (`KnownLoop.firmwareVia`, `FirmwareRelayGateway` on the server's `firmware` endpoint), updated only after asking, the band read and kept first and set up again after (`PolarLoop.updateFirmware`, `LoopFirmware.kt`); home's *Days* tab and a day's page (`DaysCubit`, `DayCubit`); `loopFiguresOf` fills Polar's defaults for the band alone (`loop_figures.dart`), the setup sheet asks for what the profile lacks with `ProfileForm(only: …)`. The person's body's figures are the synced, sealed `profile` (D33, *Your body*, `lib/src/feature/profile/`). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (`halkyon_train_flutter/LICENSE-polar-exception.md`), its notices are in the licence list (`registerPolarLicense`, on Android), and an F-Droid listing of Train declares `NonFreeDep`. Never ask the SDK for `FEATURE_TELEMETRY`. Tests on `test/support/fake_loop_platform.dart`.
|
||||
- **The icon** is Lucide's `bike` fading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py train` from the root): `assets/icon/halkyon_train_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts Lucide's notice in the licence list.
|
||||
- **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Train's development server has ports of its own (API 8180, Insights 8181, web 8182, PostgreSQL 8190; the app's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time.
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import com.polar.androidcommunications.api.ble.model.gatt.client.PowerSourcesSta
|
|||
import com.polar.sdk.api.PolarBleApi
|
||||
import com.polar.sdk.api.PolarBleApiCallbackProvider
|
||||
import com.polar.sdk.api.PolarBleApiDefaultImpl
|
||||
import com.polar.sdk.api.model.FirmwareUpdateStatus
|
||||
import com.polar.sdk.api.model.PolarDeviceInfo
|
||||
import com.polar.sdk.api.model.PolarDeviceUuid
|
||||
import com.polar.sdk.api.model.PolarHealthThermometerData
|
||||
import com.polar.sdk.api.model.PolarHrData
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
|
|
@ -21,6 +23,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
|
@ -52,6 +55,7 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
|||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TRAINING_DATA,
|
||||
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FIRMWARE_UPDATE,
|
||||
),
|
||||
).apply {
|
||||
holding = true
|
||||
|
|
@ -135,6 +139,27 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
|
|||
keptUntil = call.argument<String>("keptUntil")?.let(LocalDate::parse),
|
||||
),
|
||||
)
|
||||
// The firmware (D10, D40): what the relay asks with, Polar
|
||||
// asked directly, and the update, its stages sent as they come.
|
||||
"firmwareInfo" ->
|
||||
result.success(
|
||||
LoopFirmware.infoOf(
|
||||
api.readFile(id, LoopFirmware.DEVICE_FILE) ?: throw IllegalStateException("no device file"),
|
||||
PolarDeviceUuid.fromDeviceId(id),
|
||||
),
|
||||
)
|
||||
"checkFirmware" -> result.success(LoopFirmware.offeredOf(api.checkFirmwareUpdate(id).first()))
|
||||
"updateFirmware" -> {
|
||||
val file = call.argument<String>("file")
|
||||
val updates = if (file == null) api.updateFirmware(id) else api.updateFirmware(id, "file://$file")
|
||||
var last = "none"
|
||||
updates.collect { status ->
|
||||
last = LoopFirmware.stageOf(status)
|
||||
send(id, "firmware", "stage" to last)
|
||||
if (status is FirmwareUpdateStatus.FwUpdateFailed) throw IllegalStateException(status.details)
|
||||
}
|
||||
result.success(last)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import com.polar.sdk.api.model.CheckFirmwareUpdateStatus
|
||||
import com.polar.sdk.api.model.FirmwareUpdateStatus
|
||||
import fi.polar.remote.representation.protobuf.Device
|
||||
|
||||
/**
|
||||
* The Loop's firmware for the app (ADR 0030, D10, D40): what Polar's
|
||||
* firmware service is asked with, as the SDK reads it from the band's
|
||||
* `/DEVICE.BPB`, and how an update goes, as plain values.
|
||||
*/
|
||||
object LoopFirmware {
|
||||
/** The band's file holding its firmware's version and hardware code. */
|
||||
const val DEVICE_FILE = "/DEVICE.BPB"
|
||||
|
||||
/** [device] (the band's `/DEVICE.BPB`) with Polar's [uuid] of the band, as the relay asks with them. */
|
||||
fun infoOf(device: ByteArray, uuid: String): Map<String, Any?> {
|
||||
val info = Device.PbDeviceInfo.parseFrom(device)
|
||||
val version = info.deviceVersion
|
||||
return mapOf(
|
||||
"uuid" to uuid,
|
||||
"version" to "${version.major}.${version.minor}.${version.patch}",
|
||||
"hardwareCode" to info.hardwareCode,
|
||||
)
|
||||
}
|
||||
|
||||
/** The version Polar offers; null where it offers none. Throws where the check failed. */
|
||||
fun offeredOf(status: CheckFirmwareUpdateStatus): String? =
|
||||
when (status) {
|
||||
is CheckFirmwareUpdateStatus.CheckFwUpdateAvailable -> status.version
|
||||
is CheckFirmwareUpdateStatus.CheckFwUpdateNotAvailable -> null
|
||||
is CheckFirmwareUpdateStatus.CheckFwUpdateFailed -> throw IllegalStateException(status.details)
|
||||
}
|
||||
|
||||
/** How far an update is, as the app shows it: `fetching`, `preparing`, `writing`, `finalizing`, `done`, `none`, or `failed`. */
|
||||
fun stageOf(status: FirmwareUpdateStatus): String =
|
||||
when (status) {
|
||||
is FirmwareUpdateStatus.FetchingFwUpdatePackage -> "fetching"
|
||||
is FirmwareUpdateStatus.PreparingDeviceForFwUpdate -> "preparing"
|
||||
is FirmwareUpdateStatus.WritingFwUpdatePackage -> "writing"
|
||||
is FirmwareUpdateStatus.FinalizingFwUpdate -> "finalizing"
|
||||
is FirmwareUpdateStatus.FwUpdateCompletedSuccessfully -> "done"
|
||||
is FirmwareUpdateStatus.FwUpdateNotAvailable -> "none"
|
||||
is FirmwareUpdateStatus.FwUpdateFailed -> "failed"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import com.polar.sdk.api.model.CheckFirmwareUpdateStatus
|
||||
import com.polar.sdk.api.model.FirmwareUpdateStatus
|
||||
import fi.polar.remote.representation.protobuf.Device
|
||||
import fi.polar.remote.representation.protobuf.Structures
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
// The Loop's firmware (ADR 0030, D10, D40): what the relay asks Polar with,
|
||||
// as the SDK reads it from the band, and an update's stages.
|
||||
class LoopFirmwareTest {
|
||||
@Test
|
||||
fun `the band's file gives its version and hardware code, with Polar's UUID of it`() {
|
||||
val device =
|
||||
Device.PbDeviceInfo.newBuilder()
|
||||
.setDeviceVersion(Structures.PbVersion.newBuilder().setMajor(3).setMinor(0).setPatch(12))
|
||||
.setHardwareCode("00123456.01")
|
||||
.setModelName("Polar Loop")
|
||||
.buildPartial()
|
||||
.toByteArray()
|
||||
|
||||
assertEquals(
|
||||
mapOf("uuid" to "uuid-of-A1", "version" to "3.0.12", "hardwareCode" to "00123456.01"),
|
||||
LoopFirmware.infoOf(device, "uuid-of-A1"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a check names the version offered, or none`() {
|
||||
assertEquals("3.1.2", LoopFirmware.offeredOf(CheckFirmwareUpdateStatus.CheckFwUpdateAvailable("3.1.2")))
|
||||
assertNull(LoopFirmware.offeredOf(CheckFirmwareUpdateStatus.CheckFwUpdateNotAvailable("")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an update's stages are named for the app`() {
|
||||
assertEquals("writing", LoopFirmware.stageOf(FirmwareUpdateStatus.WritingFwUpdatePackage("50 %")))
|
||||
assertEquals("done", LoopFirmware.stageOf(FirmwareUpdateStatus.FwUpdateCompletedSuccessfully("")))
|
||||
}
|
||||
}
|
||||
|
|
@ -208,6 +208,20 @@
|
|||
"loopBattery": "Akku {level} %",
|
||||
"loopCharging": "Akku {level} %, lädt",
|
||||
"loopFirmware": "Firmware {version}",
|
||||
"loopFirmwareCheck": "Nach Firmware-Update suchen",
|
||||
"loopFirmwareCurrent": "Die Firmware des Armbands ist aktuell.",
|
||||
"loopFirmwareOffered": "Firmware {version} verfügbar",
|
||||
"loopFirmwareViaPolar": "Direkt von Polar geholt: Polar erfährt die Adresse dieses Telefons (oder die seines VPN), dazu Firmware-Version und Hardware-Code des Armbands.",
|
||||
"loopFirmwareViaInstance": "Über deine Instanz geholt: Polar erfährt die Adresse der Instanz, nicht die dieses Telefons, dazu Firmware-Version und Hardware-Code des Armbands.",
|
||||
"loopFirmwareErases": "Das Update löscht das Armband. Train sichert zuerst, was darauf ist, und richtet es danach wieder mit deinem Profil ein.",
|
||||
"loopFirmwareUpdate": "Aktualisieren",
|
||||
"loopFirmwareUpdateTitle": "Firmware des Armbands aktualisieren?",
|
||||
"loopFirmwareUpdateText": "Train sichert zuerst, was auf dem Armband ist, da das Update es löscht. Das Armband startet neu, was einige Minuten dauert, und Train richtet es danach wieder mit deinem Profil ein. Lass es bis dahin beim Telefon.",
|
||||
"loopFirmwareStage": "{stage, select, reading{Sichere, was auf dem Armband ist …} fetching{Hole die Firmware …} preparing{Bereite das Armband vor …} writing{Schreibe die Firmware …} finalizing{Schließe das Update ab …} done{Geschrieben} restarting{Das Armband startet neu …} other{Aktualisiere …}}",
|
||||
"loopFirmwareStageHint": "Lass das Armband beim Telefon und Train geöffnet.",
|
||||
"loopFirmwareUpdated": "Firmware aktualisiert; das Armband ist wieder eingerichtet.",
|
||||
"loopFirmwareThroughInstance": "Firmware über deine Instanz",
|
||||
"loopFirmwareThroughInstanceHint": "Polar erfährt dann die Adresse der Instanz statt die dieses Telefons. Aus, fragt das Telefon Polar direkt.",
|
||||
"loopProfileChanged": "Dein Körper hat sich geändert, seit das Band eingerichtet wurde",
|
||||
"loopProfileChangedHint": "Senden richtet das Band mit deinen jetzigen Angaben neu ein.",
|
||||
"loopSend": "Ans Band senden",
|
||||
|
|
|
|||
|
|
@ -264,6 +264,22 @@
|
|||
"@loopCharging": {"placeholders": {"level": {"type": "int"}}},
|
||||
"loopFirmware": "Firmware {version}",
|
||||
"@loopFirmware": {"placeholders": {"version": {"type": "String", "example": "1.2.3"}}},
|
||||
"loopFirmwareCheck": "Check for a firmware update",
|
||||
"loopFirmwareCurrent": "The band's firmware is up to date.",
|
||||
"loopFirmwareOffered": "Firmware {version} available",
|
||||
"@loopFirmwareOffered": {"placeholders": {"version": {"type": "String", "example": "2.1.0"}}},
|
||||
"loopFirmwareViaPolar": "Fetched from Polar directly: Polar learns this phone's address (or its VPN's), with the band's firmware version and hardware code.",
|
||||
"loopFirmwareViaInstance": "Fetched through your instance: Polar learns the instance's address, not this phone's, with the band's firmware version and hardware code.",
|
||||
"loopFirmwareErases": "The update erases the band. Train first keeps what is on it, then sets it up again with your profile.",
|
||||
"loopFirmwareUpdate": "Update",
|
||||
"loopFirmwareUpdateTitle": "Update the band's firmware?",
|
||||
"loopFirmwareUpdateText": "Train first keeps what is on the band, as the update erases it. The band restarts, which takes a few minutes, and Train then sets it up again with your profile. Keep it near the phone until then.",
|
||||
"loopFirmwareStage": "{stage, select, reading{Keeping what is on the band…} fetching{Fetching the firmware…} preparing{Preparing the band…} writing{Writing the firmware…} finalizing{Finishing the update…} done{Written} restarting{The band restarts…} other{Updating…}}",
|
||||
"@loopFirmwareStage": {"placeholders": {"stage": {"type": "String"}}},
|
||||
"loopFirmwareStageHint": "Keep the band near the phone, and Train open.",
|
||||
"loopFirmwareUpdated": "Firmware updated; the band is set up again.",
|
||||
"loopFirmwareThroughInstance": "Firmware through your instance",
|
||||
"loopFirmwareThroughInstanceHint": "Polar then learns the instance's address rather than this phone's. Off, the phone asks Polar directly.",
|
||||
"loopProfileChanged": "Your body changed since the band was set up",
|
||||
"loopProfileChangedHint": "Sending sets the band up again with your figures as they are now.",
|
||||
"loopSend": "Send to the band",
|
||||
|
|
|
|||
|
|
@ -1270,6 +1270,90 @@ abstract class AppLocalizations {
|
|||
/// **'Firmware {version}'**
|
||||
String loopFirmware(String version);
|
||||
|
||||
/// No description provided for @loopFirmwareCheck.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Check for a firmware update'**
|
||||
String get loopFirmwareCheck;
|
||||
|
||||
/// No description provided for @loopFirmwareCurrent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The band\'s firmware is up to date.'**
|
||||
String get loopFirmwareCurrent;
|
||||
|
||||
/// No description provided for @loopFirmwareOffered.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Firmware {version} available'**
|
||||
String loopFirmwareOffered(String version);
|
||||
|
||||
/// No description provided for @loopFirmwareViaPolar.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetched from Polar directly: Polar learns this phone\'s address (or its VPN\'s), with the band\'s firmware version and hardware code.'**
|
||||
String get loopFirmwareViaPolar;
|
||||
|
||||
/// No description provided for @loopFirmwareViaInstance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetched through your instance: Polar learns the instance\'s address, not this phone\'s, with the band\'s firmware version and hardware code.'**
|
||||
String get loopFirmwareViaInstance;
|
||||
|
||||
/// No description provided for @loopFirmwareErases.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The update erases the band. Train first keeps what is on it, then sets it up again with your profile.'**
|
||||
String get loopFirmwareErases;
|
||||
|
||||
/// No description provided for @loopFirmwareUpdate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Update'**
|
||||
String get loopFirmwareUpdate;
|
||||
|
||||
/// No description provided for @loopFirmwareUpdateTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Update the band\'s firmware?'**
|
||||
String get loopFirmwareUpdateTitle;
|
||||
|
||||
/// No description provided for @loopFirmwareUpdateText.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Train first keeps what is on the band, as the update erases it. The band restarts, which takes a few minutes, and Train then sets it up again with your profile. Keep it near the phone until then.'**
|
||||
String get loopFirmwareUpdateText;
|
||||
|
||||
/// No description provided for @loopFirmwareStage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{stage, select, reading{Keeping what is on the band…} fetching{Fetching the firmware…} preparing{Preparing the band…} writing{Writing the firmware…} finalizing{Finishing the update…} done{Written} restarting{The band restarts…} other{Updating…}}'**
|
||||
String loopFirmwareStage(String stage);
|
||||
|
||||
/// No description provided for @loopFirmwareStageHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep the band near the phone, and Train open.'**
|
||||
String get loopFirmwareStageHint;
|
||||
|
||||
/// No description provided for @loopFirmwareUpdated.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Firmware updated; the band is set up again.'**
|
||||
String get loopFirmwareUpdated;
|
||||
|
||||
/// No description provided for @loopFirmwareThroughInstance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Firmware through your instance'**
|
||||
String get loopFirmwareThroughInstance;
|
||||
|
||||
/// No description provided for @loopFirmwareThroughInstanceHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Polar then learns the instance\'s address rather than this phone\'s. Off, the phone asks Polar directly.'**
|
||||
String get loopFirmwareThroughInstanceHint;
|
||||
|
||||
/// No description provided for @loopProfileChanged.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -702,6 +702,72 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return 'Firmware $version';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareCheck => 'Nach Firmware-Update suchen';
|
||||
|
||||
@override
|
||||
String get loopFirmwareCurrent => 'Die Firmware des Armbands ist aktuell.';
|
||||
|
||||
@override
|
||||
String loopFirmwareOffered(String version) {
|
||||
return 'Firmware $version verfügbar';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareViaPolar =>
|
||||
'Direkt von Polar geholt: Polar erfährt die Adresse dieses Telefons (oder die seines VPN), dazu Firmware-Version und Hardware-Code des Armbands.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareViaInstance =>
|
||||
'Über deine Instanz geholt: Polar erfährt die Adresse der Instanz, nicht die dieses Telefons, dazu Firmware-Version und Hardware-Code des Armbands.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareErases =>
|
||||
'Das Update löscht das Armband. Train sichert zuerst, was darauf ist, und richtet es danach wieder mit deinem Profil ein.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdate => 'Aktualisieren';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdateTitle => 'Firmware des Armbands aktualisieren?';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdateText =>
|
||||
'Train sichert zuerst, was auf dem Armband ist, da das Update es löscht. Das Armband startet neu, was einige Minuten dauert, und Train richtet es danach wieder mit deinem Profil ein. Lass es bis dahin beim Telefon.';
|
||||
|
||||
@override
|
||||
String loopFirmwareStage(String stage) {
|
||||
String _temp0 = intl.Intl.selectLogic(
|
||||
stage,
|
||||
{
|
||||
'reading': 'Sichere, was auf dem Armband ist …',
|
||||
'fetching': 'Hole die Firmware …',
|
||||
'preparing': 'Bereite das Armband vor …',
|
||||
'writing': 'Schreibe die Firmware …',
|
||||
'finalizing': 'Schließe das Update ab …',
|
||||
'done': 'Geschrieben',
|
||||
'restarting': 'Das Armband startet neu …',
|
||||
'other': 'Aktualisiere …',
|
||||
},
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareStageHint =>
|
||||
'Lass das Armband beim Telefon und Train geöffnet.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdated =>
|
||||
'Firmware aktualisiert; das Armband ist wieder eingerichtet.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareThroughInstance => 'Firmware über deine Instanz';
|
||||
|
||||
@override
|
||||
String get loopFirmwareThroughInstanceHint =>
|
||||
'Polar erfährt dann die Adresse der Instanz statt die dieses Telefons. Aus, fragt das Telefon Polar direkt.';
|
||||
|
||||
@override
|
||||
String get loopProfileChanged =>
|
||||
'Dein Körper hat sich geändert, seit das Band eingerichtet wurde';
|
||||
|
|
|
|||
|
|
@ -698,6 +698,72 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return 'Firmware $version';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareCheck => 'Check for a firmware update';
|
||||
|
||||
@override
|
||||
String get loopFirmwareCurrent => 'The band\'s firmware is up to date.';
|
||||
|
||||
@override
|
||||
String loopFirmwareOffered(String version) {
|
||||
return 'Firmware $version available';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareViaPolar =>
|
||||
'Fetched from Polar directly: Polar learns this phone\'s address (or its VPN\'s), with the band\'s firmware version and hardware code.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareViaInstance =>
|
||||
'Fetched through your instance: Polar learns the instance\'s address, not this phone\'s, with the band\'s firmware version and hardware code.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareErases =>
|
||||
'The update erases the band. Train first keeps what is on it, then sets it up again with your profile.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdate => 'Update';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdateTitle => 'Update the band\'s firmware?';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdateText =>
|
||||
'Train first keeps what is on the band, as the update erases it. The band restarts, which takes a few minutes, and Train then sets it up again with your profile. Keep it near the phone until then.';
|
||||
|
||||
@override
|
||||
String loopFirmwareStage(String stage) {
|
||||
String _temp0 = intl.Intl.selectLogic(
|
||||
stage,
|
||||
{
|
||||
'reading': 'Keeping what is on the band…',
|
||||
'fetching': 'Fetching the firmware…',
|
||||
'preparing': 'Preparing the band…',
|
||||
'writing': 'Writing the firmware…',
|
||||
'finalizing': 'Finishing the update…',
|
||||
'done': 'Written',
|
||||
'restarting': 'The band restarts…',
|
||||
'other': 'Updating…',
|
||||
},
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get loopFirmwareStageHint =>
|
||||
'Keep the band near the phone, and Train open.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareUpdated =>
|
||||
'Firmware updated; the band is set up again.';
|
||||
|
||||
@override
|
||||
String get loopFirmwareThroughInstance => 'Firmware through your instance';
|
||||
|
||||
@override
|
||||
String get loopFirmwareThroughInstanceHint =>
|
||||
'Polar then learns the instance\'s address rather than this phone\'s. Off, the phone asks Polar directly.';
|
||||
|
||||
@override
|
||||
String get loopProfileChanged =>
|
||||
'Your body changed since the band was set up';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import 'package:halkyon_train_flutter/src/feature/health/data/android_health_pla
|
|||
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/android_loop_platform.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/map/data/map_source_repository.dart';
|
||||
|
|
@ -348,6 +349,7 @@ Future<TrainPerson> _openPersonData(
|
|||
days: days,
|
||||
workouts: workouts,
|
||||
backgroundLines: File(p.join(folder.path, 'loop_days.txt')),
|
||||
relay: ServerpodFirmwareRelayGateway(client),
|
||||
)..start());
|
||||
return TrainPerson(
|
||||
person,
|
||||
|
|
|
|||
|
|
@ -3,17 +3,24 @@ import 'dart:async';
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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';
|
||||
|
||||
/// What the band's screen asked last, and how it went.
|
||||
enum LoopOutcome() {
|
||||
sent,
|
||||
letGo,
|
||||
|
||||
/// Asked for its firmware (D40): none newer, or one found.
|
||||
firmwareCurrent,
|
||||
firmwareFound,
|
||||
updated,
|
||||
failed,
|
||||
}
|
||||
|
||||
/// The band's screen (ADR 0030, D34, D35): where the band stands, sending
|
||||
/// a changed profile when the person says, and letting it go.
|
||||
/// The band's screen (ADR 0030, D34, D35, D40): where the band stands,
|
||||
/// sending a changed profile when the person says, its firmware asked for
|
||||
/// and updated, and letting it go.
|
||||
class LoopCubit(final PolarLoop _loop) extends Cubit<LoopState> {
|
||||
this : super(LoopState(status: _loop.status));
|
||||
|
||||
|
|
@ -31,12 +38,28 @@ class LoopCubit(final PolarLoop _loop) extends Cubit<LoopState> {
|
|||
|
||||
Future<void> letGo() => _do(_loop.letGo, LoopOutcome.letGo);
|
||||
|
||||
Future<void> _do(Future<void> Function() action, LoopOutcome done) async {
|
||||
Future<void> checkFirmware() => _ask(
|
||||
() async => await _loop.checkFirmware() == null
|
||||
? LoopOutcome.firmwareCurrent
|
||||
: LoopOutcome.firmwareFound,
|
||||
);
|
||||
|
||||
Future<void> updateFirmware() =>
|
||||
_do(_loop.updateFirmware, LoopOutcome.updated);
|
||||
|
||||
void setFirmwareVia(FirmwareVia via) => _loop.setFirmwareVia(via);
|
||||
|
||||
Future<void> _do(Future<void> Function() action, LoopOutcome done) =>
|
||||
_ask(() async {
|
||||
await action();
|
||||
return done;
|
||||
});
|
||||
|
||||
Future<void> _ask(Future<LoopOutcome> Function() action) async {
|
||||
emit(LoopState(status: state.status, busy: true));
|
||||
LoopOutcome outcome;
|
||||
try {
|
||||
await action();
|
||||
outcome = done;
|
||||
outcome = await action();
|
||||
} on LoopFailed {
|
||||
outcome = LoopOutcome.failed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,38 @@ class AndroidLoopPlatform(final RecorderPlatform _recorder)
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LoopFirmwareInfo> firmwareInfo(String id) async {
|
||||
try {
|
||||
return switch (await _channel.invokeMethod<Object?>('firmwareInfo', {
|
||||
'id': id,
|
||||
})) {
|
||||
{
|
||||
'uuid': final String uuid,
|
||||
'version': final String version,
|
||||
'hardwareCode': final String hardwareCode,
|
||||
} =>
|
||||
(uuid: uuid, version: version, hardwareCode: hardwareCode),
|
||||
_ => throw const LoopFailed('firmware unread'),
|
||||
};
|
||||
} on PlatformException catch (error) {
|
||||
throw LoopFailed(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> checkFirmware(String id) async {
|
||||
try {
|
||||
return await _channel.invokeMethod<String>('checkFirmware', {'id': id});
|
||||
} on PlatformException catch (error) {
|
||||
throw LoopFailed(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFirmware(String id, {String? file}) =>
|
||||
_call('updateFirmware', {'id': id, 'file': ?file});
|
||||
|
||||
@override
|
||||
Future<void> background(LoopBackground? config) =>
|
||||
_call('background', {'config': config?.toMap()});
|
||||
|
|
@ -110,5 +142,7 @@ LoopEvent? loopEventOf(Object? event) => switch (event) {
|
|||
'version': final String version,
|
||||
} =>
|
||||
LoopFirmware(id, version),
|
||||
{'id': final String id, 'event': 'firmware', 'stage': final String stage} =>
|
||||
LoopFirmwareStage(id, stage),
|
||||
_ => null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' show Client;
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
|
||||
|
||||
/// The Loop's firmware through the instance (ADR 0030, D10, D40): Train's
|
||||
/// server asks Polar in the person's place and hands the file on.
|
||||
abstract interface class FirmwareRelayGateway() {
|
||||
/// Whether the instance relays firmware (`LOOP_FIRMWARE_RELAY`).
|
||||
Future<bool> offered();
|
||||
|
||||
/// The version Polar offers for the band [info] names; null where none.
|
||||
Future<String?> check(LoopFirmwareInfo info);
|
||||
|
||||
/// The file of [version] for bands of [hardwareCode].
|
||||
Future<Uint8List> download({
|
||||
required String hardwareCode,
|
||||
required String version,
|
||||
});
|
||||
}
|
||||
|
||||
/// [FirmwareRelayGateway] on Train's server (`firmware`).
|
||||
class ServerpodFirmwareRelayGateway(final Client _client)
|
||||
implements FirmwareRelayGateway {
|
||||
@override
|
||||
Future<bool> offered() => _client.firmware.offered();
|
||||
|
||||
@override
|
||||
Future<String?> check(LoopFirmwareInfo info) async =>
|
||||
(await _client.firmware.check(
|
||||
info.uuid,
|
||||
info.version,
|
||||
info.hardwareCode,
|
||||
))?.version;
|
||||
|
||||
@override
|
||||
Future<Uint8List> download({
|
||||
required String hardwareCode,
|
||||
required String version,
|
||||
}) async {
|
||||
final file = await _client.firmware.download(hardwareCode, version);
|
||||
return file.buffer.asUint8List(file.offsetInBytes, file.lengthInBytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,14 @@ final class LoopCharging(super.id, {required final bool charging})
|
|||
/// Its firmware's version.
|
||||
final class LoopFirmware(super.id, final String version) extends LoopEvent;
|
||||
|
||||
/// How far a firmware update is (D40): `fetching`, `preparing`, `writing`,
|
||||
/// `finalizing`, `done`.
|
||||
final class LoopFirmwareStage(super.id, final String stage) extends LoopEvent;
|
||||
|
||||
/// What Polar's firmware service is asked with (D10): Polar's UUID of the
|
||||
/// band, its firmware's version and its hardware code.
|
||||
typedef LoopFirmwareInfo = ({String uuid, String version, String hardwareCode});
|
||||
|
||||
/// The Loop through Polar's SDK on this phone (D34); each call throws a
|
||||
/// [LoopFailed] where the band refused or wasn't there.
|
||||
abstract interface class LoopPlatform() {
|
||||
|
|
@ -66,6 +74,18 @@ abstract interface class LoopPlatform() {
|
|||
DateTime? keptUntil,
|
||||
});
|
||||
|
||||
/// What the band's firmware is, for the instance to ask Polar with (D40).
|
||||
Future<LoopFirmwareInfo> firmwareInfo(String id);
|
||||
|
||||
/// Asks Polar directly for an update of the band's firmware; its version,
|
||||
/// null where there is none.
|
||||
Future<String?> checkFirmware(String id);
|
||||
|
||||
/// Updates the band's firmware, which erases it: fetched from Polar, or
|
||||
/// from [file] (a package the instance relayed); its stages as
|
||||
/// [LoopFirmwareStage]s. Throws [LoopFailed] where it failed.
|
||||
Future<void> updateFirmware(String id, {String? file});
|
||||
|
||||
/// Has the band read every few hours while the app is closed, into
|
||||
/// `loop_days.txt` in the person's folder, with what the app kept; or no longer,
|
||||
/// where [config] is null.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ import 'package:equatable/equatable.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
||||
|
||||
/// Where the band's firmware is asked for and fetched (ADR 0030, D10):
|
||||
/// from Polar directly, or through the instance.
|
||||
enum FirmwareVia() {
|
||||
polar,
|
||||
instance,
|
||||
}
|
||||
|
||||
/// The person's Loop on this phone (ADR 0030, D34): which band, what it was
|
||||
/// set up with, and what it told last. Never synced: one phone holds the
|
||||
/// band (D3).
|
||||
|
|
@ -33,6 +40,12 @@ final class const KnownLoop({
|
|||
/// The band's training sessions taken in (D39), by ID, each with its
|
||||
/// date, so that one isn't brought again, deleted in Train or not.
|
||||
final Map<String, DateTime> takenSessions = const {},
|
||||
|
||||
/// Where its firmware comes from (D10), and the band's date it was last
|
||||
/// asked for, once a day (D40); the update offered then, if any.
|
||||
final FirmwareVia firmwareVia = FirmwareVia.polar,
|
||||
final DateTime? firmwareChecked,
|
||||
final String? firmwareOffered,
|
||||
}) with Equatable {
|
||||
KnownLoop copyWith({
|
||||
String? name,
|
||||
|
|
@ -44,6 +57,9 @@ final class const KnownLoop({
|
|||
DateTime? keptUntil,
|
||||
DateTime? lastRead,
|
||||
Map<String, DateTime>? takenSessions,
|
||||
FirmwareVia? firmwareVia,
|
||||
DateTime? firmwareChecked,
|
||||
String? Function()? firmwareOffered,
|
||||
}) => KnownLoop(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
|
|
@ -55,6 +71,11 @@ final class const KnownLoop({
|
|||
keptUntil: keptUntil ?? this.keptUntil,
|
||||
lastRead: lastRead ?? this.lastRead,
|
||||
takenSessions: takenSessions ?? this.takenSessions,
|
||||
firmwareVia: firmwareVia ?? this.firmwareVia,
|
||||
firmwareChecked: firmwareChecked ?? this.firmwareChecked,
|
||||
firmwareOffered: firmwareOffered == null
|
||||
? this.firmwareOffered
|
||||
: firmwareOffered(),
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -69,6 +90,9 @@ final class const KnownLoop({
|
|||
keptUntil,
|
||||
lastRead,
|
||||
takenSessions,
|
||||
firmwareVia,
|
||||
firmwareChecked,
|
||||
firmwareOffered,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +129,11 @@ class LoopStore(final File _file) {
|
|||
for (final MapEntry(:key, :value) in taken.entries)
|
||||
key: ?dateOfKey(value),
|
||||
},
|
||||
firmwareVia: json['firmwareVia'] == FirmwareVia.instance.name
|
||||
? FirmwareVia.instance
|
||||
: FirmwareVia.polar,
|
||||
firmwareChecked: dateOfKey(json['firmwareChecked']),
|
||||
firmwareOffered: _as<String>(json['firmwareOffered']),
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
|
@ -133,6 +162,13 @@ class LoopStore(final File _file) {
|
|||
for (final MapEntry(:key, :value) in loop.takenSessions.entries)
|
||||
key: dayKey(value),
|
||||
},
|
||||
if (loop.firmwareVia != FirmwareVia.polar)
|
||||
'firmwareVia': loop.firmwareVia.name,
|
||||
'firmwareChecked': ?switch (loop.firmwareChecked) {
|
||||
final date? => dayKey(date),
|
||||
null => null,
|
||||
},
|
||||
'firmwareOffered': ?loop.firmwareOffered,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import 'package:halkyon_core/halkyon_core.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/data/loop_workouts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/day/model/day.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/model/loop_figures.dart';
|
||||
|
|
@ -29,6 +31,10 @@ const _firstReadDays = 28;
|
|||
/// the band has deleted it long since (D39).
|
||||
const _takenKept = Duration(days: 7);
|
||||
|
||||
/// How long the band has to come back after its firmware was written: it
|
||||
/// restarts, and may take a few minutes (D40).
|
||||
const _backWithin = Duration(minutes: 5);
|
||||
|
||||
/// Where the person's Loop stands.
|
||||
final class const LoopStatus({
|
||||
/// The band remembered; null without one.
|
||||
|
|
@ -38,9 +44,36 @@ final class const LoopStatus({
|
|||
/// Whether the profile changed since the band was set up with it, and can
|
||||
/// be sent (D35).
|
||||
final bool profileChanged = false,
|
||||
|
||||
/// Whether the instance relays firmware (D40).
|
||||
final bool relayOffered = false,
|
||||
|
||||
/// How far an update of its firmware is (a [LoopFirmwareStage]'s), or
|
||||
/// `reading` before it; null while none runs.
|
||||
final String? firmwareStage,
|
||||
}) with Equatable {
|
||||
LoopStatus copyWith({
|
||||
KnownLoop? loop,
|
||||
bool? connected,
|
||||
bool? profileChanged,
|
||||
bool? relayOffered,
|
||||
String? Function()? firmwareStage,
|
||||
}) => LoopStatus(
|
||||
loop: loop ?? this.loop,
|
||||
connected: connected ?? this.connected,
|
||||
profileChanged: profileChanged ?? this.profileChanged,
|
||||
relayOffered: relayOffered ?? this.relayOffered,
|
||||
firmwareStage: firmwareStage == null ? this.firmwareStage : firmwareStage(),
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [loop, connected, profileChanged];
|
||||
List<Object?> get props => [
|
||||
loop,
|
||||
connected,
|
||||
profileChanged,
|
||||
relayOffered,
|
||||
firmwareStage,
|
||||
];
|
||||
}
|
||||
|
||||
/// The person's Polar Loop on this phone (ADR 0030, D34, D35): set up with
|
||||
|
|
@ -52,6 +85,9 @@ final class const LoopStatus({
|
|||
/// most, and kept sealed in the person's database; what the background job
|
||||
/// read meanwhile into [backgroundLines] is kept as the app opens. Once
|
||||
/// kept, a day before today is deleted on the band by the next read (D38).
|
||||
/// Its firmware (D10, D40) is asked for on the day's first connection and
|
||||
/// when the person asks, from Polar or through the instance as they chose,
|
||||
/// and updated only when they say.
|
||||
class PolarLoop(
|
||||
/// Polar's SDK.
|
||||
final LoopPlatform platform,
|
||||
|
|
@ -66,6 +102,9 @@ class PolarLoop(
|
|||
/// writes what it reads.
|
||||
required final File backgroundLines,
|
||||
|
||||
/// The instance's firmware relay; null where there is none to ask.
|
||||
final FirmwareRelayGateway? relay,
|
||||
|
||||
/// Today, for the age the defaults take and the dates read.
|
||||
final DateTime Function() today = DateTime.now,
|
||||
final DateTime Function() now = DateTime.now,
|
||||
|
|
@ -88,6 +127,17 @@ class PolarLoop(
|
|||
|
||||
/// Whether the next read goes to the band however recent the last.
|
||||
var _readAsked = false;
|
||||
late final _checking = CoalescingRunner(
|
||||
_checkDaily,
|
||||
onError: (error, stackTrace) =>
|
||||
_failed('checked for firmware', error, stackTrace),
|
||||
);
|
||||
|
||||
/// Whether its firmware is being updated, during which nothing else asks
|
||||
/// the band; and whether it is being written, the stages it tells
|
||||
/// shown only then, as one coming late would hide the restart's.
|
||||
var _updating = false;
|
||||
var _writing = false;
|
||||
late final _clocking = CoalescingRunner(
|
||||
_setClock,
|
||||
onError: (error, stackTrace) =>
|
||||
|
|
@ -109,6 +159,7 @@ class PolarLoop(
|
|||
unawaited(_comparing.run());
|
||||
// What the background job read while the app was closed.
|
||||
unawaited(_reading.run());
|
||||
unawaited(_askRelay());
|
||||
final known = _status.loop;
|
||||
if (known != null) {
|
||||
unawaited(_schedule());
|
||||
|
|
@ -121,6 +172,7 @@ class PolarLoop(
|
|||
await _comparing.idle;
|
||||
await _clocking.idle;
|
||||
await _reading.idle;
|
||||
await _checking.idle;
|
||||
}
|
||||
|
||||
/// Reads the band's days now, where it is ready, however recent the last
|
||||
|
|
@ -162,12 +214,19 @@ class PolarLoop(
|
|||
keptUntil: same ? before?.keptUntil : null,
|
||||
lastRead: same ? before?.lastRead : null,
|
||||
takenSessions: same ? before!.takenSessions : const {},
|
||||
firmwareChecked: same ? before?.firmwareChecked : null,
|
||||
firmwareOffered: same ? before?.firmwareOffered : null,
|
||||
// The person's choice, whichever band.
|
||||
firmwareVia: before?.firmwareVia ?? FirmwareVia.polar,
|
||||
),
|
||||
connected: true,
|
||||
relayOffered: _status.relayOffered,
|
||||
),
|
||||
);
|
||||
await _comparing.run();
|
||||
await _schedule();
|
||||
// A band just set up is asked for its firmware as a connection is.
|
||||
unawaited(_checking.run());
|
||||
}
|
||||
|
||||
/// Sends the profile as it is now to the band (D35); throws [LoopFailed]
|
||||
|
|
@ -183,15 +242,103 @@ class PolarLoop(
|
|||
throw const LoopFailed('not connected');
|
||||
}
|
||||
await platform.setUp(known.id, figures);
|
||||
_update(
|
||||
LoopStatus(
|
||||
loop: known.copyWith(figures: figures),
|
||||
connected: _status.connected,
|
||||
),
|
||||
);
|
||||
_update(_status.copyWith(loop: known.copyWith(figures: figures)));
|
||||
await _comparing.run();
|
||||
}
|
||||
|
||||
/// Where the band's firmware comes from from now on (D10).
|
||||
void setFirmwareVia(FirmwareVia via) {
|
||||
if (_status.loop case final known?) {
|
||||
_update(_with(known.copyWith(firmwareVia: via)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for an update of the band's firmware now, the way the person
|
||||
/// chose (D40); the version offered, null where there is none. Throws
|
||||
/// [LoopFailed] where the band isn't ready or the asking failed.
|
||||
Future<String?> checkFirmware() async {
|
||||
final known = _status.loop;
|
||||
if (known == null) {
|
||||
return null;
|
||||
}
|
||||
if (!_ready.contains(known.id) || _updating) {
|
||||
throw const LoopFailed('not connected');
|
||||
}
|
||||
return await _check(known);
|
||||
}
|
||||
|
||||
/// Updates the band's firmware to the version offered (D40): reads and
|
||||
/// keeps what is on it first, as the update erases it, then writes the
|
||||
/// firmware, fetched from Polar or relayed by the instance, and sets the
|
||||
/// band up again with the figures it had and its clock. Throws
|
||||
/// [LoopFailed] where it isn't ready, the read or the update failed, or
|
||||
/// it didn't come back; a failed read leaves the band untouched.
|
||||
Future<void> updateFirmware() async {
|
||||
final known = _status.loop;
|
||||
final version = known?.firmwareOffered;
|
||||
if (known == null || version == null) {
|
||||
return;
|
||||
}
|
||||
if (!_ready.contains(known.id) || _updating) {
|
||||
throw const LoopFailed('not connected');
|
||||
}
|
||||
final id = known.id;
|
||||
_updating = true;
|
||||
_update(_status.copyWith(firmwareStage: () => 'reading'));
|
||||
final file = File(p.join(backgroundLines.parent.path, 'loop_firmware.zip'));
|
||||
try {
|
||||
await _reading.idle;
|
||||
await _readBand(known);
|
||||
if (known.firmwareVia == FirmwareVia.instance) {
|
||||
final relay = _relay();
|
||||
final info = await platform.firmwareInfo(id);
|
||||
final offered = await _relayed(() => relay.check(info));
|
||||
if (offered == null) {
|
||||
_update(_with(_status.loop!.copyWith(firmwareOffered: () => null)));
|
||||
throw const LoopFailed('no update');
|
||||
}
|
||||
final bytes = await _relayed(
|
||||
() =>
|
||||
relay.download(hardwareCode: info.hardwareCode, version: offered),
|
||||
);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
_writing = true;
|
||||
await platform.updateFirmware(id, file: file.path);
|
||||
} else {
|
||||
_writing = true;
|
||||
await platform.updateFirmware(id);
|
||||
}
|
||||
_writing = false;
|
||||
// It restarts: what it is set up with comes once it is back.
|
||||
_ready.remove(id);
|
||||
_update(_status.copyWith(firmwareStage: () => 'restarting'));
|
||||
await platform.connect(id);
|
||||
await _readyOf(id, within: _backWithin);
|
||||
final figures = known.figures ?? _current;
|
||||
if (figures != null) {
|
||||
await platform.setUp(id, figures);
|
||||
}
|
||||
await platform.setClock(id);
|
||||
_update(
|
||||
_with(
|
||||
_status.loop!.copyWith(
|
||||
figures: figures,
|
||||
firmware: version,
|
||||
firmwareOffered: () => null,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _comparing.run();
|
||||
} finally {
|
||||
_updating = false;
|
||||
_writing = false;
|
||||
if (file.existsSync()) {
|
||||
file.deleteSync();
|
||||
}
|
||||
_update(_status.copyWith(firmwareStage: () => null));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the band and forgets it, the only way it pairs elsewhere (D34);
|
||||
/// throws [LoopFailed] where it isn't in reach, and keeps it then.
|
||||
Future<void> letGo() async {
|
||||
|
|
@ -208,7 +355,7 @@ class PolarLoop(
|
|||
}
|
||||
_ready.remove(known.id);
|
||||
_store.forget();
|
||||
_status = const LoopStatus();
|
||||
_status = LoopStatus(relayOffered: _status.relayOffered);
|
||||
_changes.add(_status);
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +382,7 @@ class PolarLoop(
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _readyOf(String id) {
|
||||
Future<void> _readyOf(String id, {Duration within = _readyWithin}) {
|
||||
if (_ready.contains(id)) {
|
||||
return Future.value();
|
||||
}
|
||||
|
|
@ -243,7 +390,7 @@ class PolarLoop(
|
|||
.putIfAbsent(id, Completer.new)
|
||||
.future
|
||||
.timeout(
|
||||
_readyWithin,
|
||||
within,
|
||||
onTimeout: () => throw const LoopFailed('not ready'),
|
||||
)
|
||||
.whenComplete(() => _waiting.remove(id));
|
||||
|
|
@ -266,22 +413,25 @@ class PolarLoop(
|
|||
break;
|
||||
case LoopConnected(:final name):
|
||||
_update(
|
||||
LoopStatus(
|
||||
_status.copyWith(
|
||||
loop: known.copyWith(name: name, lastSeen: () => null),
|
||||
connected: true,
|
||||
profileChanged: _status.profileChanged,
|
||||
),
|
||||
);
|
||||
case LoopReady():
|
||||
// Each connection sets the band's clock (D34), and reads its days
|
||||
// where the last read is long enough ago (D37).
|
||||
unawaited(_clocking.run());
|
||||
unawaited(_reading.run());
|
||||
// Each connection sets the band's clock (D34), reads its days where
|
||||
// the last read is long enough ago (D37), and asks for its firmware
|
||||
// on the day's first (D40); an update does all this itself.
|
||||
if (!_updating) {
|
||||
unawaited(_clocking.run());
|
||||
unawaited(_reading.run());
|
||||
unawaited(_checking.run());
|
||||
}
|
||||
case LoopDisconnected():
|
||||
_update(
|
||||
LoopStatus(
|
||||
_status.copyWith(
|
||||
loop: known.copyWith(lastSeen: () => DateTime.now().toUtc()),
|
||||
profileChanged: _status.profileChanged,
|
||||
connected: false,
|
||||
),
|
||||
);
|
||||
case LoopBattery(:final level):
|
||||
|
|
@ -290,6 +440,79 @@ class PolarLoop(
|
|||
_update(_with(known.copyWith(charging: charging)));
|
||||
case LoopFirmware(:final version):
|
||||
_update(_with(known.copyWith(firmware: version)));
|
||||
case LoopFirmwareStage(:final stage):
|
||||
if (_writing) {
|
||||
_update(_status.copyWith(firmwareStage: () => stage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the instance whether it relays firmware.
|
||||
Future<void> _askRelay() async {
|
||||
final relay = this.relay;
|
||||
if (relay == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final offered = await relay.offered();
|
||||
_update(_status.copyWith(relayOffered: offered));
|
||||
} on Object catch (error, st) {
|
||||
_failed('asked about the relay', error, st);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for the band's firmware where it wasn't asked for on the band's
|
||||
/// date yet.
|
||||
Future<void> _checkDaily() async {
|
||||
final known = _status.loop;
|
||||
if (known == null || !_ready.contains(known.id) || _updating) {
|
||||
return;
|
||||
}
|
||||
if (known.firmwareChecked case final checked?
|
||||
when dayKey(checked) == dayKey(today())) {
|
||||
return;
|
||||
}
|
||||
await _check(known);
|
||||
}
|
||||
|
||||
/// Asks for an update for [known] the way the person chose, and
|
||||
/// remembers the answer; never Polar directly where the instance was
|
||||
/// chosen.
|
||||
Future<String?> _check(KnownLoop known) async {
|
||||
final String? offered;
|
||||
if (known.firmwareVia == FirmwareVia.instance) {
|
||||
final relay = _relay();
|
||||
final info = await platform.firmwareInfo(known.id);
|
||||
offered = await _relayed(() => relay.check(info));
|
||||
} else {
|
||||
offered = await platform.checkFirmware(known.id);
|
||||
}
|
||||
final date = today();
|
||||
if (_status.loop case final now? when now.id == known.id) {
|
||||
_update(
|
||||
_with(
|
||||
now.copyWith(
|
||||
firmwareChecked: DateTime.utc(date.year, date.month, date.day),
|
||||
firmwareOffered: () => offered,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return offered;
|
||||
}
|
||||
|
||||
FirmwareRelayGateway _relay() =>
|
||||
relay ?? (throw const LoopFailed('no relay'));
|
||||
|
||||
/// [ask]'s answer from the instance; its failure (refused, offline) as a
|
||||
/// [LoopFailed].
|
||||
Future<T> _relayed<T>(Future<T> Function() ask) async {
|
||||
try {
|
||||
return await ask();
|
||||
} on LoopFailed {
|
||||
rethrow;
|
||||
} on Object catch (error) {
|
||||
throw LoopFailed('relay: ${error.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,9 +535,15 @@ class PolarLoop(
|
|||
return;
|
||||
}
|
||||
final last = known.lastRead;
|
||||
if (!asked && last != null && now().difference(last) < _readEvery) {
|
||||
if (_updating ||
|
||||
!asked && last != null && now().difference(last) < _readEvery) {
|
||||
return;
|
||||
}
|
||||
await _readBand(known);
|
||||
}
|
||||
|
||||
/// Reads [known]'s days and keeps them; throws where the read failed.
|
||||
Future<void> _readBand(KnownLoop known) async {
|
||||
final lines = LoopLines();
|
||||
(await platform.readDays(
|
||||
known.id,
|
||||
|
|
@ -438,11 +667,7 @@ class PolarLoop(
|
|||
}
|
||||
}
|
||||
|
||||
LoopStatus _with(KnownLoop loop) => LoopStatus(
|
||||
loop: loop,
|
||||
connected: _status.connected,
|
||||
profileChanged: _status.profileChanged,
|
||||
);
|
||||
LoopStatus _with(KnownLoop loop) => _status.copyWith(loop: loop);
|
||||
|
||||
/// Works out the figures the profile gives now, and whether they differ
|
||||
/// from the band's.
|
||||
|
|
@ -452,9 +677,7 @@ class PolarLoop(
|
|||
final sent = _status.loop?.figures;
|
||||
final current = _current;
|
||||
_update(
|
||||
LoopStatus(
|
||||
loop: _status.loop,
|
||||
connected: _status.connected,
|
||||
_status.copyWith(
|
||||
profileChanged: sent != null && current != null && current != sent,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue