D10's details: Train checks once a day and when asked; the instance relays Polar's firmware service for its people, the files a day in memory, unless its operator sets LOOP_FIRMWARE_RELAY=off; after an update the band is set up again at once with the figures it had. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
759 lines
58 KiB
Markdown
759 lines
58 KiB
Markdown
# ADR 0030: Halkyon Train, what it does and where its data comes from
|
||
|
||
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D40 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) and its sessions (7c, D39) are built; its firmware (D10,
|
||
D40) is being built.
|
||
- **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.
|
||
|
||
## 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).
|
||
|
||
## 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.
|