halkyon/docs/adr/0030-halkyon-train.md
Tizian 「ティツィアーン」 d9f98449a8
feat(train): a recording runs an interval plan, with cues and Lap (ADR 0030, D42)
The plan chosen on the Record sheet goes to the recording service spelled
out: each step's end, its target (zones as the heart rates they span for
the profile's highest rate) and its spoken line in the app's language.
PlanRunner.kt moves through the steps on the moving time and distance,
counts a timed step's last seconds down and cues off target from a 30 s
window; PlanCues.kt gives a tone, a vibration and the voice, ducking
music. The file's plan and step lines bring it back, and the recording
screen shows the step, what is left of it, its target, the next step and
Lap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-30 15:53:00 +01:00

65 KiB
Raw Blame History

ADR 0030: Halkyon Train, what it does and where its data comes from

  • Status: decided 27–28 Oct 2026, by the developer (D1–D43 below). The shell (D12) is built; recording (D13–D28) is built on Android, iOS waiting for a Mac; Health Connect (D29–D31) is built; the Loop's setup and keeping (7a, D34–D36) and its daily data (7b, D37, D38), its sessions (7c, D39) and its firmware (D10, D40) are built, and its live heart rate (7d, D41); intervals (D42, D43) are being built, the plans (8a) and the service running them (8b) are.
  • Numbering: 0025 is held by the federation draft (stashed), 0026–0029 by the .NET rewrite on the local branch rewrite-dotnet, abandoned on 27 Oct; Train takes the next free number so that neither is ever read as this one.
  • Followed by: ADR 0031 (the apps' icons, Train's among them).
  • Relates to: ADR 0014 (halkyon_base, which Train's server includes like every other), ADR 0016 (one account across an operator's services; Train is one of them).

Context

Train is the third of Halkyon's apps after Learn and Todo, so far only named. On 27 Oct the developer asked for it to "encompass advanced features for running and cycling", to connect to a Polar Loop, and for rowing machines' APIs to be looked into (WaterRower, Kettler, Christopeit, BH Fitness, Tunturi), the Christopeit WP 5000 in detail. For running they asked for "GPS tracking, automatic stop (at least the functionality of e.g. F-Droid's OpenTracks)", HIIT, music in tandem with the pace, and planning tracks as on Strava. What was found, 27 Oct:

  • Open Bluetooth profiles carry most of it. Heart rate (0x180D, measurement 0x2A37 with RR intervals, hence HRV), running speed and cadence (0x1814), cycling speed, cadence and power, and the Fitness Machine Service (FTMS, 0x1826; Rower Data 0x2AD1, Indoor Bike Data, Treadmill Data; resistance set through its control point). Any strap, footpod, power meter, smart trainer or FTMS rower works without a vendor's account or SDK, on every platform with Bluetooth LE. Pixels have no ANT+ radio, so on GrapheneOS Train is Bluetooth LE only.
  • The Polar Loop (Sept 2025, no screen, no GPS) is no Bluetooth standard: it speaks Polar's own protocol, and doesn't broadcast its heart rate as a 0x180D sensor (Polar: it "cannot be used as a heart rate sensor for other devices"). Polar's BLE SDK (Android and iOS, its own permissive licence, source-available) covers it: first-time setup from one's own app (doFirstTimeUse(), no Polar account named anywhere), live HR, acceleration, PPI, skin temperature and PPG, and the export of sleep, sessions, daily activity, 24/7 HR and PPI and Nightly Recharge. It pairs with one peer only; re-pairing needs a factory reset. Firmware updates fetch from Polar (a check sending the device's UUID, firmware version and hardware code, no health data). Polar's cloud (AccessLink, OAuth) gives much the same through Polar Flow, free for now and revocable.
  • Rowing machines: none of the five brands publishes an API. WaterRower documents its S4/S5 monitor's USB serial protocol and its ComModule speaks FTMS; BH's current i.Concept range speaks FTMS; Kettler's Coach 2 very probably, Christopeit's and Tunturi's unconfirmed. Concept2's PM5 is documented and speaks FTMS. Older machines on Classic Bluetooth (SPP) are reachable neither on iOS nor well from Flutter.
  • The Christopeit WP 5000 (manual of 6 Nov 2025): Bluetooth 4.0, a built-in Bluetooth heart-rate receiver, water with a 16-level magnetic brake the console moves itself (its 12 programmes change it), mains-powered. Its manual names Kinomap, picked by brand, not as a generic FTMS device; an owner reports (mid-2025) that neither a Garmin's FTMS rowing profile nor EXR could pair. Christopeit's own app, Christopeit Connect (Vescape, com.vescape.christopeit), supports it, declares no data collected (App Store), and writes workouts to Health Connect or Apple Health. Whether the console speaks FTMS is unknown until it is scanned.
  • Health Connect is part of Android since 14, so GrapheneOS has it without Google Play; data stays on the device and each app is granted each type. Gadgetbridge (Garmin, Huawei, Colmi rings, Amazfit and others, no account) and Christopeit Connect write to it; OpenTracks writes sessions, distance, speed and routes there, but no heart rate.
  • OpenTracks is Apache-2.0, so its logic may be taken into Train (AGPL) with its notice; the open projects talking to rowers and trainers (qdomyos-zwift, OpenRowingMonitor, pirowflo) are GPL, references for the protocols only.
  • Music by tempo: Spotify closed its tempo data (audio features) to new apps on 27 Nov 2024; Qobuz grants its API to commercial partners only and refuses open-source integrations. Another Android app's title can be read from its media session and its tempo looked up (Deezer's bpm, often missing; AcousticBrainz, frozen since 2022), but only shown, never chosen by. Jellyfin's API is open but has no tempo field.

