feat(train): setting up and keeping the Polar Loop (ADR 0030, D34–D36)

On Android, the Loop through Polar's BLE SDK 8.3.0 (from JitPack, asked
for Polar's group alone, its files pinned by SHA-256 in Gradle's
verification metadata): LoopBridge.kt over app.halkyon.train/loop, the
first-time use's figures mapped and checked in LoopSetup.kt, tested on
the JVM. PolarLoop per person remembers the band in loop.json, connects
while the app is open, sets its clock at each connection, keeps what it
tells (battery, charging, firmware), offers a changed profile and sends
it only when asked (D35: first-time use writes a new user identifier
each time), and lets the band go by a factory reset after asking. The
setup sheet finds Loops nearest first and asks only for what the
profile lacks; Polar's defaults (60, 220 less the age, a VO2max from
Polar's fitness tables) go to the band alone.

Polar's SDK is not free software (D36, correcting D32): the app's AGPL
gains a section 7 permission for linking it, LICENSE.md names it, and
its licence and bundled notices join the licence list on Android.

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 18:58:02 +01:00
commit cd72cf6ccc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 3485 additions and 99 deletions

View file

@ -188,5 +188,37 @@
"profileSleepGoal": "Schlafziel",
"profileSave": "Speichern",
"profileSaved": "Gespeichert",
"profileInvalid": "Prüfe die markierten Angaben."
"profileInvalid": "Prüfe die markierten Angaben.",
"profileNeeded": "Braucht dein Band",
"loopTitle": "Polar Loop",
"loopHint": "Dein Band: Einrichtung, Akku und Uhrzeit",
"loopNone": "Train richtet einen Polar Loop ein und betreut ihn selbst, ohne Polar Flow und ohne Polar-Konto. Ein Band, das mit Polar Flow verbunden war, braucht zuerst einen Werksreset.",
"loopSetUp": "Loop einrichten",
"loopSetUpAgain": "Neu einrichten",
"loopLetGo": "Band freigeben",
"loopLetGoTitle": "Band freigeben?",
"loopLetGoText": "Das Band wird auf Werkseinstellungen zurückgesetzt, und was es hält und noch nicht bei Train angekommen ist, geht verloren. Danach kannst du es mit einem anderen Handy oder mit Polar Flow koppeln.",
"loopLetGoConfirm": "Band zurücksetzen",
"loopConnected": "Verbunden",
"loopLastSeen": "Zuletzt gesehen {when}",
"loopNotInReach": "Nicht in Reichweite",
"loopBattery": "Akku {level} %",
"loopCharging": "Akku {level} %, lädt",
"loopFirmware": "Firmware {version}",
"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",
"loopSent": "Ans Band gesendet.",
"loopFailed": "Das Band hat nicht geantwortet. Halte es nah und versuch es noch mal.",
"loopSearching": "Suche Loops in der Nähe",
"loopSearchingHint": "Halte das Band nah; tipp darauf, um es zu wecken.",
"loopRefused": "Ohne Geräte in der Nähe kann Train dein Band nicht suchen. Erlaube sie in den Android-Einstellungen.",
"loopUnnamed": "Polar Loop",
"loopSettingUp": "Richte das Band ein …",
"loopSettingUpHint": "Android fragt dich vielleicht, ob du es koppeln willst.",
"loopSetUpDone": "Das Band ist eingerichtet.",
"loopFormTitle": "Für dein Band",
"loopFormIntro": "Dein Band braucht diese Angaben. Sie bleiben in „Dein Körper“, mit deinem Schlüssel versiegelt. Für einen Ruhe- oder Maximalpuls oder eine VO2max, die du nicht angegeben hast, nimmt das Band Polars Standardwerte, und „Dein Körper“ bleibt, wie er ist.",
"loopFormSave": "Band einrichten"
}

View file

