halkyon/docs/adr/0030-halkyon-train.md

237 lines
17 KiB
Markdown
Raw Normal View History

# ADR 0030: Halkyon Train, what it does and where its data comes from
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D12 below). Nothing is built
yet.
- **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](0031-the-apps-icons.md) (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](0031-the-apps-icons.md)
("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.
## 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.