Decisions (27 Oct 2026, the developer's)

  • D1 What Train does: running and cycling with "advanced features", rowing machines, the Polar Loop. For running at least what OpenTracks does (GPS tracking, pausing by itself), structured intervals (HIIT), music matching the pace, and planning routes as on Strava.

  • D2 In this order (recommended): recording first (GPS, auto-pause, a heart-rate strap), then intervals, then route planning, then music. Recording is what everything else builds on; route planning is the largest piece (maps and a router); music needs the intervals' targets to follow.

  • D3 Train takes over the Polar Loop through Polar's BLE SDK, with no Polar Flow and no Polar account (recommended, over AccessLink, Polar's cloud, as the main way in): the SDK gives nearly all AccessLink would, on the device and without Polar in between. Since the Loop pairs with one peer, the band is Train's alone; the phone holding it syncs it, and other devices see its data through Train's server, as the SDK runs on Android and iOS only.

  • D4 Music comes from Jellyfin, with Train as the player (recommended, over reading what Spotify or Qobuz play, which the developer judged "a rather stale feature"): Train chooses the next track by the cadence or pace an interval asks for, may speed a track up or slow it down a few per cent, and downloads the chosen tracks before a run for playing offline. Each track's tempo is worked out by Train from its audio, once, and kept (Jellyfin has no tempo field; its MusicTags plugin only passes on a BPM tag files already have). A metronome at the target cadence works where no library is set.

  • D5 Devices connect to phones only, through flutter_reactive_ble (BSD-3, Android and iOS, Philips Hue's; recommended once the desktop was out, over universal_ble, BSD-3 on every platform but younger and less used, and over flutter_blue_plus, whose own licence asks a fee for commercial use, which an AGPL app can't pass on): "I find it exceedingly unlikely one owns or purchases a fitness band or smartwatch without intending or even owning a smartphone", and "the open license is also of paramount importance". Train talks to its own small interface (scan, connect, subscribe, write), the profiles' parsers are pure Dart, tested on recorded frames, so another library can take its place behind it.

  • D6 Health Connect both ways (recommended): Train writes its workouts there (the session with its type, route, heart rate, distance, speed and calories), off until the person turns it on, its own store staying the source of truth; and reads from there other apps' workout sessions (their heart rate, distance, power) and the person's weight and resting heart rate, each source turned on by itself. Sources Train can't speak to (Garmin and Colmi through Gadgetbridge, the WP 5000 through Christopeit Connect) come in so; Train tells its own records by their origin and takes a session another app wrote of a workout it recorded itself for the same one. Through the health package (MIT, CARP's, Android and iOS; recommended was a Kotlin channel of our own): it has no types for power or cadence, which Train therefore neither writes nor reads there, but records from the sensors directly; they can be added upstream or by a channel of our own when a source needs them.

  • D7 Train's data is sealed, always (recommended): the payloads of its rows (sessions, routes, every series, what is imported) are ADR 0017's sealed content under the person's data key, as ADR 0019 seals a private studyset, so the instance syncs what it can't read; imported data syncs as Train's own does. Health data is a special category of the GDPR (Art. 9), and nothing on the server needs to read it. The instance still sees that a row exists, its size, and when it changed; what Train works out (training load, zones, records) is worked out on the device; a route shared later is a copy made readable on purpose, as publishing is in Learn.

  • D8 The WP 5000 is an FTMS rower, reached by Train's FTMS client and nothing of its own (found on the developer's machine, 27 Oct, with nRF Connect): its console (MRK-950R-8021) advertises the Fitness Machine service, with its service data saying "rower, available" (0x1826, 01 10 00), and offers Fitness Machine Feature, Status, Training Status, the Control Point (write, indicate), Rower Data (notify) and Supported Resistance Level Range. Its features: cadence, total distance, pace, resistance level, stroke count, energy, heart rate, elapsed time, power, and setting a resistance target. A frame while rowing: 25 strokes a minute, 6 strokes, 18 m, pace 439 s, 100 W, resistance 1, 1 kcal, heart rate 0 (no strap), 17 s. The forum's report that Garmin and EXR couldn't pair isn't explained by the machine as it is now (an older firmware, or a console already connected elsewhere, which most allow one of, may be). Its other services (0xFFF0, 0xF100, 0xAE00, and one whose UUID spells "YULU" and "MERACH", presumably the console's maker's own protocol, which Kinomap and Christopeit Connect may use) are left alone; Health Connect and working out a protocol aren't needed.

  • D9 No WaterRower USB driver for now (recommended): it would serve S4 monitors without the ComModule, on Android only (iOS allows no USB serial device outside Apple's accessory programme), through an OTG cable during the workout, as a second transport beside Bluetooth, with no WaterRower at hand to test on; a WaterRower with the ComModule speaks FTMS. Its protocol is published, so a driver can be added behind Train's sensor interface once someone asks for it with one at hand.

  • D10 The Loop's firmware: from Polar directly by default, or through the instance (the developer's choice, after option 1 alone was recommended for phones always on a VPN; over updating never). Polar's SDK checks with Polar's Firmware Management API (sending a UUID made from the band's ID, its firmware version, its hardware code and a fixed client name, no health data) and flashes either what it fetched or a file on the phone (updateFirmware(identifier, firmwareUrl) takes file://, read in its source, 27 Oct); the band's settings are backed up over Bluetooth into the phone's memory and written back, never sent anywhere. So:

    • Directly (the default): the phone asks Polar and fetches the file. Polar learns the phone's address, a VPN's exit if one is always on (on GrapheneOS with "Block connections without VPN").
    • Through the instance (a setting): the phone sends the band's UUID, firmware version and hardware code to Train's server, which asks Polar, fetches the file once for every band of that hardware and hands it to the phone, which flashes it from a file. Polar learns the instance's address, which on a small instance points at its operator nearly as well as a home's does. Train says both where the setting is, and names what Polar learns where it offers an update. An update never starts by itself: Train first syncs what is on the band and deletes it there, says the band is erased and set up again, and asks.
  • D11 Maps and routes (recommended, after looking at how open apps do it; 27 Oct):

    • The map widget is flutter_map (BSD-3, Fleaflet's; the developer's choice, over maplibre, BSD-3 and rendering vector tiles natively but at 0.3): drawing tracks, heart rate's colours and waypoints over the map is ordinary Flutter. Vector tiles come through vector_map_tiles and its PMTiles plugin, tested on the developer's phone with a region's file before route planning is built; should it stutter, maplibre takes the map screens' place.
    • The map's source is the instance's, one of three (an operator's setting): a PMTiles file of its region as a sidecar (recommended, seen by no one else); OpenFreeMap (no key, no contract; the default without a sidecar); or a provider of the operator's choice with its address and key (MapTiler, JawgMaps and the European others), whose key is then readable by anyone using the app. OpenStreetMap's own tile servers forbid such use. The app names the source in use and what it sees.
    • The router is BRouter, as a sidecar (MIT; recommended over GraphHopper, which needs gigabytes to import a country, and Valhalla, with fewer sport profiles): its own image, 128–512 MB, the 5°×5° segments of the operator's region downloaded from BRouter's project (built from OSM with elevation) and refreshed monthly, its profiles text files a running one can be written for. As LanguageTool: no site of its own, reached by Train's server only, for people signed in, limited, no waypoint kept or logged. A route saved is Train's data, sealed (D7). Searching for places (geocoding) is decided when planning is built; until then a route is planned by tapping the map.
    • Offline: recording, following a route (synced beforehand with BRouter's turn hints), noticing a detour and guiding back to the route, music. Maps offline along a route (a corridor of a few kilometres downloaded with it), whole regions optional: a phone with CoMaps holds them already. Planning, rerouting and searching need the instance; BRouter on Android (its app, or its engine inside Train) may come later, never on iOS (no Java).
    • With CoMaps (Apache-2.0, OSM's data; read in its source at 63a53413, 27 Oct, by an agent, nothing tried on a device): its maps (MWM, its own format, in its private storage) can't be read by another app, so the apps work together instead:
      1. a route planned in Train goes to CoMaps as GPX (VIEW or SEND, a new list each; tracks, routes, waypoints, elevation, times, colours kept; no GeoJSON), to be seen there offline;
      2. turn hints come from Train, which announces them with the workout's own, since CoMaps navigates along no imported track (its issue #251, open);
      3. optional: a route to a point is handed to CoMaps (cm://route?sll=…&saddr=…&dll=… &daddr=…&type=pedestrian|bicycle), which builds it offline and the person starts; on Android, Train may read along through CoMaps' navigation provider (since mid 2026; a runtime permission the person grants), for the route and what is left;
      4. tracks and routes from CoMaps reach Train as GPX when the person shares them. Train builds the cm:// links itself: Organic Maps' API library and om:// don't reach CoMaps on Android. On iOS (iphone/Maps/CoMaps.plist, DeepLinkHandler.swift, read 28 Oct): GPX, KML and KMZ open in CoMaps (1, 4), cm://route works with one point between start and finish (3), its backurl leads back to Train; picking a point (crosshair) is "not supported on iOS", and iOS has no navigation provider.
  • D12 Train's shell is Todo's (recommended, 27 Oct; the icon the developer's choice, the rest taken without objection on 28 Oct):

    • a server of its own, apps/train/ with halkyon_train_server, _client, _flutter, including halkyon_base (trainBase()), its own register when it runs alone and a member of the instance's where the operator names one (ADR 0016); over Train's endpoints in Learn's server, whose deploys, migrations and failures would then be Train's, and which an operator couldn't run Train without;
    • synced with serverpod_offline_sync (ADR 0001), its rows sealed (D7), since recording happens offline;
    • ports of its own in development, 8180–8191 where Learn and Todo share 8080–8091, so that Train runs beside Learn's server as a member of its register;
    • Android and iOS record; Linux, macOS and Windows show and plan (D5); no web, as for Learn and Todo, whose keys (ADR 0017) need libsodium natively;
    • Halkyon Train, app.halkyon.train, in English and German alike;
    • its icon is Lucide's bike, in the family of ADR 0031 ("activity" and "heart-pulse" look like basic health apps, "a defibrillator or ADAC health application"; "this is more about training"; Lucide has no runner);
    • deployed once recording works and the developer wants to use it, not as an empty shell first.

Recording (28 Oct 2026, the developer's)

  • D13 Positions are recorded by Train's own platform code (recommended, over the geolocator package and paid background-tracking packages): on Android a foreground service of its own, as Learn's Pomodoro timer has, reading LocationManager's GPS provider as OpenTracks does and appending every fix to a recording file in the app's folder, which the app follows while open and takes in when it opens again; on iOS CoreLocation with background location; both behind one interface (TrackRecorder), faked in tests. So a recording goes on with the app swiped away, and nothing of Google Play services is in the app: geolocator's Android part depends on Play services' location library even where its forceLocationManager keeps it from being used, a non-free library that F-Droid's main repository refuses, and its positions arrive in Dart, which a swipe ends. The strap and the rower join the same service later.

  • D14 A workout and its series are kept apart (recommended, over one row holding every sample and over sealed GPX or FIT files in the instance's storage): workout (type, start, end, name, notes, totals; changed when edited, the last writer winning, as a studyset's payload) and workout_series (a chunk of one series, positions, heart rate, strokes, pauses, of up to 10 minutes; written once as the workout is saved, never changed, deleted with its workout, as Learn's review log), every payload sealed (D7) and nothing readable in a column, not the type nor the start. Editing a workout touches one small row, two devices never conflict over samples, and every source is one more kind of series. A chunk's payload is JSON as every payload, each column (time, latitude, longitude, altitude, accuracy, …) a list of whole-number differences from the value before, gzipped before it is sealed, since sealed bytes compress no more. The recording file is the device's own and never synced. UUID v7 ids carry their creation time, which the sync engine's change times tell anyway (D7's list).

  • D15 Sealing: the envelope moves into halkyon_app, Train holds its key itself (recommended, over moving all of Learn's key management and over a copy of the envelope in Train): Learn's PayloadEnvelope (a payload sealed for its table, row and column; readable payloads signed by the account; what doesn't open Locked) moves unchanged into halkyon_app, Learn importing it from there; Train has one key per person in a synced content_key row, its sealed ID saying it is the person's, and a device without the account's keys writes nothing, as in Learn. Learn's tree (private or readable studysets, their records, rewrites) stays Learn's. Publishing a workout later doesn't change it (asked by the developer): a published workout is a readable copy made on the device (D7), which may leave out the heart rate or the first and last few hundred metres, as Strava's privacy zones do, the sealed workout staying as it is; the envelope can sign the copy with the account's key.

  • D16 Auto-pause by each activity's best signal, never discarding samples (recommended, over GPS alone and over manual pauses only): a pause, automatic or by hand, is a marker in a series of its own, and moving time, pace and the totals leave paused stretches out, so a misjudged pause is corrected from the data afterwards. Running and walking stop with the phone's step detector (Android's step sensor, iOS's pedometer; its permission asked for, GPS alone without it), confirmed by GPS; cycling by GPS speed, paused below 3 km/h for 5 s and going on above 5 km/h; rowing by the rower's stroke rate, no stroke for 5 s. On by default for running and cycling, the thresholds adjustable once Train has settings.

  • D17 Running, walking, hiking, cycling and indoor rowing first, in this order of parts (recommended): 5a the envelope moved, Train's key holder, the two models and their migration, the chunk codec and the first synced repository; 5b recording on Android (the service, the file taken in after a swipe, the recording screen, auto-pause, saving); 5c the workouts and a workout's page with its track on a map (D11's flutter_map and the instance's tile source brought forward from route planning, the developer's choice: a run without its track looks unfinished), totals, splits and charts; 5d recording on iOS; 5e the heart-rate strap; 5f the WP 5000 over FTMS, from frames recorded with nRF Connect. Health Connect (D6) and the Loop (D3) follow recording. Each type maps onto one of Health Connect's exercise types, and is a name in the sealed payload, so new ones need no migration.

  • D18 The recording file is text, one sample a line, appended (recommended, over fixed-size binary records, smaller only while recording, as a saved workout is delta-coded JSON anyway, and over samples handed straight to Dart, lost when Android stops the Flutter engine while the service runs, against D13): each line a sample with its kind (a position, a heart rate, a step, a pause's start or end) and its time, flushed every few seconds. A line cut short is dropped as it is read; an unfinished file the app finds when it next opens is offered to be saved up to its last sample or thrown away. It lies in the person's folder, in the app's private storage, unsealed, as their local database does, until it is saved as a workout and sealed (D7).

  • D19 Permissions asked when first needed, never background location (recommended, over background location, which would let a recording start with Train closed and go on after Android killed its process, but asks to be allowed all the time on a settings page of its own since Android 11 and a declaration on Google Play, more than Train's privacy fits; OpenTracks does without it too): precise location and, on Android 13 and later, notifications as an activity with GPS first starts, not at install or first launch (refused notifications hide the recording's notification and leave it running); physical activity only as running, walking or hiking first starts with auto-pause on, for the step detector (D16), auto-pause falling back to GPS speed for that workout where it is refused. The service is a foreground service of the types location and health, started from the app while it is open, which keeps its access to location with the screen off; a process Android kills leaves its file (D18) to be found when Train next opens.

  • D20 Every fix is kept with its accuracy, and filtered as it is read (recommended, over dropping imprecise fixes in the service, which keeps the series smaller and the reading simpler but can't be undone for the workouts recorded under a threshold that proves wrong): the file and the saved series hold each fix the phone delivered with the accuracy it reported; distance, pace, splits and the track drawn leave out the fixes less accurate than a threshold, 20 m to start with (as OpenTracks' default), adjustable once Train has settings. A better filter later (smoothing, a Kalman filter) reads old workouts anew, as nothing was thrown away. An hour at a fix a second is 3,600 fixes, tens of kilobytes delta-coded and gzipped.

  • D21 The recording screen (proposed, the developer's): home's Record button opens a sheet of the activities, the last one recorded chosen, with auto-pause's switch as D16 sets it; Start asks the permissions (D19). Until the first fix precise enough the screen says it waits for GPS, showing the accuracy, while it records already, the time from Start. Recording, it shows the moving time, the distance and the pace now (min/km running, walking and hiking, km/h cycling; rowing time alone until 5f; heart rate once a strap is there, 5e), an automatic pause told apart from one by hand, no map until 5c. Stop is a long press, so that no pocket ends a run (over a tap and a confirmation, quicker and easier to hit by mistake), then Save or Discard, Discard asked again. The notification shows time and distance with Pause or Resume and opens the app; stopping only in the app, so that no stray touch on the lock screen ends a workout. A recording found cut off as Train opens (D18) is offered to be saved up to its last sample or discarded.

    Built on 28 Oct with three choices of the implementation's, for the developer to look at: rowing indoors isn't offered yet, as a foreground service on Android 14 and later needs a type its permissions allow and rowing without the rower has none (5f's Bluetooth gives it connectedDevice); on foot without the step detector auto-pause goes by GPS speed below 2 km/h for 5 s, going on above 3 km/h (D16 names only the bike's thresholds); and Stop's hint shows above the button for three seconds rather than in a snack bar, which would cover the button it speaks of. The service holds a partial wake lock while it records (at most 12 hours), so that its clock and file run with the screen off where no GPS fix wakes the processor.

  • D22 OpenFreeMap first, the PMTiles sidecar with route planning (recommended, over the sidecar now, which no one else ever sees the areas viewed through but needs Train's server and the sidecar deployed on learn.halkyon.eu before anything else of Train is ready, and a region chosen, gigabytes on the server's disk; and over a source fixed in the app, which skips D11's operator's setting and would be undone): Train's server names the instance's map source, as its operator sets it, OpenFreeMap where nothing is set (D11), so 5c needs no deploy; a workout's page names the source drawing it and what it sees (the device's address and the area viewed, not the track). The sidecar comes when route planning gives Train's server a reason to run on the instance: the region's PMTiles file served as ordinary tiles (D23).

  • D23 flutter_map 8 with vector_map_tiles 9's beta (recommended; 28 Oct): D11's combination doesn't exist as released, as vector_map_tiles' stable line (8) needs flutter_map 7 and its PMTiles plugin (1.5.0, October 2024) vector_map_tiles 8, while the 9 line, beta since November 2024 (beta.13 on 1 September 2026), is where its maintainer works and needs flutter_map 8. Over the stable line, all three packages of 2024 and held there until 9 is out, and over maplibre (0.3.7), native and smooth but drawing tracks and their colours as its layers rather than Flutter's widgets, the reason for D11's choice. The beta is pinned exactly and moves to 9.0.0 once released; the sidecar serves the region's PMTiles file as ordinary vector tiles with Protomaps' pmtiles serve (go-pmtiles, BSD-3-Clause) instead of the app reading it, so the app holds nothing of PMTiles'.

  • D24 The workouts and a workout's page (the developer's; 28 Oct): home lists the workouts newest first under their months, each with its activity's icon, its name (else the time of day's and the activity's, "Morning run"), its day and start, distance, moving time and pace (km/h cycling); one this device can't open shows locked; Record stays. A workout's page: the map on its upper part, the track in cobalt, its pauses gaps rather than straight jumps, start and end marked, the source's credit on it, fit to the track (heart rate's colours with the strap, 5e); its totals (distance, moving time, pace or speed, the whole time, the elevation gained, counting climbs above 3 m of the fixes precise enough, as GPS's altitude is noisy); its splits, per kilometre on foot, per 5 km cycling; charts of pace or speed and of elevation over the distance, drawn by the app's own small painter (over fl_chart, MIT, larger than two lines need and one more dependency to follow); under the map, which source draws it and what that sees, with its page where the operator names one (D22). Its name and notes are edited; deleting asks first and offers an undo, as Learn's deletions do. Without GPS no map and no splits.

    Built so (28 Oct), with choices of mine for the developer to look at: the day's parts start at 5, 12, 17 and 22 on the device's clock; locked workouts come last under no month, as when they were is sealed too; the map stays fixed above the page as it scrolls, pans and zooms but doesn't turn, and folds the source's credit into flutter_map's attribution button, the credit being wider than a phone; start green, end cerise, the track cobalt in either theme (the map's colours are the source's); the charts show their lowest and highest value beside them and nothing else; where the instance can't be asked for its map source, the page says so and shows the rest; the elevation counts a climb once it passes 3 m above where it last turned, so a steady climb's last metres short of the next 3 are not counted (a 20 m hill reads as 18 or 19 m): the simple filter, to be compared with recorded runs.

  • D25 iOS recording waits for a Mac (the developer's; 28 Oct): 5e and 5f come before 5d, which moves to the end of step 5. iOS's side (CoreLocation with background location, the pedometer, a Live Activity for the notification) needs Xcode, which runs on macOS alone, and this machine has none; written unbuilt it would look done and not compile, and CI's runner has no Xcode either. The heart-rate strap and the WP 5000 are built and tested on Android here meanwhile.

  • D26 The recorder service talks to the strap itself (recommended, the developer's; 28 Oct; over flutter_reactive_ble for finding the strap beside it, two stacks touching one strap for one screen's sake, and over D5's Dart alone, whose heart rate would stop where a swipe stops the Flutter engine, D18, while GPS records on): Android's own Bluetooth API in Kotlin, as OpenTracks has it, connects during a recording and writes the h lines, so heart rate is recorded as long as positions are; finding and choosing a strap goes through the recorder's channel too, so Train has one Bluetooth stack and no Bluetooth package. This revises D5: its open licence holds, as nothing third-party is left, and the platform's API sits behind RecorderPlatform as the library would have behind Train's own interface. The profiles' parsers (Heart Rate Measurement now, FTMS's Rower Data in 5f) are Kotlin, tested on the JVM on recorded frames, and iOS (5d) needs them again in Swift.

  • D27 The strap, from choosing it to the workout's page (the developer's; 28 Oct): the Record sheet's Heart rate row names the strap or "No strap" and opens a sheet that looks for devices advertising the Heart Rate Service (0x180D), the nearest first; the one chosen is remembered on the device, in the person's folder (strap.json, its address and name, never synced: each phone finds it anew), Forget strap letting it go. Nearby devices are asked for as the sheet first looks (D19), declared never to locate (Android 12 on; before, scanning needs location). No bonding: the service connects by address. A remembered strap records with every workout unless switched off on the sheet for one; the recording doesn't wait for it. The recording screen shows the heart rate ("–" while connecting), and "Strap lost" where GPS's state shows while it is gone; the notification stays time and distance, as a rate changing each second would repost it each second. A lost strap is asked for again in the background for as long as the recording runs; the minutes without it stay a gap. Only the rate is kept (h lines), not the R-R intervals, until something uses them. The workout's page: the heart's average and highest rate while moving among the totals, each split's average, and its chart over the distance (over the moving time without a track). The track is coloured by heart-rate zones once Train's settings hold the person's maximum rate, as colouring by each workout's own range paints an easy run as red as a race.

    Built so (28 Oct), with choices of mine for the developer to look at: the file gains strap <time> connecting|connected|lost lines (the format's version stays 1, as a reader before skipped lines it didn't know); a rate older than 5 s shows as "–"; the first attempt connects directly, falling back on Android's background connection once it fails and after every loss; a sighting stronger than −65 dBm is "close by"; the heart-rate chart averages the rates of each stretch (as the pace chart's stretches, or 10 s, at least, over the time). Not yet tried with a real strap.

  • D28 The rower, from choosing it to the workout's page (the developer's; 28 Oct): with indoor rowing chosen, the Record sheet's Rowing machine row opens a sheet as the strap's, looking for the Fitness Machine Service (0x1826) and listing the machines whose advertisement says "rower"; the one chosen is remembered in the person's folder (rower.json). Indoor rowing is offered again, Start asking for a rower. Train reads Rower Data and takes no control of the console (its resistance through the Control Point waits for a reason, as training plans); a console held by another app (one connection at a time) is said so. The foreground service gets the connectedDevice type, which nearby devices allow. Each frame becomes a line r <time> <stroke rate> <strokes> <metres> <watts> and a rowing series; the console's running totals count from its first frame, and its own heart rate is kept only without a strap. Auto-pause: no new stroke for 5 s pauses (D16), the next one goes on, on by default. The recording screen: moving time, metres, the split per 500 m of the last 30 s, the stroke rate, watts, the heart rate with a strap. The list shows the split per 500 m; the page: distance, moving time, the average split, stroke rate and watts, the highest watts, strokes, heart rate; splits per 500 m with their split, stroke rate and watts; charts of the split, the stroke rate and watts over the distance; no map. Written from FTMS 1.0 and D8's frame, checked on the developer's machine later (an nRF Connect log of Rower Data and Fitness Machine Status, or a first row).

    Built so (28 Oct), with choices of mine for the developer to look at: a machine whose advertisement carries no Fitness Machine data is listed too, as it can't tell, and only one that says it is something else is left out; a console reset to zero mid-row counts on from there; metres rowed while paused count for nothing, as GPS's do (D16); the strap's sheet and the rower's are one sheet, the same "close by" (−65 dBm); the strap and the rower share one connection class (NotifyLink.kt), and a row asks for nearby devices as it starts, as a run asks for location, since Android needs it for the service's type; Fitness Machine Status (the console's own pause and reset) is not read yet, the strokes deciding the pause; the notification shows a row's distance in kilometres, as the others'. Not yet tried on the WP 5000.

After recording (28 Oct 2026, the developer's)

  • D29 Health Connect next, the Loop after it (recommended, the developer's; over the Loop first, and over neither before the intervals): Health Connect is the smaller piece and serves every workout Train records already; writing gives them to every other app on the phone, reading brings what Train can't speak to itself (Gadgetbridge's watches and rings, Christopeit Connect's rows), and the Loop's sessions can later go there the same way. The Loop needs Polar's SDK, its first-time setup and D10's firmware, and new kinds of data (daily activity, sleep, 24/7 heart rate, Nightly Recharge) with synced models of their own; it is tried only with the band at hand. The intervals (D2) need neither.

  • D30 Health Connect through Kotlin of our own (recommended, the developer's; revises D6's health package, as D26 revised D5): androidx.health.connect:connect-client (Apache-2.0, AndroidX, no Play services) behind a channel of Train's, as the strap's. The health package (13.3.2) lacks power, cadence, elevation gained, distance and speed as records of their own, ties no heart rate to its session, has no changes API (so each read would be a whole window read again) and sets no client record ID (so a workout written again would be a second copy, not a replacement). Our own gets every record type, the changes API, Train's workout ID as the client record ID (written again replaces, deleted deletes) and each record's origin. Its logic is tested on the JVM, the Dart side on a fake platform; Apple Health needs Swift of its own later, as iOS waits for a Mac anyway (D25).

  • D31 Health Connect, from turning it on to the workouts (the developer's; 28 Oct):

    • Turning it on: a Health Connect row in the account sheet (Train has no settings yet) opens a screen with two switches, both off: Write my workouts and Read from other apps. They are this phone's, as Health Connect is, kept per person in their folder (health_connect.json, with what was written and the changes token), not synced. Each permission is asked as its switch is turned on. Built into Android from 14; below, the row says it needs the Health Connect app. connect-client 1.1.0, the stable one. The rationale Health Connect asks for is a short screen of Train's, linked from the system's permission dialog.
    • Writing: every workout of the person this phone holds, synced and earlier ones too; Train's workout ID is the record's client ID and a counter its version, so an edit replaces and a deletion deletes; imported ones are never written back. As a workout is saved, edited or deleted, and as the app opens (what a sync brought). The session (type, name, notes, its pauses as pause segments), its route with altitude and accuracy, heart rate, speed and the rower's watts as samples, distance and elevation gained as totals. No calories (no weight, no energy model yet); stroke rate has no type there.
    • Reading: the screen lists each app that wrote workouts there (Gadgetbridge, Christopeit Connect), each with its own switch, off, new ones appearing as they write; Train's permissions are per type, the filtering by app its own. As the app opens and comes to the front, through the changes token; nothing in the background. Turning it on asks for the history too, so older than 30 days comes in; refused, the last 30 days only. Each session becomes a workout of Train's own store (D7: sealed, synced), named by its app, with the samples Health Connect holds for it (heart rate, distance, power). Another app's route asks consent per session, so none comes by itself: an imported workout's page offers Bring in the track. An imported one's name and notes can be edited, and it can be deleted, remembered on this phone so it doesn't come back. Doubles: a session mostly overlapping one of Train's own of the same kind isn't added; where Train's has no heart rate, the other's is added to it, otherwise it is left out. Weight and resting heart rate wait for what uses them (calories, zones), with the synced model they need.
    • No migration: an imported workout is a workout, its origin one more field of the sealed, versioned payload.

    Writing built so (28 Oct), with choices of mine for the developer to look at: turning writing off leaves what was written there; a session's title is the workout's name only where the person gave one (Health Connect names it by the app otherwise); the route is the track as the page draws it (the precise fixes while moving), the speed a sample at each of its fixes after a piece's first; steps aren't written, as Train keeps none (the step detector only decides auto-pause); a workout is written again only where its payload's fingerprint changed; a write Health Connect refuses later (the permission taken back there) turns writing off and the screen says so; Android names only the apps the manifest lists (Gadgetbridge, Christopeit Connect, OpenTracks), any other shows by its package; the rationale opens only while someone is signed in. Train needs Android 8 (API 26) now, as connect-client 1.1.0 does. Not yet tried on a phone.

    Reading built so (28 Oct), with choices of mine for the developer to look at: a session another app deletes there stays in Train (Train's store is what counts); a treadmill's run is a run and a stationary bike's ride a ride, any other kind stays out; a double is the same kind overlapping for at least half the shorter of the two, and a session doubling one brought in from another app is left out too (two apps writing the same row); a ride's power isn't kept, having no series of its own yet (only the rower's has); an app turned off later keeps what it brought; a route location without its accuracy counts as precise; Bring in the track shows on every imported workout without one but rows, as whether a route is there isn't kept. Reading runs as the app opens and comes to the front, and an app turned on brings its earlier sessions at once. Not yet tried on a phone.

The Polar Loop (28 Oct 2026, the developer's)

  • D32 The band's own days first (recommended, the developer's; over its live heart rate first, and over its setup and sessions alone): 7a setting up and keeping the band (first-time use from Train, its battery, its clock, its firmware as D10 has it); 7b its daily data (steps, active time, calories, 24/7 heart rate, sleep, Nightly Recharge, skin temperature), new synced, sealed models and a first screen of days and nights; 7c its training sessions as workouts; 7d the Loop as live heart rate in a recording without a strap. All-day tracking is what the Loop is for, and its memory must be read regularly or it fills; a strap gives better heart rate in a workout than the wrist. Through Polar's BLE SDK (8.3.0, its notice kept; from JitPack), which covers the Loop with the Polar 360. Its licence isn't permissive, as this said first: D36.

  • D33 The body's figures in a sealed, synced profile (recommended, the developer's; over this phone alone, and over keeping them nowhere): one synced model, profile, its payload sealed under the person's key as a workout's (D7), holding what the Loop's first-time use needs (sex as Polar's formulas take it, birth date, height, weight, resting and highest heart rate, VO2max, training background, typical day, sleep goal), each optional; a Your body screen edits them, the band's setup asks only for what is missing and offers Polar's defaults (60, 220 less the age, its VO2max table) to the band alone, a blank staying blank in Train; a change reaches the band at its next connection. The same figures serve calories and heart-rate zones later (D31), on every device of the person's. Weight is one current value for now.

  • D34 Setting up and keeping the Loop (the developer's; 28 Oct): profile one row per person, its fields as D33's (sex female or male, birth date, height in cm, weight in kg, resting and highest heart rate, VO2max, Polar's six training backgrounds, typical day sitting, standing or moving, sleep goal 5 to 11 h), each optional, edited on Your body from the account sheet. A Polar Loop row there (Android) opens the band's screen; Set up a Loop opens a sheet looking for Loops nearby, nearest first; the one chosen is connected through Polar's SDK (Android's pairing dialog may show; nearby devices asked first where not granted), set up with the profile (a short form asks for what is missing, Polar's defaults offered to the band alone), its clock set, and remembered in the person's folder (loop.json, not synced: one phone holds it, D3). The band's screen: its name, battery and charging, firmware version, connected or when last seen; Set up again; Let the band go (a factory reset after asking, the only way to pair it elsewhere). The SDK connects while Train is open and the band in reach, each connection setting its clock and sending what changed in the profile; background syncing is decided with 7b. Firmware moves after 7b, as D10 syncs the band before an update erases it. A Kotlin class of MainActivity's on app.halkyon.train/loop, the SDK at 8.3.0 from JitPack (a repository for Polar's package alone, its checksums verified), Polar's notice in the licence list.

  • D35 A changed profile reaches the band when the person says (recommended, the developer's; 28 Oct; over sending it at each connection, as D34 had it, and over only at setup): in Polar's SDK 8.3.0 only the first-time use writes the body's figures (/U/0/S/PHYSDATA.BPB), and it writes a new user identifier each time too (/U/0/USERID.BPB, made from the time); no public call writes the figures alone. What a new identifier does to what the band holds isn't known until it is tried on a real Loop, so the band's screen offers Send to the band once the profile differs from what the band was set up with, and that runs the first-time use again; should it prove harmless, sending can become automatic.

  • D36 Polar's SDK under its own licence, in the one build (the developer's; 28 Oct; over a second Android flavour with the SDK alone, a protocol of Train's own worked out from the band's traffic, and setting the Loop aside): D32 called the SDK's licence permissive, wrongly. Polar_SDK_License.txt (its 8.3.0 tag) is Polar's Software Development Kit Limited License Agreement: the object code may be distributed only together with an app and only to move data between Polar's devices and it (3.1); Polar may end it with 90 days' notice (10.2); neither Polar's name nor its products' may be used in marketing, nor any association implied, without its written consent (4.4); Finnish law. Train's Android app links it all the same, under an added permission of section 7 of the AGPL for that library (apps/train/halkyon_train_flutter/LICENSE-polar-exception.md; LICENSE.md names it); F-Droid's main repository would refuse Train so, and a listing of Train in the project's own repository is to declare NonFreeDep. Its sources on GitHub are under the same agreement, so a protocol of Train's own couldn't be written from them. Polar's licence and the notices of what the SDK bundles (ThirdPartySoftwareListing.txt) are in the licence list, on Android.

    7a built so (28 Oct), with choices of mine for the developer to look at: the band's defaults for a VO2max not given are the middle of the Moderate class of Polar's fitness tables (its manuals', after Shvartz and Reibold 1990), by sex and five-year age rows from 20 to 65, the first row below 20 and the last above 65, since Polar publishes no default of its own; a height above Polar's 240 cm goes to the band as 240 (Your body takes up to 250); the setup asks for sex, birth date, height, weight, training background, typical day and sleep goal where the profile lacks them, each needed, on a page over the sheet, and keeps the answers in Your body; Loops are found by a name beginning Polar Loop; the SDK is made only once a person's app opens on Android, reconnects by itself while the band is remembered, and never asks for its telemetry feature; the clock is set as each connection is ready (and with each setup), a failure only logged; Let the band go keeps the band where it isn't in reach and says so; a band chosen that doesn't connect within 60 s, or refuses, isn't remembered and the search goes on. The SDK's files are pinned by SHA-256 in Gradle's verification-metadata.xml as JitPack served them on 28 Oct, every other group trusted as before. Not yet tried with a Loop.

  • D37 The Loop's days and nights (recommended, the developer's; 28 Oct): what Polar's SDK reads of each day (its summary: steps, distance, calories of activity, training and rest, time in each activity class; its 24/7 heart rate; its sleep with phases, cycles, rating and skin temperature; Nightly Recharge; skin temperature) kept as Train's own models, as workouts are (over the band's raw files sealed as they come, which other devices could read only with Polar's protobuf definitions, under Polar's licence, and over summaries alone): one sealed, synced day per date (summary, Nightly Recharge, skin temperature), one night per sleep (its phases), and the day's heart rate in chunks (day_series, as a workout's series), each versioned JSON, gzipped and sealed. The band is read while Train is open and also in the background (over only while open): a WorkManager job every few hours connects and reads the days since the last read into a plain file of the person's folder (app-private, as recording.txt), which the app seals and syncs as it opens, since only the Dart side holds the person's key. Train deletes nothing on the band (over deleting the days kept), whose memory is taken to roll over by itself until a real band shows. Home gains Days: each day's steps and active time with the night before's sleep and Nightly Recharge, a day's page its heart rate as a chart, the night's phases and its skin temperature. Steps, sleep and heart rate to Health Connect wait (D31 writes workouts). D35 stays: the SDK's low-level writeFile could write the figures alone (PHYSDATA.BPB) without a new user identifier, to be weighed once a real band shows what a new identifier does.

  • D38 What the band holds is deleted once Train keeps it (recommended, the developer's; 28 Oct; revises D37's third point, over deleting a day once it is in the phone's file, and over never deleting): Polar's own sync guideline (the SDK's documentation/SyncImplementationGuideline.md, 8.3.0) asks apps to remove what they synced, as soon as possible, to free the device's memory and not lose data, sensors such as the Polar 360 holding less than watches; D37 had assumed the band's memory rolls over by itself. So once the app has sealed a day (yesterday or earlier) into the person's database, the next read deletes it on the band, type by type (activity, daily summary, 24/7 samples, sleep and its score, Nightly Recharge, skin temperature and skin contact); the background job reads into the file alone and deletes only what the app confirmed kept; today is never deleted. Each read is framed by the SDK's sync start and stop calls, its calls to the band one after the other, as the guideline asks. The VO2max defaults of 7a are Polar's table too: the SDK's documentation/FirstTimeUse.md gives the same tables, without saying which class to take.

    7b built so (28 Oct), with choices of mine for the developer to look at: home has two tabs, Workouts and Days, on every device, Days saying where days come from while there are none; the first read of a band goes four weeks back, later ones from the day after those kept; the band is read as each connection is ready, at most every half hour, and when the person pulls the list; a later read replaces what it brought of a date (its summary, Recharge, skin temperature, heart rate, night) and keeps the rest, and rewrites nothing unchanged; a day's heart rate goes in chunks by the three hours each measurement began in, every row's ID made from the person and the date (https://halkyon.app/train/day/<person>/<date>, …/night/…, …/heart/<index>); a row that doesn't open on a device is left out of Days, as its date is sealed with it; what is kept goes up to the day before the latest read's (keptUntil, in loop.json with the last read's time), kept over a new setup of the same band and forgotten for another; the background job runs every four hours while the battery isn't low and the open app doesn't hold the band, and reads from the same day until the app has kept what it wrote, so the file can hold a day more than once, the last line winning; the app moves the file aside before reading it and deletes it once sealed, so lines written meanwhile wait; the schedule is the phone's, for the last person whose app opened with a band, and Let the band go stops it. Polar's NONREM12 and NONREM3 are shown as light and deep sleep; the chart draws each 24/7 measurement's mean at its start; a night's times show in the phone's time zone. Not yet tried with a Loop: whether the SDK's deletion includes its until day, and whether the job connects to the band in the background, are for a real band.

  • D39 The Loop's training sessions are workouts (recommended, the developer's; 28 Oct): the band records a session by itself when it detects one (at least ten minutes of raised heart rate and movement, saved as Other indoor unless the person picks a sport; Polar's manual, Automatic training detection), which Polar's SDK reads from the band (PolarTrainingSessionApi: a summary with start, duration, sport, calories, distance and ascent, and samples at an interval: heart rate, and speed, distance and cadence where the band had them). Each becomes a sealed, synced workout, its heart rate its series, its origin Polar Loop with the band's reference of the session, as Health Connect's imports are (D31), so there is one list and one page, written to Health Connect with the rest (over a model of its own under Days). Train gains an activity Other workout for what it doesn't know, which the person can change on the workout's page; Polar's running (sport 1), cycling (2) and other outdoor (16, as Other) are the IDs the SDK itself names, every other sport comes as Other until a band shows its IDs (over leaving out what Train doesn't know, most of the band's sessions). A session overlapping a workout Train recorded for at least half the shorter, whatever the sports, is that workout's double: the recorded one stays and gains the band's heart rate where it has none (over keeping both). Sessions are read with the days, by the open app and the background job, and deleted on the band once sealed by the same rule (D38); one deleted in Train is remembered, so it isn't brought back.

    7c built so (28 Oct), with choices of mine for the developer to look at: a multisport session is a workout per exercise, each with the band's reference and its index as its ID; the workout's moving time is the session's length, its distance the band's sum, its series the heart rate alone (speed and cadence samples are read but not kept, as Train has no series for them yet, and a band without GPS has no track); a heart-rate sample of 0 is none; every session the band holds is listed at each read (they are few, as the kept ones go), those from the read's first day on read and those up to the day kept deleted; the sessions taken in are remembered in loop.json by ID with their date, forgotten a week after that date is kept, and a session already kept as a workout (loop.json lost) isn't brought again either; a double is looked for among the workouts Train recorded alone, not those brought in from Health Connect; Other workout can't be recorded on the phone (the record sheet offers it not), and the edit sheet changes any workout's activity. Polar's training sessions are protobuf messages of the SDK's, so the app names protobuf-javalite 3.20.0, the SDK's own, for compiling (compileOnly), adding nothing to the APK. Health Connect's writing takes the Loop's workouts as Train's own, its bring in the track stays for Health Connect's. Not yet tried with a Loop: Polar's other sport IDs, and whether the band keeps samples of a detected session, are for a real band.

  • D40 How D10's firmware updates go (recommended, the developer's; 28 Oct): Polar's SDK asks Polar's firmware service (https://firmware-management.polar.com, POST /api/v1/firmware-update/check with a client name, the band's UUID made from its ID, its firmware version and hardware code; read in the SDK's classes, 8.3.0) and gets a version and a file's address. Train asks once a day, on the day's first connection, and when the person taps Check for updates (over only when asked); an update found shows on the Loop screen with its version and what Polar learns, and never starts by itself. Through the instance, Train's server asks Polar for the person and fetches the file, keeping the latest per hardware and version in memory for a day (over a folder on disk, which the deploy would need a volume for); it offers this by default, for the people signed in, and its operator turns it off with LOOP_FIRMWARE_RELAY=off (over off by default, and over always on), the app then offering the direct way alone. Before an update Train reads and keeps what is on the band; after it, Train sets the band up again at once with the figures it had and sets its clock (over asking first), the band getting a new user identifier as with every setup (D35).

    Built so (28 Oct), with choices of mine for the developer to look at: Train's server has a firmware endpoint (offered, check, download, for the people signed in; LoopFirmwareRefused where it is off, a figure isn't plain, a file isn't known or Polar can't be reached), which names an update only where Polar gives an https file, and keeps a fetched file under its hardware and version until a day after it was fetched. On the phone, the band's date is the phone's today; a band just set up is asked as a connection is; where the person chose the instance, Polar is never asked directly in its place, even when the relay is off or out of reach (the check fails then, and the screen says so), and the switch shows where the instance relays or was chosen. An update through the instance asks the relay again, writes the file into the person's folder (loop_firmware.zip), hands it to the SDK as a file:// address and deletes it afterwards. A read failing before the update leaves the band untouched; after the writing, Train connects again, waits up to five minutes for the band, sets it up with the figures it had (the profile's as they are where none are known) and sets its clock, and takes the version offered as the band's until it tells its own. The screen shows each stage the SDK tells while it writes. Not yet tried with a Loop: whether the version in /DEVICE.BPB (major.minor.patch) is what Polar's service expects, whether the band connects again by itself after its restart, and whether the SDK takes the relayed file as it takes the one it fetches.

  • D41 The Loop's live heart rate in a recording (7d) (recommended, the developer's; 28 Oct): Polar's SDK names live heart rate (beats per minute) among the Loop's and the Polar 360's features (its Polar360.md), with PPI, PPG, acceleration and skin temperature streams. A recording with a Loop remembered takes its heart rate from the strap where one is remembered and switched on, and from the Loop where there is none, or while the strap is lost, so that the gap fills (over the strap alone where remembered, and over choosing one for each recording): a chest strap reads a workout more truly than the wrist (D32). The recording service reads the Loop through Polar's SDK (startHrStreaming), which already holds the band's connection, the SDK moving into a holder the open app and the service share, so that the band stays connected with the screen off and the app gone (over the service's own connection to the band's Heart Rate Service, as for a strap, a second client on the SDK's connection, the two fighting over its notifications). It keeps the heart rate alone, as h lines like the strap's (over pulse-to-pulse intervals too, which nothing uses yet and which cost battery). The Heart rate row names the source.

    Built so (28 Oct), with choices of mine for the developer to look at: Polar's SDK is one for the app's process (PolarSdk.kt), held by the open app's bridge and by a recording reading a Loop, shut down as the last lets go; the background job (D37) leaves the band alone while either holds it, and the app opening again during a recording finds the band connected and is told so rather than connecting anew. The app doesn't disconnect a band a recording reads. The SDK now asks for FEATURE_HR as well. A recording takes the Loop whenever the person has one set up, the strap's switch on the Record sheet switching off the strap alone (so a strap left at home is no gap); the Loop's rate is written only while the strap isn't connected, as h lines, the band's state as loop <time> connecting|connected|lost lines (the file's version stays 1, as strap lines did), and a rate read off the skin is none. The stream is asked for again five seconds after it stops (LoopHeart.kt, tested on the JVM). The screen shows Strap lost only while the Loop isn't sending in its place. A rower's own heart rate is kept only where neither a strap nor a Loop is there. Not yet tried with a Loop: whether its live rate needs the band in a mode of its own, and how far the wrist's rate lags a strap's.

  • D42 Interval plans, and who runs them (recommended, the developer's; 28 Oct): a plan is a name and a list of steps (warm-up, work, recovery, cool-down) with repeat blocks; each step ends after a time, after a distance, or with a lap tap, and may aim at a target: a pace or speed range (from GPS), a heart-rate range in bpm or one of five zones, and on the rower a split, watts or a stroke rate (over pace and heart rate alone). The zones are Polar's and most watches': 50–60, 60–70, 70–80, 80–90 and 90–100 % of the profile's highest heart rate, its default from age where unset, as for the band (over Karvonen's heart-rate reserve, which needs a resting rate). Plans are Train's synced, sealed plan rows, so one made on the desktop reaches the phone (over the person's folder alone), with an editor and a few templates to start from (4×4 min, Tabata 8×20/10 s, 10×400 m; over none). The recording service runs the plan, sent to it as the recording starts: it moves from step to step on its own moving time and distance, pauses stopping a step's clock, and writes step lines to the recording file, which the app reads (over the open app running it, which Android may stop with the screen off). Every step change gives a tone, a vibration and, where the phone has a text-to-speech engine (GrapheneOS ships none; RHVoice or eSpeak from F-Droid give one), a spoken line naming the step, its length and its target; a timed step counts its last three seconds down; off target, a short cue at most every 30 s, switchable per plan (over tones alone, and over no off-target cues). A step on the rower doesn't set its resistance yet (over setting it through FTMS's control point): whether the WP 5000 speaks FTMS is still unknown (D8). Built: the plans, their tab, their editor and the templates (8a, c167e9b9); the service running one (8b). The app sends the run spelled out as the recording starts, each step with its end, its target (a zone as the heart rates it spans for the profile's highest rate, 220 less the age where unset, 180 bpm where neither is given) and its spoken line in the app's language, which the service writes into the file as a plan line with the plan itself; PlanRunner.kt moves through the steps on the moving time and distance, the off-target cues after a step's first 15 s and from a 30 s window of GPS (or the rower's frames, or the last 5 s of heart rate, stroke rate or watts); PlanCues.kt gives the tone, the vibration and the voice, ducking music. The recording screen shows the step, what is left of it, its target, the next step and Lap. Not tried on a phone yet: the voice's timing over music, and how a GPS pace off target feels at 30 s.

  • D43 What an interval workout keeps (recommended, the developer's; 28 Oct): the workout keeps the plan's name and its steps as run, a lap each (when, how long, how far), sealed in its payload; its page lists each step with its time, distance, average pace or speed and heart rate, on the rower its split and watts, marked where it held its target, and shades the charts by step (over laps without targets, and over no trace of the plan).

Consequences

  • Train's sensors are one pattern: open profiles read by the app itself, on the phone; the Loop through Polar's SDK, also on the phone.
  • The recording runs in an Android foreground service from its first version, so that it survives the screen going off.
  • The Loop can't be used with Polar Flow while Train holds it; going back needs a factory reset of the band.
  • The desktop connects to no device and records nothing; it shows and plans, with what the phones recorded coming through the server.