@ -239,5 +239,42 @@
"profileSleepGoal": "Sleep goal",
"profileSave": "Save",
"profileSaved": "Saved",
"profileInvalid": "Check the figures marked."
"profileInvalid": "Check the figures marked.",
"profileNeeded": "Needed for your band",
"loopTitle": "Polar Loop",
"loopHint": "Your band: its setup, battery and clock",
"@loopHint": {"description": "Under Polar Loop in the account sheet."},
"loopNone": "Train sets up a Polar Loop and keeps it itself, without Polar Flow or a Polar account. A band used with Polar Flow needs a factory reset first.",
"loopSetUp": "Set up a Loop",
"loopSetUpAgain": "Set up again",
"loopLetGo": "Let the band go",
"loopLetGoTitle": "Let the band go?",
"loopLetGoText": "The band is reset to the factory's state, and what it holds that hasn't reached Train is lost. Afterwards it can be paired with another phone, or with Polar Flow.",
"loopLetGoConfirm": "Reset the band",
"loopConnected": "Connected",
"loopLastSeen": "Last seen {when}",
"@loopLastSeen": {"placeholders": {"when": {"type": "String", "example": "28 Oct 2026, 18:30"}}},
"loopNotInReach": "Not in reach",
"loopBattery": "Battery {level} %",
"@loopBattery": {"placeholders": {"level": {"type": "int"}}},
"loopCharging": "Battery {level} %, charging",
"@loopCharging": {"placeholders": {"level": {"type": "int"}}},
"loopFirmware": "Firmware {version}",
"@loopFirmware": {"placeholders": {"version": {"type": "String", "example": "1.2.3"}}},
"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",
"loopSent": "Sent to the band.",
"loopFailed": "The band didn't answer. Keep it close, and try again.",
"loopSearching": "Looking for Loops nearby",
"loopSearchingHint": "Keep the band close; tap it to wake it up.",
"loopRefused": "Train can't look for your band without nearby devices. Allow them in Android's settings.",
"loopUnnamed": "Polar Loop",
"loopSettingUp": "Setting up the band…",
"loopSettingUpHint": "Android may ask you to pair it.",
"loopSetUpDone": "The band is set up.",
"loopFormTitle": "For your band",
"loopFormIntro": "Your band needs these. They're kept in Your body, sealed with your key. For a resting or highest heart rate or a VO2max you haven't given, the band takes Polar's defaults, and Your body stays as it is.",
"loopFormSave": "Set up the band"
}

View file

@ -1161,6 +1161,192 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Check the figures marked.'**
String get profileInvalid;
/// No description provided for @profileNeeded.
///
/// In en, this message translates to:
/// **'Needed for your band'**
String get profileNeeded;
/// No description provided for @loopTitle.
///
/// In en, this message translates to:
/// **'Polar Loop'**
String get loopTitle;
/// Under Polar Loop in the account sheet.
///
/// In en, this message translates to:
/// **'Your band: its setup, battery and clock'**
String get loopHint;
/// No description provided for @loopNone.
///
/// In en, this message translates to:
/// **'Train sets up a Polar Loop and keeps it itself, without Polar Flow or a Polar account. A band used with Polar Flow needs a factory reset first.'**
String get loopNone;
/// No description provided for @loopSetUp.
///
/// In en, this message translates to:
/// **'Set up a Loop'**
String get loopSetUp;
/// No description provided for @loopSetUpAgain.
///
/// In en, this message translates to:
/// **'Set up again'**
String get loopSetUpAgain;
/// No description provided for @loopLetGo.
///
/// In en, this message translates to:
/// **'Let the band go'**
String get loopLetGo;
/// No description provided for @loopLetGoTitle.
///
/// In en, this message translates to:
/// **'Let the band go?'**
String get loopLetGoTitle;
/// No description provided for @loopLetGoText.
///
/// In en, this message translates to:
/// **'The band is reset to the factory\'s state, and what it holds that hasn\'t reached Train is lost. Afterwards it can be paired with another phone, or with Polar Flow.'**
String get loopLetGoText;
/// No description provided for @loopLetGoConfirm.
///
/// In en, this message translates to:
/// **'Reset the band'**
String get loopLetGoConfirm;
/// No description provided for @loopConnected.
///
/// In en, this message translates to:
/// **'Connected'**
String get loopConnected;
/// No description provided for @loopLastSeen.
///
/// In en, this message translates to:
/// **'Last seen {when}'**
String loopLastSeen(String when);
/// No description provided for @loopNotInReach.
///
/// In en, this message translates to:
/// **'Not in reach'**
String get loopNotInReach;
/// No description provided for @loopBattery.
///
/// In en, this message translates to:
/// **'Battery {level} %'**
String loopBattery(int level);
/// No description provided for @loopCharging.
///
/// In en, this message translates to:
/// **'Battery {level} %, charging'**
String loopCharging(int level);
/// No description provided for @loopFirmware.
///
/// In en, this message translates to:
/// **'Firmware {version}'**
String loopFirmware(String version);
/// No description provided for @loopProfileChanged.
///
/// In en, this message translates to:
/// **'Your body changed since the band was set up'**
String get loopProfileChanged;
/// No description provided for @loopProfileChangedHint.
///
/// In en, this message translates to:
/// **'Sending sets the band up again with your figures as they are now.'**
String get loopProfileChangedHint;
/// No description provided for @loopSend.
///
/// In en, this message translates to:
/// **'Send to the band'**
String get loopSend;
/// No description provided for @loopSent.
///
/// In en, this message translates to:
/// **'Sent to the band.'**
String get loopSent;
/// No description provided for @loopFailed.
///
/// In en, this message translates to:
/// **'The band didn\'t answer. Keep it close, and try again.'**
String get loopFailed;
/// No description provided for @loopSearching.
///
/// In en, this message translates to:
/// **'Looking for Loops nearby'**
String get loopSearching;
/// No description provided for @loopSearchingHint.
///
/// In en, this message translates to:
/// **'Keep the band close; tap it to wake it up.'**
String get loopSearchingHint;
/// No description provided for @loopRefused.
///
/// In en, this message translates to:
/// **'Train can\'t look for your band without nearby devices. Allow them in Android\'s settings.'**
String get loopRefused;
/// No description provided for @loopUnnamed.
///
/// In en, this message translates to:
/// **'Polar Loop'**
String get loopUnnamed;
/// No description provided for @loopSettingUp.
///
/// In en, this message translates to:
/// **'Setting up the band…'**
String get loopSettingUp;
/// No description provided for @loopSettingUpHint.
///
/// In en, this message translates to:
/// **'Android may ask you to pair it.'**
String get loopSettingUpHint;
/// No description provided for @loopSetUpDone.
///
/// In en, this message translates to:
/// **'The band is set up.'**
String get loopSetUpDone;
/// No description provided for @loopFormTitle.
///
/// In en, this message translates to:
/// **'For your band'**
String get loopFormTitle;
/// No description provided for @loopFormIntro.
///
/// In en, this message translates to:
/// **'Your band needs these. They\'re kept in Your body, sealed with your key. For a resting or highest heart rate or a VO2max you haven\'t given, the band takes Polar\'s defaults, and Your body stays as it is.'**
String get loopFormIntro;
/// No description provided for @loopFormSave.
///
/// In en, this message translates to:
/// **'Set up the band'**
String get loopFormSave;
}
class _AppLocalizationsDelegate

View file

@ -637,4 +637,114 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get profileInvalid => 'Prüfe die markierten Angaben.';
@override
String get profileNeeded => 'Braucht dein Band';
@override
String get loopTitle => 'Polar Loop';
@override
String get loopHint => 'Dein Band: Einrichtung, Akku und Uhrzeit';
@override
String get loopNone =>
'Train richtet einen Polar Loop ein und betreut ihn selbst, ohne Polar Flow und ohne Polar-Konto. Ein Band, das mit Polar Flow verbunden war, braucht zuerst einen Werksreset.';
@override
String get loopSetUp => 'Loop einrichten';
@override
String get loopSetUpAgain => 'Neu einrichten';
@override
String get loopLetGo => 'Band freigeben';
@override
String get loopLetGoTitle => 'Band freigeben?';
@override
String get loopLetGoText =>
'Das Band wird auf Werkseinstellungen zurückgesetzt, und was es hält und noch nicht bei Train angekommen ist, geht verloren. Danach kannst du es mit einem anderen Handy oder mit Polar Flow koppeln.';
@override
String get loopLetGoConfirm => 'Band zurücksetzen';
@override
String get loopConnected => 'Verbunden';
@override
String loopLastSeen(String when) {
return 'Zuletzt gesehen $when';
}
@override
String get loopNotInReach => 'Nicht in Reichweite';
@override
String loopBattery(int level) {
return 'Akku $level %';
}
@override
String loopCharging(int level) {
return 'Akku $level %, lädt';
}
@override
String loopFirmware(String version) {
return 'Firmware $version';
}
@override
String get loopProfileChanged =>
'Dein Körper hat sich geändert, seit das Band eingerichtet wurde';
@override
String get loopProfileChangedHint =>
'Senden richtet das Band mit deinen jetzigen Angaben neu ein.';
@override
String get loopSend => 'Ans Band senden';
@override
String get loopSent => 'Ans Band gesendet.';
@override
String get loopFailed =>
'Das Band hat nicht geantwortet. Halte es nah und versuch es noch mal.';
@override
String get loopSearching => 'Suche Loops in der Nähe';
@override
String get loopSearchingHint =>
'Halte das Band nah; tipp darauf, um es zu wecken.';
@override
String get loopRefused =>
'Ohne Geräte in der Nähe kann Train dein Band nicht suchen. Erlaube sie in den Android-Einstellungen.';
@override
String get loopUnnamed => 'Polar Loop';
@override
String get loopSettingUp => 'Richte das Band ein …';
@override
String get loopSettingUpHint =>
'Android fragt dich vielleicht, ob du es koppeln willst.';
@override
String get loopSetUpDone => 'Das Band ist eingerichtet.';
@override
String get loopFormTitle => 'Für dein Band';
@override
String get loopFormIntro =>
'Dein Band braucht diese Angaben. Sie bleiben in „Dein Körper“, mit deinem Schlüssel versiegelt. Für einen Ruhe- oder Maximalpuls oder eine VO2max, die du nicht angegeben hast, nimmt das Band Polars Standardwerte, und „Dein Körper“ bleibt, wie er ist.';
@override
String get loopFormSave => 'Band einrichten';
}

View file

@ -633,6 +633,114 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get profileInvalid => 'Check the figures marked.';
@override
String get profileNeeded => 'Needed for your band';
@override
String get loopTitle => 'Polar Loop';
@override
String get loopHint => 'Your band: its setup, battery and clock';
@override
String get loopNone =>
'Train sets up a Polar Loop and keeps it itself, without Polar Flow or a Polar account. A band used with Polar Flow needs a factory reset first.';
@override
String get loopSetUp => 'Set up a Loop';
@override
String get loopSetUpAgain => 'Set up again';
@override
String get loopLetGo => 'Let the band go';
@override
String get loopLetGoTitle => 'Let the band go?';
@override
String get loopLetGoText =>
'The band is reset to the factory\'s state, and what it holds that hasn\'t reached Train is lost. Afterwards it can be paired with another phone, or with Polar Flow.';
@override
String get loopLetGoConfirm => 'Reset the band';
@override
String get loopConnected => 'Connected';
@override
String loopLastSeen(String when) {
return 'Last seen $when';
}
@override
String get loopNotInReach => 'Not in reach';
@override
String loopBattery(int level) {
return 'Battery $level %';
}
@override
String loopCharging(int level) {
return 'Battery $level %, charging';
}
@override
String loopFirmware(String version) {
return 'Firmware $version';
}
@override
String get loopProfileChanged =>
'Your body changed since the band was set up';
@override
String get loopProfileChangedHint =>
'Sending sets the band up again with your figures as they are now.';
@override
String get loopSend => 'Send to the band';
@override
String get loopSent => 'Sent to the band.';
@override
String get loopFailed =>
'The band didn\'t answer. Keep it close, and try again.';
@override
String get loopSearching => 'Looking for Loops nearby';
@override
String get loopSearchingHint => 'Keep the band close; tap it to wake it up.';
@override
String get loopRefused =>
'Train can\'t look for your band without nearby devices. Allow them in Android\'s settings.';
@override
String get loopUnnamed => 'Polar Loop';
@override
String get loopSettingUp => 'Setting up the band…';
@override
String get loopSettingUpHint => 'Android may ask you to pair it.';
@override
String get loopSetUpDone => 'The band is set up.';
@override
String get loopFormTitle => 'For your band';
@override
String get loopFormIntro =>
'Your band needs these. They\'re kept in Your body, sealed with your key. For a resting or highest heart rate or a VO2max you haven\'t given, the band takes Polar\'s defaults, and Your body stays as it is.';
@override
String get loopFormSave => 'Set up the band';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).