docs(adr): ADR 0030 (Halkyon Train) and ADR 0031 (the apps' icons)
ADR 0030 records what Train does and where its data comes from: running,
cycling and rowing, in the order recording, intervals, routes, music; the
Polar Loop taken over through Polar's BLE SDK; music from Jellyfin;
devices on phones through flutter_reactive_ble; Health Connect both ways
through the health package; every row sealed; the WP 5000 an FTMS rower,
as scanned; no WaterRower USB driver for now; the Loop's firmware from
Polar directly or through the instance; flutter_map, the instance's map
source, BRouter as a sidecar, offline use and working with CoMaps; and
Train's shell after Todo's.
ADR 0031 gives the apps' icons one family: Lucide glyphs fading from
cobalt through a colour between into each app's own, Learn's graduation
cap, Todo's list, Train's bike, Meditate's circle-dot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 00:43:21 +01:00
|
|
|
|
# ADR 0030: Halkyon Train, what it does and where its data comes from
|
|
|
|
|
|
|
2026-10-29 00:05:39 +01:00
|
|
|
|
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D46 below). The shell (D12)
|
2026-10-28 16:25:28 +01:00
|
|
|
|
is built; recording (D13–D28) is built on Android, iOS waiting for a Mac; Health
|
feat(train): Days, the Loop's days and nights kept sealed (ADR 0030, D37, D38)
What the band read brings, from the bridge or the background job's
file (`LoopLines`), is kept by `DayRepository`: one sealed `day` and
`night` a date and the day's heart rate in chunks of three hours, every
ID made from the person and the date, a later read replacing what it
brought and keeping the rest, nothing rewritten unchanged; versioned,
gzipped JSON (`day_payload.dart`). `PolarLoop` reads the band as each
connection is ready (every half hour at most, or when asked), takes in
the background file as the app opens, and remembers up to which day
the days are sealed, which the next read then deletes on the band;
it keeps the background job on the band and from the right day, and
`Let the band go` stops it.
Home gains a Days tab, each day with its steps, active time, sleep and
Nightly Recharge, pulled to read the band; a day's page shows its
figures, its time in each class, its heart rate, the night's phases
and rating, Nightly Recharge and skin temperature.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 19:53:12 +01:00
|
|
|
|
Connect (D29–D31) is built; the Loop's setup and keeping (7a, D34–D36) and its
|
feat(train): the Loop's firmware updates, directly or through the instance (ADR 0030, D10, D40)
Train's server relays Polar's firmware service for the people signed in
(`firmware`: offered, check, download), a fetched file kept in memory a
day per hardware and version, off with LOOP_FIRMWARE_RELAY=off. The app
asks on the day's first connection and on the band's screen, from Polar
or the instance as the person chose, never Polar in the instance's
place; an update runs only after asking, reads and keeps the band
first, and sets it up again with its figures and clock after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 20:52:53 +01:00
|
|
|
|
daily data (7b, D37, D38), its sessions (7c, D39) and its firmware (D10, D40) are
|
feat(train): a workout keeps its plan's steps as laps, shown on its page (ADR 0030, D43)
As a recording with a plan is saved, each step run becomes a lap in the
workout's sealed payload: the step, its round, when it started and ended,
its moving time and distance, with the highest heart rate the zones ran
with. The page lists the steps with their pace, speed or split, stroke
rate, watts and heart rate, marks whether each held its target, and the
charts shade the work steps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 23:12:34 +01:00
|
|
|
|
built, and its live heart rate (7d, D41); intervals (D42, D43) are built: the plans
|
2026-10-28 23:25:56 +01:00
|
|
|
|
(8a), the service running them (8b) and the laps a workout keeps (8c); routes (D44,
|
|
|
|
|
|
D45) are being built.
|
docs(adr): ADR 0030 (Halkyon Train) and ADR 0031 (the apps' icons)
ADR 0030 records what Train does and where its data comes from: running,
cycling and rowing, in the order recording, intervals, routes, music; the
Polar Loop taken over through Polar's BLE SDK; music from Jellyfin;
devices on phones through flutter_reactive_ble; Health Connect both ways
through the health package; every row sealed; the WP 5000 an FTMS rower,
as scanned; no WaterRower USB driver for now; the Loop's firmware from
Polar directly or through the instance; flutter_map, the instance's map
source, BRouter as a sidecar, offline use and working with CoMaps; and
Train's shell after Todo's.
ADR 0031 gives the apps' icons one family: Lucide glyphs fading from
cobalt through a colour between into each app's own, Learn's graduation
cap, Todo's list, Train's bike, Meditate's circle-dot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 00:43:21 +01:00
|
|
|
|
- **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;
|
2026-11-02 23:09:38 +01:00
|
|
|
|
- **its icon is Lucide's `bike`** (Lucide's `dumbbell` since 2 Nov 2026, ADR 0031, D7), in the family of [ADR 0031](0031-the-apps-icons.md)
|
docs(adr): ADR 0030 (Halkyon Train) and ADR 0031 (the apps' icons)
ADR 0030 records what Train does and where its data comes from: running,
cycling and rowing, in the order recording, intervals, routes, music; the
Polar Loop taken over through Polar's BLE SDK; music from Jellyfin;
devices on phones through flutter_reactive_ble; Health Connect both ways
through the health package; every row sealed; the WP 5000 an FTMS rower,
as scanned; no WaterRower USB driver for now; the Loop's firmware from
Polar directly or through the instance; flutter_map, the instance's map
source, BRouter as a sidecar, offline use and working with CoMaps; and
Train's shell after Todo's.
ADR 0031 gives the apps' icons one family: Lucide glyphs fading from
cobalt through a colour between into each app's own, Learn's graduation
cap, Todo's list, Train's bike, Meditate's circle-dot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 00:43:21 +01:00
|
|
|
|
("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.
|
|
|
|
|
|
|
2026-10-28 11:04:53 +01:00
|
|
|
|
## 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.
|
feat(train): a recording's file read and saved as a workout (ADR 0030, D18, D20)
The recorder's file is text, one sample a line (D18): positions with
their accuracy, heart rates, steps, pauses and the end. Reading it drops
a last line cut short and skips any line it can't read; a file without
its end is a recording cut off, which ends at its last sample.
A recording becomes its workout and series: every fix kept with its
accuracy (D20), the distance through those within 20 m and never across
a pause, the moving time without the pauses (D16), each kind of sample
in chunks of ten minutes (D14). D18 to D20 are recorded in ADR 0030.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 12:31:09 +01:00
|
|
|
|
- **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.
|
feat(train): recording a workout on Android (ADR 0030, D13, D16, D19, D21)
RecorderService, a foreground service of the types location and health
started from the open app, takes fixes from LocationManager's GPS and
steps from the step detector, pauses by itself (AutoPause, tested on the
JVM) and appends each sample to the person's recording file, flushed
every five seconds. MainActivity asks for location, notifications and,
for auto-pause on foot, physical activity, never background location
(D19), and bridges the service to the app.
The app follows the file's lines with RecordingBloc and shows the moving
time, the distance, the pace or speed and GPS's state; the notification
shows the time and the distance with Pause or Resume. Stop is held down;
a stopped recording is saved as a workout and its series, or discarded
after asking, and one cut off or left unsaved is offered as the app
opens. Home has the Record button where the device records.
Three choices of the implementation's are recorded under D21 for review:
rowing waits for 5f, auto-pause on foot without steps goes by GPS speed
(below 2 km/h, on above 3 km/h), and Stop's hint shows above the button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 13:24:56 +01:00
|
|
|
|
- **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.
|
2026-11-01 23:36:41 +01:00
|
|
|
|
**Opened from Train's frame** (1 Nov 2026, the developer's report: an indoor row
|
|
|
|
|
|
started, its notification showed, its screen didn't): a recording started opens its
|
|
|
|
|
|
screen from `TrainShell`, as the recording's phase turns from idle or cut off to
|
|
|
|
|
|
recording, not from the Record tab's card, which had pushed it only after the
|
|
|
|
|
|
service had started and gave up silently where it was gone by then; one found
|
|
|
|
|
|
running as the app opens leaves the tabs as they were. The notification tapped opens
|
|
|
|
|
|
the recording's screen (`RecordingOpenings.kt`, `RecorderPlatform.openings`), not
|
|
|
|
|
|
merely the app, and never a second over the first.
|
2026-10-28 13:42:28 +01:00
|
|
|
|
- **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
|
feat(train): the workouts listed, and each one's page with its map (ADR 0030, D22-D24)
Home lists the person's workouts newest first under their months, each
with its activity's icon, its name (else the time of day's and the
activity's), when it started, its distance, moving time and pace or
speed; those this device can't open come last, locked. A workout's page
shows its track on the instance's map source (flutter_map with
vector_map_tiles, pinned at 9.0.0-beta.13), in pieces between its
pauses, start and end marked; its totals with the elevation gained;
its splits per kilometre on foot, per 5 km cycling; pace or speed and
elevation over the distance, drawn by the app's own painter; and what
the map's source sees. Its name and notes are edited there; deleting
asks first and can be undone for a moment from home (halkyon_app's
DelayedDeletions, TrainPerson.deletions).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 14:29:39 +01:00
|
|
|
|
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.
|
feat(train): the heart-rate strap, recorded by the service itself (ADR 0030, D25-D27)
The Record sheet gains a Heart rate row: a sheet looks for straps
advertising the Heart Rate Service, the nearest first, once nearby
devices are allowed, and the one chosen is remembered in the person's
folder; it records with every workout unless switched off for one.
The recorder service connects to it with Android's own Bluetooth API,
no package (D26), parses Heart Rate Measurement in Kotlin (tested on
the JVM), writes each rate and the strap's state to the recording
file, and asks for a lost strap again for as long as it records. The
recording screen shows the heart rate, and "Strap lost" while it is
gone; a workout's page shows the heart's average and highest rate,
each split's average and its chart.
iOS's recording waits for a Mac (D25), after the rower.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 15:50:36 +01:00
|
|
|
|
- **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.
|
feat(train): rowing on the WP 5000 over FTMS (ADR 0030, D28)
Rowing indoors is offered again, with the rower chosen on the Record
sheet: a sheet looks for Fitness Machine Service rowers, and the one
chosen is remembered in the person's folder. The recorder service
connects to it as to the strap (one NotifyLink for both), parses
FTMS's Rower Data in Kotlin (tested on the JVM on frames written from
the specification and D8's), counts its totals from its first frame,
pauses 5 s after the last stroke, and gets the connectedDevice type. A
row's screen shows its metres, split per 500 m, stroke rate and watts;
its page its average split, stroke rate and power, the highest power,
its strokes, splits per 500 m and charts of the split, stroke rate and
power. Not yet tried on the WP 5000.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 16:20:37 +01:00
|
|
|
|
- **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.
|
2026-10-28 11:04:53 +01:00
|
|
|
|
|
2026-10-28 16:25:28 +01:00
|
|
|
|
## 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.
|
2026-10-28 16:27:12 +01:00
|
|
|
|
- **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).
|
2026-10-28 16:33:43 +01:00
|
|
|
|
- **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.
|
2026-10-28 16:25:28 +01:00
|
|
|
|
|
feat(train): workouts written to Health Connect (ADR 0030, D31)
A Health Connect row in the account sheet opens its screen: Write my
workouts, off until turned on, Health Connect asking as it is. Then
every workout this phone holds is written there (its session with its
pauses and route, heart rate, speed and the rower's watts as samples,
distance and elevation gained), and written again or deleted as it
changes, here or through a sync, and as the app opens. Client IDs come
from the workout's ID, a version counts up with each change; what was
written is kept in the person's health_connect.json, never synced. A
permission taken back in Health Connect turns writing off and says so;
Health Connect's link to Train's rationale opens a screen of its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 17:06:31 +01:00
|
|
|
|
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.
|
|
|
|
|
|
|
feat(train): other apps' workouts read from Health Connect (ADR 0030, D31)
Read from other apps, off until turned on, asks for reading with the
history; the apps that wrote sessions are listed, each off. One turned
on brings its sessions, earlier ones at once and later ones through the
changes token as the app opens and comes to the front: each a workout
of Train's own store, sealed and synced, its origin in its payload, its
heart rate, pauses and a row's power as series, named by its app in the
list and on its page. A double of a workout Train has brings at most its
heart rate; one deleted in Train stays out; a track comes only when
asked for on the page, Health Connect asking for consent. What came from
Health Connect is never written back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 17:17:59 +01:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-10-28 17:41:47 +01:00
|
|
|
|
## 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.
|
feat(train): setting up and keeping the Polar Loop (ADR 0030, D34–D36)
On Android, the Loop through Polar's BLE SDK 8.3.0 (from JitPack, asked
for Polar's group alone, its files pinned by SHA-256 in Gradle's
verification metadata): LoopBridge.kt over app.halkyon.train/loop, the
first-time use's figures mapped and checked in LoopSetup.kt, tested on
the JVM. PolarLoop per person remembers the band in loop.json, connects
while the app is open, sets its clock at each connection, keeps what it
tells (battery, charging, firmware), offers a changed profile and sends
it only when asked (D35: first-time use writes a new user identifier
each time), and lets the band go by a factory reset after asking. The
setup sheet finds Loops nearest first and asks only for what the
profile lacks; Polar's defaults (60, 220 less the age, a VO2max from
Polar's fitness tables) go to the band alone.
Polar's SDK is not free software (D36, correcting D32): the app's AGPL
gains a section 7 permission for linking it, LICENSE.md names it, and
its licence and bundled notices join the licence list on Android.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 18:58:02 +01:00
|
|
|
|
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.
|
2026-10-28 17:43:59 +01:00
|
|
|
|
- **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.
|
2026-10-28 17:45:50 +01:00
|
|
|
|
- **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.
|
2026-10-28 17:41:47 +01:00
|
|
|
|
|
feat(train): setting up and keeping the Polar Loop (ADR 0030, D34–D36)
On Android, the Loop through Polar's BLE SDK 8.3.0 (from JitPack, asked
for Polar's group alone, its files pinned by SHA-256 in Gradle's
verification metadata): LoopBridge.kt over app.halkyon.train/loop, the
first-time use's figures mapped and checked in LoopSetup.kt, tested on
the JVM. PolarLoop per person remembers the band in loop.json, connects
while the app is open, sets its clock at each connection, keeps what it
tells (battery, charging, firmware), offers a changed profile and sends
it only when asked (D35: first-time use writes a new user identifier
each time), and lets the band go by a factory reset after asking. The
setup sheet finds Loops nearest first and asks only for what the
profile lacks; Polar's defaults (60, 220 less the age, a VO2max from
Polar's fitness tables) go to the band alone.
Polar's SDK is not free software (D36, correcting D32): the app's AGPL
gains a section 7 permission for linking it, LICENSE.md names it, and
its licence and bundled notices join the licence list on Android.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 18:58:02 +01:00
|
|
|
|
- **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.
|
|
|
|
|
|
|
2026-10-28 19:13:22 +01:00
|
|
|
|
- **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.
|
|
|
|
|
|
|
2026-10-28 19:52:57 +01:00
|
|
|
|
- **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.
|
|
|
|
|
|
|
feat(train): Days, the Loop's days and nights kept sealed (ADR 0030, D37, D38)
What the band read brings, from the bridge or the background job's
file (`LoopLines`), is kept by `DayRepository`: one sealed `day` and
`night` a date and the day's heart rate in chunks of three hours, every
ID made from the person and the date, a later read replacing what it
brought and keeping the rest, nothing rewritten unchanged; versioned,
gzipped JSON (`day_payload.dart`). `PolarLoop` reads the band as each
connection is ready (every half hour at most, or when asked), takes in
the background file as the app opens, and remembers up to which day
the days are sealed, which the next read then deletes on the band;
it keeps the background job on the band and from the right day, and
`Let the band go` stops it.
Home gains a Days tab, each day with its steps, active time, sleep and
Nightly Recharge, pulled to read the band; a day's page shows its
figures, its time in each class, its heart rate, the night's phases
and rating, Nightly Recharge and skin temperature.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 19:53:12 +01:00
|
|
|
|
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
|
2026-10-30 16:12:00 +01:00
|
|
|
|
hours each measurement began in, every row's ID made from the date under the
|
|
|
|
|
|
account's key (D57: `train/day/<date>`, `train/night/<date>`, `…/heart/<index>`); a
|
feat(train): Days, the Loop's days and nights kept sealed (ADR 0030, D37, D38)
What the band read brings, from the bridge or the background job's
file (`LoopLines`), is kept by `DayRepository`: one sealed `day` and
`night` a date and the day's heart rate in chunks of three hours, every
ID made from the person and the date, a later read replacing what it
brought and keeping the rest, nothing rewritten unchanged; versioned,
gzipped JSON (`day_payload.dart`). `PolarLoop` reads the band as each
connection is ready (every half hour at most, or when asked), takes in
the background file as the app opens, and remembers up to which day
the days are sealed, which the next read then deletes on the band;
it keeps the background job on the band and from the right day, and
`Let the band go` stops it.
Home gains a Days tab, each day with its steps, active time, sleep and
Nightly Recharge, pulled to read the band; a day's page shows its
figures, its time in each class, its heart rate, the night's phases
and rating, Nightly Recharge and skin temperature.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 19:53:12 +01:00
|
|
|
|
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.
|
2026-10-28 19:52:57 +01:00
|
|
|
|
|
2026-10-28 20:03:34 +01:00
|
|
|
|
- **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.
|
|
|
|
|
|
|
feat(train): the Loop's training sessions as workouts (ADR 0030, D39)
Each read of the band lists the sessions it holds, reads those from the
read's first day on (`LoopDays.kt`, one line per exercise: its start
with the band's offset, length, Polar's sport ID, figures and heart
rate at its interval), and deletes those up to the day kept, as the
days. The app keeps each as a workout of origin Polar Loop with its
heart rate (`takeLoopSessions`), Polar's running and cycling as
Train's and every other sport as the new `Activity.other`, which a
workout's edit sheet now changes; a session overlapping a workout Train
recorded gives it its heart rate where it has none instead. The
sessions taken are remembered in `loop.json`, so one deleted in Train
isn't brought back. Health Connect writes the Loop's workouts as
Train's own.
The SDK gives sessions as its protobuf messages: protobuf-javalite
3.20.0, the SDK's own, is named for compiling only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 20:22:23 +01:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-10-28 20:29:04 +01:00
|
|
|
|
- **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).
|
|
|
|
|
|
|
feat(train): the Loop's firmware updates, directly or through the instance (ADR 0030, D10, D40)
Train's server relays Polar's firmware service for the people signed in
(`firmware`: offered, check, download), a fetched file kept in memory a
day per hardware and version, off with LOOP_FIRMWARE_RELAY=off. The app
asks on the day's first connection and on the band's screen, from Polar
or the instance as the person chose, never Polar in the instance's
place; an update runs only after asking, reads and keeps the band
first, and sets it up again with its figures and clock after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 20:52:53 +01:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-10-28 21:32:13 +01:00
|
|
|
|
- **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.
|
|
|
|
|
|
|
feat(train): interval plans, synced and sealed, with an editor and templates (ADR 0030, D42)
Train's server gains the synced `plan` table, a sealed payload alone;
the migration `plans` only adds it, its space index and the foreign key
to its sync space, and a person's deletion takes it with their space.
The app keeps plans as `plan` rows sealed for their row: a name, what
they are for, whether a step off target is told, and blocks of steps
repeated, each ending after a time, a distance or a lap tap and aiming
at a pace, speed, heart rate or zone, or the rower's split, watts or
stroke rate. Home's Plans tab lists them, a new one starts from nothing
or from a template (4×4 minutes, Tabata, 10×400 m), the editor changes
steps on a sheet, and a deletion asks first and can be undone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 22:35:22 +01:00
|
|
|
|
- **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).
|
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-28 22:59:48 +01:00
|
|
|
|
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.
|
feat(train): interval plans, synced and sealed, with an editor and templates (ADR 0030, D42)
Train's server gains the synced `plan` table, a sealed payload alone;
the migration `plans` only adds it, its space index and the foreign key
to its sync space, and a person's deletion takes it with their space.
The app keeps plans as `plan` rows sealed for their row: a name, what
they are for, whether a step off target is told, and blocks of steps
repeated, each ending after a time, a distance or a lap tap and aiming
at a pace, speed, heart rate or zone, or the rower's split, watts or
stroke rate. Home's Plans tab lists them, a new one starts from nothing
or from a template (4×4 minutes, Tabata, 10×400 m), the editor changes
steps on a sheet, and a deletion asks first and can be undone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 22:35:22 +01:00
|
|
|
|
- **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).
|
feat(train): a workout keeps its plan's steps as laps, shown on its page (ADR 0030, D43)
As a recording with a plan is saved, each step run becomes a lap in the
workout's sealed payload: the step, its round, when it started and ended,
its moving time and distance, with the highest heart rate the zones ran
with. The page lists the steps with their pace, speed or split, stroke
rate, watts and heart rate, marks whether each held its target, and the
charts shade the work steps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 23:12:34 +01:00
|
|
|
|
Built (8c): the laps are worked out as the recording is saved, from the file's
|
|
|
|
|
|
`step` lines, each step's moving time and distance counted as the workout's are
|
|
|
|
|
|
(a step cut short by Stop is kept as far as it went, one the plan never reached
|
|
|
|
|
|
isn't); the highest heart rate the zones ran with is kept beside them, so that a
|
|
|
|
|
|
zone held is judged on the rates of that day. A step held its target where its
|
|
|
|
|
|
average (its pace, speed or split over its moving time and distance, its heart
|
|
|
|
|
|
rate, stroke rate or watts) lies within it. The charts shade the work steps.
|
|
|
|
|
|
Health Connect gets the session as before, without laps for now.
|
2026-10-28 23:25:56 +01:00
|
|
|
|
- **D44 Planning a route** (recommended, the developer's, but the region; 28 Oct):
|
|
|
|
|
|
waypoints are tapped on the map, dragged, the last one undone, the first where the
|
|
|
|
|
|
phone is; searching for places comes later, as a Photon sidecar of its own (Apache-2.0;
|
|
|
|
|
|
over Photon now, Germany's database alone a 7.5 GB download and a few GB of RAM, and
|
|
|
|
|
|
over komoot's public instance, which sees the places searched and promises nothing).
|
|
|
|
|
|
The instance's BRouter and PMTiles sidecars cover **Europe** (the developer's choice,
|
|
|
|
|
|
over Germany alone, recommended for its few GB, and over Germany, Austria and
|
|
|
|
|
|
Switzerland): tens of GB of segments and tiles on the server, refreshed monthly; the
|
|
|
|
|
|
sizes are written into the deploy's notes when it is built. Six profiles: Run, a
|
|
|
|
|
|
profile Train writes (paths, parks and quiet roads first, any surface; BRouter has
|
|
|
|
|
|
none for running), Hike (`hiking-mountain`), Road bike (`fastbike`), Trekking bike
|
|
|
|
|
|
(`trekking`), Gravel (`gravel`) and MTB (`mtb`), the route's activity choosing one by
|
|
|
|
|
|
default (over BRouter's alone, its hiking profile sending runners up steps, and over
|
|
|
|
|
|
one profile an activity). As it is planned, a route shows its line, its distance, its climb and
|
|
|
|
|
|
descent, an elevation profile under the map and the surfaces it runs on as BRouter
|
|
|
|
|
|
tells them (over distance and climb alone, and over loops of a given length, which
|
|
|
|
|
|
BRouter can't make and Train would only guess at).
|
feat(train): routes kept as sealed rows, planned on the map (ADR 0030, D44)
Routes are synced `route` rows (the model `SavedRoute`, as Flutter has a
`Route`), sealed as plans are; the migration `routes` only creates the
table, its space index and its cascade to the sync engine's spaces. A
route's payload holds its name, activity, profile, waypoints and the way
planned, points and elevations delta-coded and gzipped.
Home gets a fourth tab, Routes, and the planner: waypoints tapped on the
map, held and dragged, the last undone, a new route's first where the
phone is (GPS through the recorder's channel), the way planned by the
instance's BRouter after each change, an earlier answer dropped; its
distance, climb, descent, elevation and surfaces under the map.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-29 00:37:44 +01:00
|
|
|
|
Built: the `routing` endpoint and the deploy (9a, `ab128550`, `7fcb91c6`); routes
|
|
|
|
|
|
as synced, sealed `route` rows and the planner (9b). A route's payload holds its
|
|
|
|
|
|
name, activity, profile, waypoints and the way planned (points to a micro-degree
|
|
|
|
|
|
and elevations to a decimetre, each delta-coded, its turns, its stretches),
|
|
|
|
|
|
gzipped as a workout's series are, since a planned route has thousands of points.
|
|
|
|
|
|
The server's model is `SavedRoute` on the table `route`, since Flutter's widgets
|
|
|
|
|
|
have a `Route` of their own. Home's fourth tab lists the routes; the planner is the
|
|
|
|
|
|
map above and the route's figures under it: a tap adds a waypoint, one held and
|
|
|
|
|
|
dragged moves it (held first, so that dragging the map never moves one), the
|
|
|
|
|
|
button above the map takes the last off, and the instance plans the way after each
|
|
|
|
|
|
change, an answer to an earlier change dropped once a later one is asked. A new
|
|
|
|
|
|
route starts where the phone is: the recorder's channel asks GPS for its last fix
|
|
|
|
|
|
of the past two minutes or a new one (15 s at most), the location permission asked
|
|
|
|
|
|
first (no package, as recording takes no other); on a device that can't tell it
|
|
|
|
|
|
starts empty. Surfaces are grouped from OpenStreetMap's tags as paved, compacted,
|
|
|
|
|
|
gravel, natural ground and not known. An instance without BRouter says so on the
|
|
|
|
|
|
planner.
|
2026-10-28 23:25:56 +01:00
|
|
|
|
- **D45 Following a route** (recommended, the developer's; 28 Oct): the recording
|
|
|
|
|
|
screen keeps its figures first and swipes to a map with the route, the track so far
|
|
|
|
|
|
and the position, north or heading up as tapped, with a route or without (over the
|
|
|
|
|
|
map always above the figures, and over no map). The recording service guides,
|
|
|
|
|
|
offline, as it runs a plan: BRouter's turn hints spoken about 50 m before a turn (on
|
|
|
|
|
|
a bike about 100 m) after the tone, the next turn shown; more than 40 m off the route
|
|
|
|
|
|
for 10 s, a cue and the way back (the direction and distance to the route's nearest
|
|
|
|
|
|
point), told again on rejoining (over cues off route alone, and over rerouting by
|
|
|
|
|
|
itself online). Maps offline along a route on request: *Keep offline* downloads its
|
|
|
|
|
|
corridor, 2 km each side at zooms 10 to 16, on that device alone, until switched
|
|
|
|
|
|
off (over every route's on every phone, and over none yet). Routes go out as GPX
|
|
|
|
|
|
(to CoMaps or anything, with waypoints and turn hints) and come in as GPX shared to
|
|
|
|
|
|
Train or opened in it, snapped to a profile or kept as drawn (over out alone, and
|
|
|
|
|
|
over neither yet).
|
2026-10-29 08:27:27 +01:00
|
|
|
|
Built: following in a recording (9c). The Record sheet offers the routes with a
|
|
|
|
|
|
way planned, for an activity with GPS, and a route for an activity chooses it, as a
|
|
|
|
|
|
plan does. The app hands the service the route's points, its turns each with its
|
|
|
|
|
|
line in the app's language ("In 50 metres: Turn left"; BRouter's beelines and
|
|
|
|
|
|
off-route marks aren't turns, and are left out), and the words off the route with
|
|
|
|
|
|
places for the figures, through a file beside the app's (`route.json`), since an
|
|
|
|
|
|
intent carries less than a route's thousands of points. `RouteGuide.kt` places each
|
|
|
|
|
|
fix precise enough on the route (among its next 500 m and last 100 m first, the
|
|
|
|
|
|
whole route where none is near, so that a shortcut or a figure eight finds its
|
|
|
|
|
|
place), none while paused; a turn is told once, 50 m (100 m on a bike) along the
|
|
|
|
|
|
route before it, a tone first and the line 0.4 s after; one passed unseen is
|
|
|
|
|
|
skipped. Off the route: past 40 m for 10 s, a double vibration and the way back
|
|
|
|
|
|
("It lies 80 metres to the north-east", to 10 m, to the nearest point), again
|
|
|
|
|
|
every minute while still off (a runner off for long would otherwise hear it once),
|
|
|
|
|
|
told again once back within 30 m (so that a fix wavering about 40 m doesn't cue
|
|
|
|
|
|
over and over); no turn is told while off. The end is told once within 30 m of it.
|
|
|
|
|
|
The file keeps the route (`route`, its payload) and what following it gave
|
|
|
|
|
|
(`guide … next|turn|off|on|arrived`), so a recording the app comes back to shows it
|
|
|
|
|
|
again. The recording screen shows the next turn with how far it is as the crow
|
|
|
|
|
|
flies (the service places the person, the app only reads its lines), or the way
|
|
|
|
|
|
back; it swipes to a map of the route, the track so far (the fixes precise enough
|
|
|
|
|
|
to count) and the position, which the map follows, zoomed with two fingers alone
|
|
|
|
|
|
so that a swipe goes back to the figures, north up by default and the heading up
|
|
|
|
|
|
(of the last 5 m or more) as tapped. A saved workout doesn't keep which route it
|
|
|
|
|
|
followed yet.
|
2026-10-29 10:28:36 +01:00
|
|
|
|
Built: maps offline (9d's first half). A saved route's page has *Keep the map
|
|
|
|
|
|
offline*; on, `OfflineMaps` (in the person's folder, `offline_maps/`, never synced)
|
|
|
|
|
|
asks the source for every tile within 2 km of the way (`corridorTiles`: the tiles
|
|
|
|
|
|
under a square of 2 km each way around a point every 250 m, a band at least 2 km
|
|
|
|
|
|
either side) at zooms 10 to 16, but never above the zoom the source has (OpenFreeMap
|
|
|
|
|
|
has 14, the map overzooms from there), four at a time, a tile shared by two routes
|
|
|
|
|
|
kept once and deleted only once neither keeps it; the page counts them as they come
|
|
|
|
|
|
and then says how much the device holds, and the list marks the route. The style,
|
|
|
|
|
|
its sources' TileJSON and its sprites are kept each time they are read, and the
|
|
|
|
|
|
instance's map source too (`map_source.json`), so that a map draws with no network
|
|
|
|
|
|
at all; the map reads a kept tile from the device before the network
|
|
|
|
|
|
(`keptVectorTiles`), which is why the app now builds vector_map_tiles' style itself
|
|
|
|
|
|
(with vector_tile_renderer's readers): its `StyleReader` reads only from the
|
|
|
|
|
|
network. A tile that didn't come is counted and asked for again the next time the
|
|
|
|
|
|
app opens online, and one the source has none of (204, 404: the sea) is nothing
|
|
|
|
|
|
missing. A kept route that changes, here or on another device, or whose map source
|
|
|
|
|
|
changes, is kept again along its new way, the old way's tiles deleted; one deleted
|
|
|
|
|
|
is let go; one that doesn't open on this device stays. Only a saved route can be
|
|
|
|
|
|
kept, since it is the saved way that is kept. Not done: a size limit or a warning
|
|
|
|
|
|
before a long route's download (a 200 km ride at OpenFreeMap's zooms is some 700
|
|
|
|
|
|
tiles, a few tens of MB), and keeping a map only on Wi-Fi.
|
2026-10-29 10:44:47 +01:00
|
|
|
|
Built: GPX out and in (9d's second half). A route's page hands it on as GPX 1.1
|
|
|
|
|
|
(`gpx.dart`): on Android to the share sheet (`RouteFiles.kt`, through a FileProvider
|
|
|
|
|
|
that shares only the one file written for it, in the cache's `shared/`), elsewhere
|
|
|
|
|
|
saved where the person chooses (halkyon_app's `FileSaver`); its way as a track with
|
|
|
|
|
|
the elevations, its waypoints as `wpt` of the type `waypoint` and its turns as `wpt`
|
|
|
|
|
|
of the type `turn`, named in the app's language (beelines left out), which CoMaps
|
|
|
|
|
|
and others show as points along the track. Routes come in from *Bring in a GPX
|
|
|
|
|
|
file* in the Routes tab (file_selector's dialog, as Learn's) and, on Android, from a
|
|
|
|
|
|
GPX file opened with Train or shared to it (by the type `application/gpx+xml` where
|
|
|
|
|
|
the sender names it, by a name ending `.gpx` where it says only that it is a file;
|
|
|
|
|
|
at most 20 MB, a file arriving before the app listens kept until it does). Any
|
|
|
|
|
|
version of GPX is read: the first track, its segments joined, else the first
|
|
|
|
|
|
route; elevations only where every point has one. The person then chooses: *Plan
|
|
|
|
|
|
it along the paths*, through Train's own waypoints where the file is Train's, a
|
|
|
|
|
|
route's points where it has a track as well, else up to 50 points spread along the
|
|
|
|
|
|
way at least a kilometre apart, planned at once with the running profile (changed
|
|
|
|
|
|
on the page, which plans it again); or *Keep it as drawn*, the file's way with its
|
|
|
|
|
|
distance and climb worked out as a recording's (climbs above 3 m), no turns told.
|
|
|
|
|
|
Either opens as a new route to be named and saved; a drawn route that is changed
|
|
|
|
|
|
is planned along the paths from then on. A file without a route says so.
|
2026-10-29 00:05:39 +01:00
|
|
|
|
- **D46 Train's server on the instance** (recommended, the developer's; 28 Oct): it
|
|
|
|
|
|
joins learn.halkyon.eu's server, as BRouter needs a server of Train's to ask it
|
|
|
|
|
|
(D11, D22), at **train.halkyon.eu** (over a path on learn.halkyon.eu, which would
|
|
|
|
|
|
carry Learn's name for good); its data in the instance's PostgreSQL as a database of
|
|
|
|
|
|
its own, `halkyon_train`, under a role that can't connect to Learn's (over a second
|
|
|
|
|
|
PostgreSQL, twice the memory and a second backup); a **member of the instance's
|
|
|
|
|
|
register** on Learn's server (ADR 0016: one account a person across the operator's
|
|
|
|
|
|
services, over a register of its own). It is a compose file of its own,
|
|
|
|
|
|
`deploy/compose.train.yaml`, switched on in `.env` as the sidecars are, so nothing
|
|
|
|
|
|
changes on the instance until the operator does (the steps in
|
|
|
|
|
|
`deploy/sidecars/README.md`, *Train*); `redeploy.sh` then builds, dumps, migrates
|
|
|
|
|
|
and waits for it as for Learn's server, and `backup.sh` backs its database up in the
|
|
|
|
|
|
same snapshots. BRouter (v1.7.10, its release checked by SHA-256) runs beside Train's
|
|
|
|
|
|
server alone, on a network without a way out, with no log (it writes the waypoints),
|
|
|
|
|
|
its segments refreshed monthly by a container of its own; Europe's took 3.3 GB on
|
|
|
|
|
|
28 Oct. Whether the server's disk has room is the developer's to check before
|
|
|
|
|
|
switching it on. The map stays OpenFreeMap's: the PMTiles sidecar needs a style for
|
|
|
|
|
|
Protomaps' schema, a step of its own (about 40 GB for Europe, estimated from the
|
|
|
|
|
|
planet's 120 GB). Learn's server signs in to PostgreSQL as its superuser, so it could
|
|
|
|
|
|
read Train's database; a role of its own for it is left for later. restic now forgets
|
|
|
|
|
|
snapshots grouped by host alone, so that the snapshots before Train's dump joined
|
|
|
|
|
|
them aren't kept for good as a group of their own.
|
2026-10-29 12:36:32 +01:00
|
|
|
|
- **D47 Music from Jellyfin** (recommended, the developer's; 29 Oct; D4's how):
|
|
|
|
|
|
- **Signing in**: with Jellyfin's Quick Connect (Train shows a code, the person
|
|
|
|
|
|
approves it in another Jellyfin client, Train gets a token of its own), else with
|
|
|
|
|
|
the user name and password where the server has Quick Connect off (over the
|
|
|
|
|
|
password alone, which always passes through Train, and over an API key, which only
|
|
|
|
|
|
an administrator makes and which carries an administrator's rights).
|
|
|
|
|
|
- **Which tracks**: those of the Jellyfin playlists the person marks for running in
|
|
|
|
|
|
Train's settings (over the whole library, hours of downloads and analysis on a large
|
|
|
|
|
|
one, and over genres, only as good as the library's tags).
|
|
|
|
|
|
- **Kept on this phone only**: the token in secure storage, the server's address, the
|
|
|
|
|
|
marked playlists and each track's tempo in a file of the person's on the device,
|
|
|
|
|
|
nothing on Train's server (over sealed, synced rows, a synced model and its
|
|
|
|
|
|
migration so that a second phone skips the analysis; music plays on phones only,
|
|
|
|
|
|
D5). A second phone signs in and analyses again.
|
|
|
|
|
|
- **Offline**: the marked playlists' tracks are downloaded on unmetered networks only
|
|
|
|
|
|
and kept up with the playlists; the settings show their size and delete them (over
|
|
|
|
|
|
fetching what a run needs as it starts, which makes the start wait on the network
|
|
|
|
|
|
and a free run guess its length, and over streaming, which D4 ruled out).
|
|
|
|
|
|
- **Tempo by Train's own analysis, in Kotlin** (over aubio, GPL-3 C, which needs a
|
|
|
|
|
|
native build per ABI; TarsosDSP, GPL-3 Java, unmaintained since 2021; Essentia,
|
|
|
|
|
|
AGPL-3 C++, tens of MB and a heavy build): Android's decoder reads a minute of the
|
|
|
|
|
|
downloaded track, an onset envelope and its autocorrelation give the beats per
|
|
|
|
|
|
minute, tested on the JVM with beats of known tempo. A BPM tag the file carries is
|
|
|
|
|
|
taken first.
|
|
|
|
|
|
- **Played by Media3's ExoPlayer in the recording service** (Apache-2.0, AndroidX;
|
|
|
|
|
|
over just_audio and audio_service in Flutter, whose engine may stop with the
|
|
|
|
|
|
activity and whose player would talk to the service's cues and plan runner across
|
|
|
|
|
|
the channel): beside the cues, which duck it, and the plan runner, it plays on with
|
|
|
|
|
|
the screen off and picks the next track there.
|
|
|
|
|
|
- **Cadence followed**: the one the step detector measures, smoothed over 30 s,
|
|
|
|
|
|
without a plan and for a step aiming at heart rate or the like; for a step aiming
|
|
|
|
|
|
at a pace, the cadence the person's own past runs show at that pace (steps against
|
|
|
|
|
|
speed), the measured one until three runs show it (over the measured cadence alone,
|
|
|
|
|
|
which never pulls the person towards the step's pace, and over a cadence set by
|
|
|
|
|
|
hand). A track fits at its tempo, its half or its double, sped up or slowed down by
|
|
|
|
|
|
at most **6 %** with the pitch kept (over 10 %, where voices start to sound off, and
|
|
|
|
|
|
over none, which needs a large playlist). A metronome at the cadence plays where no
|
|
|
|
|
|
library is set (D4).
|
|
|
|
|
|
- In parts: 10a signing in to Jellyfin and marking playlists in the settings; 10b
|
|
|
|
|
|
downloading the marked tracks and working out their tempo; 10c playing them in a
|
|
|
|
|
|
recording, by cadence, and the metronome.
|
|
|
|
|
|
Built: 10a. *Music* in the account sheet, on Android: the server's address (https
|
|
|
|
|
|
where none is typed; a path kept, as for a server under `/jellyfin`), then Quick
|
|
|
|
|
|
Connect's code, asked about every 3 s and given up after 9 minutes (Jellyfin forgets
|
|
|
|
|
|
a request after ten), or *Use a password*, which a server with Quick Connect off
|
|
|
|
|
|
asks for at once; Quick Connect as Jellyfin 10.9 and later have it. Train signs in
|
|
|
|
|
|
as the client "Halkyon Train" with a device ID of its own, made once and kept, so
|
|
|
|
|
|
that the server lists it as a device of the person's. The token is in the keyring
|
|
|
|
|
|
under the person's ID, and the tokens of people the device no longer keeps are
|
|
|
|
|
|
forgotten as the library opens; a token Jellyfin takes back signs the person out
|
|
|
|
|
|
on the phone too. Signing out ends the token on the server where it answers. The
|
|
|
|
|
|
person's playlists of music are listed (a playlist naming no type counts as one,
|
|
|
|
|
|
one of videos doesn't) and marked, the marked ones by ID and name in `music.json`.
|
2026-10-29 12:48:53 +01:00
|
|
|
|
Built: 10b. The marked playlists' tracks are kept in the person's `music/`, each
|
|
|
|
|
|
once whatever the playlists holding it, as Jellyfin keeps the file (`/Audio/…/stream`
|
|
|
|
|
|
with `static`, no transcoding; a smaller copy on request is left for when a library
|
|
|
|
|
|
shows the need), through a part file renamed once whole. Downloading is the app's
|
|
|
|
|
|
own, while it is open or resumed, where Android calls the network unmetered (its own
|
|
|
|
|
|
verdict, not merely Wi-Fi: a phone's hotspot is metered): the token is in the app's
|
|
|
|
|
|
keyring, which a background job of Android's couldn't read without a copy outside
|
|
|
|
|
|
it. What no marked playlist holds any more is deleted at once, offline too; a track
|
|
|
|
|
|
taken out of its playlist on the server goes at the next sync, a playlist that
|
|
|
|
|
|
can't be read keeps its tracks, a track that fails to download is tried again at
|
|
|
|
|
|
the next. Each track's tempo is worked out once after its download, on the phone
|
|
|
|
|
|
(`MusicBridge.kt`): an ID3v2 `TBPM` frame or a FLAC `BPM` comment where the file has
|
|
|
|
|
|
one (`BpmTag.kt`; from 40 to 250 only), else a minute from the middle decoded by
|
|
|
|
|
|
Android's MediaCodec, mixed down to mono, its spectral flux (about 86 frames a
|
|
|
|
|
|
second) autocorrelated over 50 to 220 beats per minute and weighed towards 120 by
|
|
|
|
|
|
an octave either side (`Tempo.kt`), none where the beat repeats too weakly; tested
|
|
|
|
|
|
on the JVM with kicks of known tempo (within a beat a minute, hi-hats on the
|
|
|
|
|
|
off-beat not doubling it), noise and silence having none, and tags cut short. The
|
|
|
|
|
|
music screen shows what the phone keeps (tracks, size, how many have a tempo, the
|
|
|
|
|
|
wait for an unmetered network) and *Delete*, which after asking unmarks every
|
|
|
|
|
|
playlist. Not tried on a phone yet: decoding real files, how often the analysis
|
|
|
|
|
|
lands an octave off (10c counts half and double as fitting).
|
feat(train): music by cadence in a recording, or the metronome (ADR 0030, D48)
A workout keeps its cadence as a series of its own (steps a minute, every
10 s while moving). The Record sheet offers Music, Metronome or Off on
foot, remembered; Music once a kept track has a tempo. The recorder
measures the cadence over the last 30 s and plays a fitting track (its
tempo, half or double, within 6 %) through Media3's ExoPlayer, retuning
the track playing where it still fits, else crossfading over 3 s; a
plan's new step picks anew. A run starts at the cadence learnt from the
person's runs, which also gives a plan's steps their cadence.
Media3 1.10.1, pinned by Gradle's checksums (trusted on first download
from Google's Maven, 29 Oct 2026), for review:
media3-common aar 39686f54ff6baf8cbc841199afd55619533eaef2ab6cd0efc32b8a3c55dd88c3
module a81b2f0ebc256fe0e09f05d93c5259ade5bf44707748b9295343bee67575d9c2
media3-container aar a198b62c577fa434941282fbfa31eb66ff0ff308a6573edf0b5dd01f4e38445d
module e2a1c62c7e5d1013b906bc6ff5056db3e797cda4505e2d7d229524b24ca67a0e
media3-database aar 520e6f814e42a7d428db187ac073b453c0fcba0f4f5f4edd1cf33fafd2cfb740
module c380fea161e80d8074215a20e86e3dc2c2d825de964ca87aee66c8331f5ae252
media3-datasource aar e22f9f779047ce951152c793af094593e7cbb52475a82d56fa4f869fd319e700
module 9d32d2ec6a983ee9f418bcc05777aa23eee4db37bf794f44fb66a05d6f22e963
media3-decoder aar a9fb2b8fa1d54b4a8d6bda98a1c655b04d59c74b3ace7e2503e9e39830f13aee
module 388d5226366edd1facf8eec42cb5420871a46032d545286e90eaea8454789580
media3-exoplayer aar 19090f829afbbb595d67618f59c212f72b433ac01dc72b47594cf42552c8ed61
module ba0b9ef325fa114baa0f2fbdb4caf54029606924be1999f4af02bed5f079756b
media3-extractor aar 3c823279356b64f4215a8c6cb1e6bfa60c533351a651786ad6fa69ad001ef1b1
module b3eb755d7373e4fd199ff80814005f3b303693dfb8ace16b5b9482655d49d493
Not tried on a phone yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-29 13:31:41 +01:00
|
|
|
|
- **D48 Music in a recording** (recommended, the developer's; 29 Oct; D47's 10c):
|
|
|
|
|
|
- **A workout keeps its cadence** as a series of its own, `cadence` (steps a minute,
|
|
|
|
|
|
a sample every 10 s while moving), sealed and synced as heart rate is; an app
|
|
|
|
|
|
before it skips the kind, so nothing migrates (over each step's time, some 50 KB an
|
|
|
|
|
|
hour for detail nothing needs, and over the total alone, too little to learn
|
|
|
|
|
|
cadence against pace). It is Health Connect's StepsCadenceRecord, to be written
|
|
|
|
|
|
there later.
|
|
|
|
|
|
- **The Record sheet offers Music, Metronome or Off** on foot (running, walking,
|
|
|
|
|
|
hiking), remembered for next time; Music once some kept track has a tempo (over a
|
|
|
|
|
|
switch with the metronome as its fallback, which leaves no metronome to a person
|
|
|
|
|
|
with a library).
|
|
|
|
|
|
- **A change of cadence retunes, else crossfades**: within 6 % the playing track is
|
|
|
|
|
|
sped up or slowed down at once, beyond it the player fades over 3 s into a track
|
|
|
|
|
|
that fits; a plan's new step picks a fitting track at once (over playing the track
|
|
|
|
|
|
to its end, minutes off the beat).
|
|
|
|
|
|
- **Media3 1.10.1 pinned by Gradle's own checksums** (`--write-verification-metadata
|
|
|
|
|
|
sha256`, trusted on first download from Google's Maven, as Polar's SDK was), the
|
|
|
|
|
|
entries added listed in the commit for the developer to review.
|
|
|
|
|
|
- Built: 10c. A recording's `cadence` series is its steps counted in 10 s windows
|
|
|
|
|
|
from its start, a window touching a pause left out (`cadenceOf`); the steps are now
|
|
|
|
|
|
counted on foot whatever auto-pause listens to. The cadence a run's music starts at
|
|
|
|
|
|
is learnt from the person's last ten recorded runs (`cadenceModelOf`, once three of
|
|
|
|
|
|
them have twelve cadence samples with a precise speed each): steps a minute as a
|
|
|
|
|
|
line in speed, its slope kept between 0 and 25, flat where the runs' speeds barely
|
|
|
|
|
|
differ, and a plan's steps get the cadence of their target pace or speed from it
|
|
|
|
|
|
(a step's `cadence`); a walk or a hike starts at 110, a run without the model at
|
|
|
|
|
|
165. In the service (`MusicConductor.kt`, tested on the JVM) the cadence is the
|
|
|
|
|
|
steps of the last 30 s once they span 10 s, forgotten after a pause or a mark,
|
|
|
|
|
|
asked every 5 s; a change under 2 steps a minute does nothing. A track fits at its
|
|
|
|
|
|
tempo, half or double it; among those fitting, each plays once before any plays
|
|
|
|
|
|
again; where none fits, the nearest plays as near as 6 % allows; without tracks the
|
|
|
|
|
|
metronome clicks at the cadence (an AudioTrack's static loop). `MusicPlayer.kt`
|
|
|
|
|
|
plays with Media3's ExoPlayer, the pitch kept, the playing player holding the
|
|
|
|
|
|
audio focus so that the cues duck it; a new track fades in by a second player over
|
|
|
|
|
|
3 s; a file that won't play counts as ended. A pause by hand pauses the music,
|
|
|
|
|
|
auto-pause doesn't (a red light shouldn't stop the song). Music chosen where the
|
|
|
|
|
|
phone no longer keeps a track with a tempo shows and plays nothing. Not tried on a
|
|
|
|
|
|
phone yet: the crossfade, the ducking under the cues, the metronome's click, how
|
|
|
|
|
|
steady the cadence feels.
|
2026-10-29 14:25:13 +01:00
|
|
|
|
- **D49 What OpenTracks does that Train didn't** (recommended, the developer's; 29 Oct;
|
|
|
|
|
|
step 11, D1's "at least what OpenTracks does"):
|
|
|
|
|
|
- **Announcements every km on foot, every 5 km by bike** (11a), never on a rower: the
|
|
|
|
|
|
time, the distance, the last split's pace or speed and the heart rate where a strap
|
|
|
|
|
|
or the Loop sends one, switched on the Record sheet and remembered for each activity;
|
|
|
|
|
|
one due while a plan's or a route's cue speaks waits for it (over an interval chosen
|
|
|
|
|
|
on the sheet, or the figures chosen on a settings page Train doesn't have).
|
|
|
|
|
|
- **A workout goes out as GPX, TCX or FIT** (11b) from its page through the share
|
|
|
|
|
|
sheet: GPX 1.1 with Garmin's TrackPointExtension (heart rate, cadence), TCX (laps,
|
|
|
|
|
|
heart rate, cadence, power, a row's distance) and FIT (binary, what Strava, Garmin
|
|
|
|
|
|
and intervals.icu read best, laps and power included), each written in Dart and
|
|
|
|
|
|
tested against the formats' specifications (over text formats alone, which lose
|
|
|
|
|
|
FIT's readers' best import).
|
|
|
|
|
|
- **The barometer's air pressure is kept raw** (11c) as a series of its own,
|
|
|
|
|
|
`pressure`, where the phone has a barometer; the page works out elevation from it,
|
|
|
|
|
|
its changes from the pressure, its level from the GPS altitudes' median, and the
|
|
|
|
|
|
climb from that elevation (over correcting each fix as it is recorded, which would
|
|
|
|
|
|
lose the readings a better filter could use later).
|
|
|
|
|
|
- **A bike's speed and cadence sensor (CSC, 0x1816) and power meter (CPS, 0x1818)**
|
|
|
|
|
|
(11d), one of each chosen on the Record sheet as the strap is and remembered: the
|
|
|
|
|
|
wheel's speed gives the distance where GPS can't (a turbo trainer, a tunnel), the
|
|
|
|
|
|
wheel's circumference learnt from GPS on the first rides or typed in; the crank's
|
|
|
|
|
|
cadence and the watts are series (`bikeCadence`, `power`), live and on the page with
|
|
|
|
|
|
average and normalised power.
|
|
|
|
|
|
- Built: 11a. The words come from the app (`recorderAnnouncementsOf`, in
|
|
|
|
|
|
`announce_speech.dart`) in its language: the first hundred distances spelled out
|
|
|
|
|
|
(further on as digits), what each count of hours, minutes and seconds is said as,
|
|
|
|
|
|
and each sentence with its placeholder; the service (`Announcer.kt`, tested on the
|
|
|
|
|
|
JVM) counts the moving time and the distance the plan's measures keep (paused fixes
|
|
|
|
|
|
left out), and on each distance passed says it, the moving time (hours and minutes
|
|
|
|
|
|
after the first hour, else minutes and seconds), the split's pace or speed (the
|
|
|
|
|
|
number in the app's language) and the heart rate of the last 5 s; a jump past
|
|
|
|
|
|
several (GPS lost in a tunnel) says the last. Spoken without a tone, queued after a
|
|
|
|
|
|
cue that speaks; a cue that comes meanwhile cuts it short, as cues come first. Off
|
|
|
|
|
|
for every activity until switched on (`announce.json`, `AnnounceStore`, never
|
|
|
|
|
|
synced). Not tried on a phone yet: how it sounds over music, eSpeak's and
|
|
|
|
|
|
RHVoice's reading of the German sentences.
|
feat(train): a workout out as GPX, TCX or FIT (ADR 0030, D49)
11b: Export on a workout's page hands it to the share sheet as GPX (with
a track only; heart rate and cadence in Garmin's TrackPointExtension),
TCX (laps, heart rate, cadence, watts, a row's distance) or FIT (file
ID, records, timer events at the pauses, laps with their intensities,
session, activity), all from the same points: the precise fixes while
moving, else the rower's frames, else the heart rates; a plan's steps
are the laps. The FIT writer is our own (protocol 1.0, CRC-16/ARC),
tested by a reader written from the specification.
Not tried yet with Strava, intervals.icu or Garmin Connect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-29 14:37:48 +01:00
|
|
|
|
- Built: 11b. *Export* on a workout's page (where the device hands files on) offers
|
|
|
|
|
|
GPX (only with a track), TCX and FIT, and hands the file to the share sheet (saved
|
|
|
|
|
|
where the person chooses on the desktop), named `halkyon-train-` and when it
|
|
|
|
|
|
started in UTC. Each is written from the same points (`workout_export.dart`): the
|
|
|
|
|
|
precise fixes while moving as the saved distance counts them, else the rower's
|
|
|
|
|
|
frames, else the heart rates; with the heart rate of the last 5 s and the cadence
|
|
|
|
|
|
of the window the point falls in, on foot in strides (half the steps, as Garmin's
|
|
|
|
|
|
devices write a run's cadence), a row's strokes, its watts; a plan's steps are the
|
|
|
|
|
|
laps (recovery *Resting* in TCX, FIT's intensities for warm-up and cool-down), else
|
|
|
|
|
|
one lap. GPX has a segment each piece between pauses; TCX writes the schema's order;
|
|
|
|
|
|
FIT (`fit_encoder.dart`, protocol 1.0, little-endian, profile 21.00, CRC-16/ARC) the
|
|
|
|
|
|
file ID as a development manufacturer's, the timer's start, stops and starts at
|
|
|
|
|
|
the pauses, a record each point (semicircles, centimetres), a lap each, the session
|
|
|
|
|
|
(a row as rowing indoors) and the activity. Tested by reading the files back: the
|
|
|
|
|
|
XML parsed, the FIT by a reader written from the specification in the tests
|
|
|
|
|
|
(`test/support/fit_reader.dart`), not by Garmin's SDK. Not tried yet: an upload to
|
|
|
|
|
|
Strava, intervals.icu or Garmin Connect.
|
2026-10-29 14:48:28 +01:00
|
|
|
|
- Built: 11c. Where a recording has GPS and the phone a barometer, the service averages
|
|
|
|
|
|
its readings over 5 s (`PressureAverage.kt`, tested on the JVM) into the file's `b`
|
|
|
|
|
|
lines and the workout's `pressure` series (`dpa`, tenths of a pascal, some 2 KB an
|
|
|
|
|
|
hour sealed). The page, Health Connect's totals and the files out read the fixes'
|
|
|
|
|
|
altitudes from it (`withBarometer`): each fix's height by the standard atmosphere at
|
|
|
|
|
|
the pressure then (between two readings as it went), moved by the median of GPS's
|
|
|
|
|
|
altitudes less those, so that its changes are the barometer's and its level GPS's;
|
|
|
|
|
|
its climbs count above 1 m rather than GPS's 3 m (the barometer's noise, averaged,
|
|
|
|
|
|
a fraction of a metre). A workout recorded before keeps GPS's altitudes. Not tried
|
|
|
|
|
|
on a phone yet: weather fronts over a long ride (a change of 1 hPa an hour reads as
|
|
|
|
|
|
some 8 m), and pockets' gusts.
|
feat(train): a ride reads the bike's speed and cadence sensor and power meter (ADR 0030, D49)
11d, its sensors: a ride's Record sheet offers the CSC sensor (0x1816)
and the power meter (0x1818), found nearby and remembered as the strap
is; the person's devices are now one map of DeviceKind to DeviceStore.
The service parses CSC and Cycling Power measurements (JVM-tested,
counters' rollover, coasting), writes the wheel's revolutions, the
crank's cadence and the watts to the file and to series of their own.
The recording screen shows cadence and watts; the page the power's
chart, average, highest and normalised power and the cadence's chart and
average; GPX, TCX and FIT carry them. A plan's watts target reads the
power meter too.
The wheel's speed and distance follow. Not tried with a real sensor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-29 15:11:20 +01:00
|
|
|
|
- Built: 11d's sensors. A ride's Record sheet offers the speed and cadence sensor and the
|
|
|
|
|
|
power meter, each found nearby and remembered as the strap is (`speed_sensor.json`,
|
|
|
|
|
|
`power_meter.json`; the person's devices now one map, `DeviceKind` to `DeviceStore`).
|
|
|
|
|
|
The service reads them on Android's Bluetooth API as it reads the strap
|
|
|
|
|
|
(`CyclingSensors.kt`, tested on the JVM: CSC Measurement 0x2A5B, Cycling Power
|
|
|
|
|
|
Measurement 0x2A63 with the fields before the crank's skipped as the flags say, the
|
|
|
|
|
|
crank's cadence from two events over both counters' rollover, 0 after 3 s without a
|
|
|
|
|
|
turn, the wheel's revolutions counted from the first frame); the file's `w`, `c`,
|
|
|
|
|
|
`pw`, `csc` and `power` lines, the series `wheel`, `pedalling` and `power`. The crank's
|
|
|
|
|
|
cadence comes from the sensor, else from the power meter. A plan's watts target reads
|
|
|
|
|
|
the power meter too. The recording screen shows the cadence and the watts of the last
|
|
|
|
|
|
5 s; the page the power's chart, average and highest (as the rower's), the normalised
|
|
|
|
|
|
power (Coggan's: the watts a second, the last reading held 5 s, their 30 s rolling
|
|
|
|
|
|
mean, the fourth root of its fourth powers' mean; none under a minute) and the
|
|
|
|
|
|
cadence's chart and average (coasting left out of the average); the files out carry
|
2026-10-29 15:22:48 +01:00
|
|
|
|
the crank's cadence and the watts. Not yet: Health Connect's power and cadence
|
|
|
|
|
|
records. Not tried with a real sensor yet.
|
|
|
|
|
|
- Built: 11d's wheel. The service writes the wheel's circumference a ride starts with
|
|
|
|
|
|
(`wheelsize` line, the `wheel` series' `mm` column); a ride's distance, splits,
|
|
|
|
|
|
progress and live speed are the wheel's where GPS made less than 90 % of the wheel's
|
|
|
|
|
|
way (a turbo trainer, a tunnel, no fix), else GPS's (`usesWheel`, `rodeByWheel`).
|
|
|
|
|
|
The circumference starts at 2105 mm (700×25C) and is learnt from rides with at least
|
2026-10-30 16:01:40 +01:00
|
|
|
|
2 km of GPS covering the ride (D56) whose quotient falls between 1 and 3 m: the
|
|
|
|
|
|
first sets it, each later one moves it a quarter of the way (`measuredWheelMm`); one
|
|
|
|
|
|
typed on the Record sheet (1000 to 3000 mm) stays until cleared, which measures
|
|
|
|
|
|
again. Auto-pause still follows GPS alone on a trainer.
|
2026-10-29 16:04:05 +01:00
|
|
|
|
- **D50 The instance's own map and searching for places** (recommended, the developer's
|
|
|
|
|
|
but the search's region; 29 Oct; step 12, what D22, D23 and D44 left for later):
|
|
|
|
|
|
- **Tiles** (12a): Protomaps' basemap (schema v4, zooms 0 to 15, ODbL, OpenStreetMap's
|
|
|
|
|
|
credit) cut for Europe (the BRouter region's box) from Protomaps' daily build with
|
|
|
|
|
|
`pmtiles extract` and served as ordinary vector tiles by `pmtiles serve` (go-pmtiles
|
|
|
|
|
|
v1.31.2, BSD-3-Clause, its release checked by SHA-256), both in an image of the
|
|
|
|
|
|
deploy's (`deploy/maps/`), a sidecar switched on in `.env` as Train is
|
|
|
|
|
|
(`compose.maps.yaml`); the cut renewed monthly, the new file written beside the old
|
|
|
|
|
|
and moved over it. The planet's build was 139 GB on 29 Oct; Europe's cut is to be
|
|
|
|
|
|
measured on the server (some 45 GB estimated), twice that while a new one is cut.
|
|
|
|
|
|
Over OpenFreeMap, which stays the source where the sidecar is off: no one outside
|
|
|
|
|
|
the instance sees the areas viewed.
|
|
|
|
|
|
- **Train's own style** (the developer's, over Protomaps' basemaps style, which needs
|
|
|
|
|
|
Node to make, its sprites hosted, and uses expressions vector_map_tiles' renderer
|
|
|
|
|
|
may not draw): written in Dart in Train's server (`train_style.dart`), light and
|
|
|
|
|
|
dark (the app's theme choosing), for running and riding: land, woods, parks, water,
|
|
|
|
|
|
roads by class with paths, tracks and cycleways set apart, rail, buildings at high
|
|
|
|
|
|
zooms, boundaries, and the names of places, roads and water in the app's language
|
|
|
|
|
|
where the tiles have it; no points of interest' icons at first. The server serves
|
|
|
|
|
|
it (`/map/style/light.json`, `dark.json`, public: nothing in it is secret), and a
|
|
|
|
|
|
test holds it to what vector_tile_renderer's reader takes without a warning.
|
|
|
|
|
|
- **For people signed in alone** (the developer's, over public tiles, which anyone
|
|
|
|
|
|
could spend the server's bandwidth on): `map.source` hands a key with the source
|
|
|
|
|
|
(`MapSource.tileKey`, a day count and an HMAC of it under the instance's
|
|
|
|
|
|
`mapKeySecret`, valid 30 days, naming no one, so that a tile asked for points at no
|
|
|
|
|
|
person), which the app sends as `Authorization: Bearer` with each tile; Caddy asks
|
|
|
|
|
|
Train's web server about each tile (`forward_auth`, `/map/tile-key`) before passing
|
|
|
|
|
|
it on. Neither logs a tile: what was asked shows the area viewed.
|
|
|
|
|
|
- **Searching** (12b): a Photon sidecar (v1.3.0, Apache-2.0, Java 21) on GraphHopper's
|
|
|
|
|
|
weekly database of **Europe** (the developer's choice, over Germany alone,
|
|
|
|
|
|
recommended for its 7.5 GB, and over no search yet): a 32.6 GB download on 29 Oct,
|
|
|
|
|
|
unpacked larger (to be measured), renewed monthly, its memory several GB, the
|
|
|
|
|
|
server's disk and memory the developer's to check before switching it on. As
|
|
|
|
|
|
BRouter: no site, reached by Train's server alone (a `search` endpoint, for people
|
|
|
|
|
|
signed in, limited, nothing kept or logged), on a network without a way out.
|
|
|
|
|
|
- **In the planner** (the developer's): a search field over the map; results nearest
|
|
|
|
|
|
the map's centre first, in the app's language; one chosen becomes the next waypoint
|
|
|
|
|
|
and the map moves to it (over moving the map alone).
|
|
|
|
|
|
- Built: 12a. Train's server: `MAP_TILES_URL` (set by `compose.maps.yaml`) makes the
|
|
|
|
|
|
instance's own tiles the source, before `MAP_STYLE_URL`; `MapSource` gained
|
|
|
|
|
|
`darkStyleUrl` and `tileKey` (both null for other sources); `InstanceMap`
|
|
|
|
|
|
(`lib/src/map/instance_map.dart`) hands the key with `map.source` (`MapKeys`,
|
|
|
|
|
|
`m1.<day>.<HMAC>`, the same all day, refused once its day has passed or changed in
|
|
|
|
|
|
a byte, compared in constant time), serves the style at `/map/style/light.json` and
|
|
|
|
|
|
`dark.json` (`?lang=en|de`) and answers Caddy's check at `/map/tile-key` (204 or
|
|
|
|
|
|
401; never where the tiles aren't the instance's own); without a `mapKeySecret` of
|
|
|
|
|
|
32 characters it doesn't start where it serves its own tiles. The style
|
|
|
|
|
|
(`train_style.dart`): 28 layers, expressions vector_tile_renderer reads (`match`, `coalesce`,
|
|
|
|
|
|
`interpolate`, `geometry-type`), tracks brown and dashed, footpaths dotted in rust,
|
|
|
|
|
|
cycleways teal; a test keeps copies in the app's fixtures, which the app's tests
|
|
|
|
|
|
read with vector_tile_renderer and no warning. The app picks the dark style in a
|
|
|
|
|
|
dark theme, adds the language to the instance's own, and sends the key with each
|
|
|
|
|
|
tile and with a kept route's (`map_style.dart`); kept styles are now one folder each
|
|
|
|
|
|
(`style/<hash>/`), so a style kept before this is read anew once online. The page
|
|
|
|
|
|
names the instance's own map as keeping nothing of the area shown. The deploy:
|
|
|
|
|
|
`deploy/maps/` (go-pmtiles on Alpine, `tiles.sh` cutting Europe's box,
|
|
|
|
|
|
`-25,35,45,75`, from the newest build each month), `compose.maps.yaml` (the tiles
|
|
|
|
|
|
beside Caddy alone on an internal network, unlogged; the cut on the downloads
|
|
|
|
|
|
network), `sidecars/train-maps.caddy` imported into Train's site; the operator's
|
|
|
|
|
|
steps in `deploy/sidecars/README.md`, *Train's map*. Found on the way:
|
|
|
|
|
|
`.dockerignore` left all of `apps/train/` out of the build's context, so Train's
|
|
|
|
|
|
server's image and BRouter's (which copies Train's running profile) couldn't have
|
|
|
|
|
|
been built on the server; now only Train's app and client are left out, and
|
|
|
|
|
|
`test/deploy/images_test.dart` checks Train's image and the sidecars' copied
|
|
|
|
|
|
files. BRouter's segments and the tiles are kept out of git and the context. Not
|
|
|
|
|
|
run: any of the deploy (no Docker access here), Caddy's `forward_auth` and its
|
|
|
|
|
|
`import` of an empty folder, whether `pmtiles serve` reads a new cut moved over the
|
|
|
|
|
|
old one, the style drawn on a phone.
|
2026-10-29 16:20:36 +01:00
|
|
|
|
- Built: 12b. Train's server: the `search` endpoint (`lib/src/search/`, for people
|
|
|
|
|
|
signed in) asks the instance's Photon (`PHOTON_URL`, set by `compose.search.yaml`;
|
|
|
|
|
|
none: `offered` is false and searches are refused) for queries of 2 to 200
|
|
|
|
|
|
characters, 8 places at most, those nearest the map's centre first, named in
|
|
|
|
|
|
English or German (another language: Photon's own names), 60 searches a person a
|
|
|
|
|
|
minute (the planner asks as people type); Photon's GeoJSON read into `Place` (its
|
|
|
|
|
|
name, or the street and number of an address; its street, town and country as
|
|
|
|
|
|
the name doesn't say them; its kind), tested on its documented format, on a local
|
|
|
|
|
|
HTTP server and on a fake Photon; nothing kept or logged. The app: a field over the
|
|
|
|
|
|
planner's map where the instance searches (`PlaceSearchCubit`, asked once typing
|
|
|
|
|
|
pauses for 400 ms, a cleared or later query's answer dropped; `place_search_gateway.dart`),
|
|
|
|
|
|
the places found listed under it, one chosen the next waypoint and the map moved
|
|
|
|
|
|
to it (zoom 14 at least). The deploy: `deploy/photon/` (Photon 1.3.0 on Temurin 21;
|
|
|
|
|
|
`data.sh` downloads GraphHopper's `photon-db-<region>-1.0-latest` monthly, unpacked
|
|
|
|
|
|
as it streams and checked against its MD5, its archive never kept; `serve.sh` puts
|
2026-10-30 12:56:00 +01:00
|
|
|
|
a new database in place within a minute, Photon away a minute or two),
|
2026-10-29 16:20:36 +01:00
|
|
|
|
`compose.search.yaml` (Photon beside Train's server alone on an internal network,
|
2026-10-30 12:56:00 +01:00
|
|
|
|
unlogged; its downloads on the downloads' network, an ordinary bridge it shares with
|
|
|
|
|
|
BRouter's segments and the map's tiles; `PHOTON_HEAP` 6g, `PHOTON_MEMORY`
|
2026-10-29 16:20:36 +01:00
|
|
|
|
8g), the operator's steps in the README's *Train's search*. Not run: any of the
|
|
|
|
|
|
deploy, the size of Europe's database unpacked and Photon's memory on it, whether
|
|
|
|
|
|
1.3.0 reads GraphHopper's `1.0` build (the folder GraphHopper offers for Photon's
|
|
|
|
|
|
1.x releases), the field on a phone.
|
2026-10-29 17:53:54 +01:00
|
|
|
|
- **D51 What a ride's sensors and a run's steps give Health Connect, and the wheel's
|
|
|
|
|
|
auto-pause** (recommended, the developer's; 29 Oct; step 13, what D49 left open):
|
|
|
|
|
|
- **Health Connect** (13a): a ride's power meter's watts as `PowerRecord` samples (as
|
|
|
|
|
|
the rower's), its crank's cadence as `CyclingPedalingCadenceRecord`, and where its
|
|
|
|
|
|
distance is the wheel's (D49) the wheel's speed as `SpeedRecord` in place of GPS's;
|
|
|
|
|
|
a run's or walk's steps' cadence as `StepsCadenceRecord` (the cadence series, D48),
|
|
|
|
|
|
over a ride's alone. The two cadences' permissions are asked with writing's, but
|
|
|
|
|
|
writing stays on without them, so that those who switched it on before keep it:
|
|
|
|
|
|
records Health Connect hasn't allowed are left out. Workouts written before are
|
|
|
|
|
|
written again with them, as any change to what a workout gives is.
|
|
|
|
|
|
- **Auto-pause by the wheel** (13b): while the speed sensor sends, the wheel's speed
|
|
|
|
|
|
decides with a bike's thresholds (paused below 3 km/h for 5 s, going on above
|
|
|
|
|
|
5 km/h); GPS's again once the wheel has been silent 5 s (over moving while either
|
|
|
|
|
|
says so, which never pauses on a trainer where GPS drifts).
|
|
|
|
|
|
- Built: 13a and 13b. `healthRecordsOf` adds the power meter's watts to the rower's,
|
|
|
|
|
|
the crank's cadence (`pedalling`) and the steps' (`stepsCadence`, the series' 10 s
|
|
|
|
|
|
windows), and on a ride whose distance is the wheel's its speed over stretches of
|
|
|
|
|
|
5 s at least (`_wheelSpeeds`), pauses left out. `HealthRecords.kt` maps them to
|
|
|
|
|
|
`CyclingPedalingCadenceRecord` and `StepsCadenceRecord`; the bridge asks for their
|
|
|
|
|
|
permissions (`WRITE_EXERCISE` and `WRITE_STEPS`, as Health Connect names them,
|
|
|
|
|
|
which a JVM test holds the manifest to for every kind written) with writing's
|
|
|
|
|
|
(`ASKED_WITH`), and leaves out the records not allowed. So that workouts written
|
|
|
|
|
|
before are written again, the fingerprint deciding it now folds in
|
|
|
|
|
|
`healthRecordsVersion` (2): every workout is written once more after the update.
|
|
|
|
|
|
`AutoPause.wheel` takes the wheel's speed (`WheelSpeed` in `CyclingSensors.kt`,
|
|
|
|
|
|
over at least half a second, past the 32-bit rollover, by the circumference the
|
|
|
|
|
|
ride starts with), and GPS's speed is ignored while the wheel sent within 5 s; a
|
|
|
|
|
|
ride without a circumference (no speed sensor) pauses by GPS as before. Not tried
|
|
|
|
|
|
with a real sensor or Health Connect.
|
2026-10-29 18:42:46 +01:00
|
|
|
|
- **D52 Music kept in the background, and smaller copies** (recommended, the
|
|
|
|
|
|
developer's; 29 Oct; step 14, what D47's 10b left open):
|
|
|
|
|
|
- **Downloads by a WorkManager job of Train's, in Kotlin, on the app's list** (14b;
|
|
|
|
|
|
over the job also reading the marked playlists, which writes part of the Jellyfin
|
|
|
|
|
|
client a second time; over the workmanager plugin running today's Dart in a
|
|
|
|
|
|
background engine, a new plugin and a whole engine for each run; over Android's
|
|
|
|
|
|
DownloadManager, which keeps the token in the system's download database): the
|
|
|
|
|
|
app, as it opens and resumes, reads the playlists as before, deletes what they no
|
|
|
|
|
|
longer hold and writes which tracks are missing; the job downloads those and works
|
|
|
|
|
|
out their tempo, a line each in the person's music folder, which the app takes in
|
|
|
|
|
|
as it opens and, while open, as each line comes. A playlist changed on the server
|
|
|
|
|
|
while the app is closed is noticed as it opens. The job does every download, the
|
|
|
|
|
|
open app's too, so that one code path runs and two never fetch the same track.
|
|
|
|
|
|
- **The token in one store of Train's, in Kotlin** (14a; over a second copy beside
|
|
|
|
|
|
flutter_secure_storage's, two to keep in step, and over the job reading that
|
|
|
|
|
|
plugin's own files, whose format has changed between versions): a file per person
|
|
|
|
|
|
in the no-backup folder, sealed with AES-GCM under a key in Android's Keystore
|
|
|
|
|
|
that can't be exported; the app reads it over the music channel; a token kept
|
|
|
|
|
|
before is moved over once and deleted from the keyring.
|
|
|
|
|
|
- **When**: every 6 hours and once whenever the list changes, on an unmetered network,
|
|
|
|
|
|
with the battery and the storage not low (over daily while charging, which leaves
|
|
|
|
|
|
a new playlist waiting overnight, and over every 12 hours).
|
|
|
|
|
|
- **Smaller copies** (14c): the music screen chooses *As they are* or *Smaller*, the
|
|
|
|
|
|
default *Smaller*: files above 256 kbps (FLAC and other lossless) are transcoded by
|
|
|
|
|
|
Jellyfin to Opus at 128 kbps in WebM, which Android's extractor and decoder read
|
|
|
|
|
|
on every version Train runs on; MP3s and AACs come as they are (over *As they are*
|
|
|
|
|
|
by default, and over always transcoding, which loses an MP3's quality twice and
|
|
|
|
|
|
has the server's processor do every track). Switching downloads the tracks again;
|
|
|
|
|
|
a file is kept until its new copy is whole, its tempo kept.
|
|
|
|
|
|
- Built: 14a. `JellyfinTokens.kt` keeps each person's token in the no-backup
|
|
|
|
|
|
folder's `jellyfin/`, a file named by their ID: a version byte, the IV the
|
|
|
|
|
|
Keystore makes and the token sealed with AES-GCM under `train_jellyfin`, a
|
|
|
|
|
|
256-bit key of Android's Keystore made once, the person's ID its associated data;
|
|
|
|
|
|
a file that doesn't open is deleted and counts as none, so the person signs in
|
|
|
|
|
|
again. Tested on the JVM with a software key. `AndroidJellyfinTokenStore` asks it
|
|
|
|
|
|
over the music channel and moves a token the keyring holds under
|
|
|
|
|
|
`train_jellyfin:` over as it is first read; keeping one, and forgetting others',
|
|
|
|
|
|
clears the keyring's too.
|
2026-10-29 18:57:19 +01:00
|
|
|
|
- Built: 14b. `MusicDownloads` reads the playlists as before and hands the tracks
|
|
|
|
|
|
not yet downloaded, or without a tempo worked out, to `MusicPlatform.schedule`
|
|
|
|
|
|
(`MusicJob`: the person, their music folder, the server, Jellyfin's
|
|
|
|
|
|
`Authorization` header without the token, and each track's path), none where no
|
|
|
|
|
|
one is signed in or nothing is missing. `MusicWorker.kt` keeps the list in the
|
|
|
|
|
|
no-backup folder's `music_background.json` and enqueues a periodic run of 6 hours
|
|
|
|
|
|
and a run now, both unique and kept where one waits, under WorkManager's
|
|
|
|
|
|
unmetered network, battery and storage constraints; one run at a time in the
|
|
|
|
|
|
process (a mutex), which reads the list again as it ends and runs again where it
|
|
|
|
|
|
changed. `MusicFetcher.kt` (tested on the JVM against a server of the test's own)
|
|
|
|
|
|
downloads each track through a part file, adds the token from `JellyfinTokens`,
|
|
|
|
|
|
works out its tempo with `MusicBridge.of`, and appends a line to the folder's
|
|
|
|
|
|
`fetched.txt`, skipping what a line names already; a refused token stops the run,
|
|
|
|
|
|
a server out of reach has WorkManager try again later, a track the server fails is
|
|
|
|
|
|
left for the next run. The app takes the lines in as it opens and resumes, and as
|
|
|
|
|
|
`…/music/fetched` says one came while it listens (the file renamed first, so a
|
|
|
|
|
|
line written meanwhile lands in the next); a track no longer kept has its file
|
|
|
|
|
|
deleted. The app's own download (`JellyfinClient.download`) is gone, and so is
|
|
|
|
|
|
the tempo over the channel. Not tried on a phone: WorkManager's runs, the
|
|
|
|
|
|
downloads of large files over HttpURLConnection.
|
2026-10-29 19:09:06 +01:00
|
|
|
|
- Built: 14c. The playlists' items are asked with `fields=MediaSources`, which gives
|
|
|
|
|
|
each track's bitrate and its audio stream's codec (`MusicTrack.bitrate`, `codec`).
|
|
|
|
|
|
*Smaller copies* on the music screen (`MusicSettings.smaller`, on by default, kept
|
|
|
|
|
|
through signing out) has a file above 256 kbps fetched from
|
|
|
|
|
|
`/Audio/<id>/stream.webm?audioCodec=opus&audioBitRate=128000&maxAudioChannels=2`,
|
|
|
|
|
|
but for the lossy codecs `mp3`, `aac`, `vorbis` and `opus` at any rate: the
|
|
|
|
|
|
decision's "MP3s and AACs come as they are" taken over its threshold, which a
|
|
|
|
|
|
320 kbps MP3 is above. Each track keeps which copy its file is (`MusicCopy`, a
|
|
|
|
|
|
track kept before counting as the original); one kept as another copy than wanted
|
|
|
|
|
|
goes to the job again, without its tempo worked out again, and the job's line
|
|
|
|
|
|
names the copy, so that one fetched as the original doesn't stop the smaller from
|
|
|
|
|
|
coming. Not tried against a real Jellyfin: that its transcoding answers this path
|
|
|
|
|
|
with WebM, how long it takes for a track.
|
feat(train): interval plans, synced and sealed, with an editor and templates (ADR 0030, D42)
Train's server gains the synced `plan` table, a sealed payload alone;
the migration `plans` only adds it, its space index and the foreign key
to its sync space, and a person's deletion takes it with their space.
The app keeps plans as `plan` rows sealed for their row: a name, what
they are for, whether a step off target is told, and blocks of steps
repeated, each ending after a time, a distance or a lap tap and aiming
at a pace, speed, heart rate or zone, or the rower's split, watts or
stroke rate. Home's Plans tab lists them, a new one starts from nothing
or from a template (4×4 minutes, Tabata, 10×400 m), the editor changes
steps on a sheet, and a deletion asks first and can be undone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 22:35:22 +01:00
|
|
|
|
|
2026-10-30 12:56:00 +01:00
|
|
|
|
## After the branch's review (30 Oct 2026, the developer's)
|
|
|
|
|
|
|
|
|
|
|
|
- **D53 Nothing of Train's in a backup or a transfer to another phone** (the
|
|
|
|
|
|
developer's; 30 Oct): Train keeps unsealed on the phone what D7 seals everywhere
|
|
|
|
|
|
else: a recording while it runs (`recording.txt`, the track and the heart rate), the
|
|
|
|
|
|
Loop's days before they are sealed (`loop_days.txt`), the body's figures the band was
|
|
|
|
|
|
set up with (`loop.json`), the maps along routes. Android's backup (Google's, or
|
|
|
|
|
|
Seedvault on GrapheneOS and LineageOS) and its transfer to a new phone copied the
|
|
|
|
|
|
app's folders by default, outside the sealing. `allowBackup="false"` and
|
|
|
|
|
|
`fullBackupContent="false"` stop both through Android 11; from Android 12 on
|
|
|
|
|
|
`allowBackup` stops the backup alone, and `res/xml/data_extraction_rules.xml`
|
|
|
|
|
|
excludes every folder from the backup and from a transfer (`BackupRulesTest`). Over
|
|
|
|
|
|
excluding Train's unsealed files one by one, which a file added later would slip
|
|
|
|
|
|
through, and over moving them under `noBackupFilesDir`, which a transfer from
|
|
|
|
|
|
Android 12 on still takes; nothing is lost on a new phone that matters: what is
|
|
|
|
|
|
sealed comes back from the instance, the account's keys from signing in.
|
|
|
|
|
|
- **D54 Jellyfin over https alone, but on the phone itself** (the developer's; 30 Oct;
|
|
|
|
|
|
revises D47's addresses): an address with `http://` to another machine is refused as
|
|
|
|
|
|
typed (`jellyfinInClear`, `MusicFailure.inClear`, *Train connects to Jellyfin over
|
|
|
|
|
|
https only*): over http the password, Quick Connect's token and the person's
|
|
|
|
|
|
listening go through the network in clear. `http://localhost` and `http://127.0.0.1`
|
|
|
|
|
|
stay, for a Jellyfin on the phone, the network security config letting the
|
|
|
|
|
|
downloads (`MusicFetcher`, `HttpURLConnection`, which Android holds to it, where
|
|
|
|
|
|
Dart's client isn't) reach those two and nothing else in clear. Over allowing http
|
|
|
|
|
|
on the local network too, which Android's network security config can't scope to a
|
|
|
|
|
|
network (it names hosts), so that the downloads would have to be allowed cleartext
|
|
|
|
|
|
everywhere.
|
|
|
|
|
|
- **D55 Only the firmware the person agreed to** (the developer's; 30 Oct; D40):
|
|
|
|
|
|
relayed by the instance, the band gets the version the person was shown and agreed
|
|
|
|
|
|
to, or none: where the relay offers another by then, nothing is downloaded or
|
|
|
|
|
|
written, and the other version is offered instead (`LoopFailed('another update')`).
|
|
|
|
|
|
The relay's calls to Polar have timeouts, caps on their size and https on every
|
|
|
|
|
|
hop, one fetch per file at a time, and a limit per person.
|
2026-10-30 16:14:56 +01:00
|
|
|
|
What the phone can't check (30 Oct): Polar's SDK (8.3.0) unzips the file and writes
|
|
|
|
|
|
its parts to the band over PFTP as they come, with no hash or signature of its own
|
|
|
|
|
|
(its classes use neither `MessageDigest` nor `Signature`). Whether the band refuses
|
|
|
|
|
|
an image Polar didn't sign is to be seen from a file of Polar's; until then,
|
|
|
|
|
|
firmware through the instance trusts the instance as the direct way trusts Polar's
|
|
|
|
|
|
service, and the person choosing the relay chooses that.
|
2026-10-30 16:01:40 +01:00
|
|
|
|
- **D56 The wheel learnt from GPS that covered the ride** (recommended, the developer's;
|
|
|
|
|
|
30 Oct; revises D49's measure): a ride measures the wheel where precise fixes followed
|
|
|
|
|
|
it for 95 % of its moving time (gaps of 30 s at most count, a pause cuts), not where
|
|
|
|
|
|
GPS made 90 % of the wheel's way at the circumference taken: that held every wheel to
|
|
|
|
|
|
the one taken, so a real wheel under some 1.9 m (20", 24", a child's 16") was never
|
|
|
|
|
|
learnt from the 2105 mm a sensor starts with. Its quotient may fall between 1 and 3 m,
|
|
|
|
|
|
as one typed may (`measuredWheelMm`, `_gpsCovered`). A tunnel's straight line still
|
|
|
|
|
|
measures nothing. The distance still follows `usesWheel`, against the wheel as learnt.
|
2026-10-30 16:12:00 +01:00
|
|
|
|
- **D57 Row IDs that tell the instance no date** (recommended, the developer's; 30 Oct;
|
|
|
|
|
|
revises D37's IDs): a `day`, `night` and `day_series` row's ID was a UUID of version
|
|
|
|
|
|
5 of `https://halkyon.app/train/day/<person>/<date>`, so that every phone named a
|
|
|
|
|
|
day alike offline; but the instance knows the person and can hash every date, so
|
|
|
|
|
|
each ID told it which days the person wore the band, their sealing aside. The ID is
|
|
|
|
|
|
now the account's keyed hash of the name (halkyon_keys' `keyedRowId`: BLAKE2b keyed
|
|
|
|
|
|
with the account's X25519 secret key over a label and `train/day/<date>`, a UUID of
|
|
|
|
|
|
version 8; `ContentCipher.rowId`, `TrainKeys.rowId`), the same on every device of
|
|
|
|
|
|
the account, which all hold its keys, and nothing of the date read from it without
|
|
|
|
|
|
them. Over a key of Train's own (its data keys multiply offline, D15, so two
|
|
|
|
|
|
phones would name one day two ways until they met), random IDs with a sealed index
|
|
|
|
|
|
from date to ID (two phones offline each making a day's row, merged later: more
|
|
|
|
|
|
code for the same), and coarser rows (a week or month each, rewriting more with
|
|
|
|
|
|
every day). What the instance still sees: how many rows there are, their sizes and
|
|
|
|
|
|
when they are written; a reset of the account's keys (ADR 0017) renames every row,
|
|
|
|
|
|
as it leaves what was sealed unreadable anyway. Train had no release, so no row
|
|
|
|
|
|
needed moving.
|
2026-10-30 16:40:12 +01:00
|
|
|
|
- **D58 Train's releases** (recommended, the developer's; 30 Oct): as Learn's, from
|
|
|
|
|
|
`tool/release.dart train` on the developer's laptop (`apps/train/release.dart`), the
|
|
|
|
|
|
APK alone (Train is Android's), connecting to train.halkyon.eu, published on
|
|
|
|
|
|
code.halkyon.app and in the project's F-Droid repository beside Learn, signed with a
|
|
|
|
|
|
key of Train's own (a PKCS #12 keystore at `~/.local/share/halkyon-train/`, its
|
|
|
|
|
|
certificate pinned in `tool/release/train-android-certificate.sha256`): one key for
|
|
|
|
|
|
both apps would let either's build pass for the other's update. Its listing
|
|
|
|
|
|
(`tool/release/fdroid/metadata/app.halkyon.train.yml`) declares `NonFreeDep` (Polar's
|
|
|
|
|
|
SDK, D36) and `NonFreeNet` (Polar's firmware service, the direct way of D40, and the
|
|
|
|
|
|
server's SSPL, as Learn's says).
|
2026-10-30 23:01:04 +01:00
|
|
|
|
- **D59 Waypoints pulled out of the line, and any one taken off** (recommended, the
|
|
|
|
|
|
developer's; 30 Oct; D44): the planned line held and dragged pulls a waypoint out of
|
|
|
|
|
|
it, inserted between the two waypoints of the stretch it was held on, and the way is
|
|
|
|
|
|
planned again through it, as Komoot, brouter-web and Google Maps do; a waypoint tapped
|
|
|
|
|
|
offers to take it off, where undo took only the last. Held first, as a waypoint is
|
|
|
|
|
|
moved, so that dragging the map never pulls the line. Over a tap on the line
|
|
|
|
|
|
inserting a waypoint to be dragged after (two steps, and a line easily hit by a
|
|
|
|
|
|
finger meaning the map) and over a list of waypoints under the map (good for long
|
|
|
|
|
|
routes, clumsy for "a little closer to the lake"). The stretch is the one of the line
|
|
|
|
|
|
nearest where it was held, each waypoint's place on the line found from the one
|
|
|
|
|
|
before's on, so a way out and back the same streets keeps its stretches in order
|
|
|
|
|
|
(`insertionIndex`, `route_legs.dart`).
|
feat(train): Home, Maps, Record, Groups and Profile in Learn's frame, washed in carrot (ADR 0030, D60)
Train's tabs are Strava's five where one screen with four tabs at its
top had held workouts, days, plans and routes: Home the workouts, Maps
the routes, Record recording and the interval plans, Groups a screen
saying they come later, Profile the days and the way to the account.
A device that doesn't record has no Record tab, its plans beside the
days in Profile.
The frame is halkyon_ui's NavigationFrame, driven by go_router as
Learn's: a workout's page opens in Home and a day's in Profile with the
tabs beside them; the route and plan editors and the recording cover
them, their content handed over as it is. Every tab washes down from the
top in carrot, the warm end of Train's icon, as Learn's in cobalt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-30 23:52:48 +01:00
|
|
|
|
- **D60 Train's tabs, as Strava's and in Learn's frame** (the developer's; 30 Oct):
|
|
|
|
|
|
Home (the workouts), Maps (the routes), Record (recording, and the interval plans),
|
|
|
|
|
|
Groups (to come; for now a screen that says so) and Profile (the days, and the way to
|
|
|
|
|
|
the account), as Strava's five, where one screen with four tabs at its top had held
|
|
|
|
|
|
workouts, days, plans and routes, and a floating button recorded. Record is left out
|
|
|
|
|
|
where the device doesn't record (the desktop), which keeps the plans beside the days
|
|
|
|
|
|
in Profile. The frame is Learn's, moved into halkyon_ui for every app
|
|
|
|
|
|
(`NavigationFrame`, `TabsStack`): a rail with the app's icon on top from medium
|
|
|
|
|
|
windows on, a bar floating above the foot below, a tick on every choice. Driven by
|
|
|
|
|
|
go_router as Learn's (a `StatefulShellRoute`, each tab keeping its own stack): a
|
|
|
|
|
|
workout's page opens in Home and a day's in Profile with the tabs beside them, the
|
|
|
|
|
|
route and plan editors and the recording over them; over keeping `Navigator.push`
|
|
|
|
|
|
under hand-made nested navigators (a second pattern beside Learn's, Android's back
|
|
|
|
|
|
handled by hand) and over tabs whose every screen covers the bar (as before, unlike
|
|
|
|
|
|
Learn and Strava). Every screen washes down from the top in carrot, the warm end of
|
|
|
|
|
|
Train's icon, as Learn's in cobalt (`TopGradient` under the app's `ScreenColor`);
|
|
|
|
|
|
buttons and links stay cobalt. Over cobalt (Learn's, so the two apps would look
|
|
|
|
|
|
alike), strawberry and the icon's whole sweep.
|
feat(train): Settings over the tabs from a cog on Home and Profile, the account near its foot (ADR 0030, D60)
As Learn's: a cog atop Home and Profile, with the count of accounts
waiting for whoever operates the instance, opens Settings over the tabs,
where an account sheet had held the account and the training's screens.
Grouped in cards: the operator's card, the training (your body, Health
Connect, the Polar Loop, music, those the device has), the account near
the foot, About Halkyon Train last, and the version small at the very
bottom.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 00:16:01 +01:00
|
|
|
|
Settings is a screen over the tabs, as Learn's, from a cog atop Home and Profile
|
|
|
|
|
|
(31 Oct, the developer's), where an account sheet had held the account and the
|
|
|
|
|
|
training's screens: grouped in cards, the training's screens first, the account near
|
|
|
|
|
|
the foot, About Halkyon Train last and the version small below it.
|
2026-10-31 12:12:55 +01:00
|
|
|
|
A recording under way, left for another tab, stands above the bar on every tab
|
|
|
|
|
|
(below the rail's screen from medium windows on), as Strava's: its activity, its
|
|
|
|
|
|
moving time and distance, paused or stopped, a tap leading back to it
|
|
|
|
|
|
(`RecordingBar`, `NavigationFrame.status`); over the Record tab's button alone, which
|
|
|
|
|
|
had been the one way back. A workout just saved opens its page, in Home's tab, where
|
|
|
|
|
|
the recording had gone back to the tab it started from (both the developer's, 31 Oct).
|
2026-10-31 12:20:39 +01:00
|
|
|
|
Home opens on this week, as Strava's (`WeekCard`, `weekSummary`): the time trained
|
|
|
|
|
|
(moving time, else start to end), the distance and the workouts, and a strip of its
|
|
|
|
|
|
seven days each as tall as its time, the week starting on the day the person's
|
|
|
|
|
|
language starts it on (Monday in British English and German, Sunday in American
|
|
|
|
|
|
English, Material's `firstDayOfWeekIndex`), each workout on the day it started, local
|
|
|
|
|
|
time; over the totals alone and over a chart of several weeks (the developer's,
|
|
|
|
|
|
31 Oct).
|
2026-10-31 12:25:38 +01:00
|
|
|
|
Maps shows every route on the instance's map (`RoutesMap`), the camera fitting them,
|
|
|
|
|
|
one tapped opening its planner: above the list below expanded windows, beside it from
|
|
|
|
|
|
expanded windows on, as Material's list–detail layout has two panes there; without a
|
|
|
|
|
|
route to draw, the list alone, as there is nothing to centre on without asking the
|
|
|
|
|
|
phone where it is (the developer's, 31 Oct).
|
2026-10-31 18:00:45 +01:00
|
|
|
|
Record has a card for each activity it records, its Start opening the Record sheet
|
|
|
|
|
|
for that activity, the interval plans below (`ActivityCard`); over Start recording
|
|
|
|
|
|
at once with the sheet's choices, an options button beside it, which hides what a
|
|
|
|
|
|
workout may need set (the developer's, 31 Oct). The sheet no longer chooses the
|
|
|
|
|
|
activity, so it offers the plans and routes for it or for any alone, where one for
|
|
|
|
|
|
another activity had chosen it; while a recording is under way or waits to be saved,
|
|
|
|
|
|
the cards' Start waits, the bar leading back.
|
2026-10-31 18:46:26 +01:00
|
|
|
|
Profile opens on *Your body* (`BodyCard`: weight, resting and maximal heart rate, as
|
|
|
|
|
|
far as given, tapped opening the screen, which Settings keeps too), the last 12
|
|
|
|
|
|
weeks' time trained as bars (`WeeksCard`, `weeksBack`: this week chosen, a week
|
|
|
|
|
|
tapped showing its time, distance and workouts) and this calendar year by activity
|
|
|
|
|
|
(`YearCard`, `yearSummary`), over the days; over the year's totals alone and over
|
|
|
|
|
|
progress from the Loop's days, which someone without a Loop wouldn't have (the
|
|
|
|
|
|
developer's, 31 Oct). Every duration reads as the days' do (`hoursMinutes`).
|
2026-10-31 18:52:32 +01:00
|
|
|
|
Home opens on today from the band (`TodayCard`: steps, active time, last night's sleep
|
|
|
|
|
|
and Nightly Recharge, as Profile lists a day, `dayFigures`; tapped, the day's page,
|
|
|
|
|
|
in Profile's tab), shown while the band gave figures for today, and so for someone
|
|
|
|
|
|
with a Loop alone; above the week, which waits for the first workout, and above
|
|
|
|
|
|
Home's message while there is none.
|
feat(ui): tabs folding into the title, in Learn's and Train's Profile (ADR 0030, D60)
halkyon_ui's FoldingTabs: the tab row under the app bar's title at the
top, scrolling away with the content, the chosen tab's name then small
under the title, which, tapped, scrolls back up to the row, as Strava's.
Learn's Profile (Profile, Statistics) is built on it, and Train's
Profile in tabs: Progress (your body, the last 12 weeks, this year),
Days, and Plans where the device doesn't record.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 19:12:11 +01:00
|
|
|
|
Profile's tabs fold into the title as Strava's (halkyon_ui's `FoldingTabs`, Learn's
|
|
|
|
|
|
Profile on it too): Progress (the cards above), Days, and Plans where the device
|
|
|
|
|
|
doesn't record; at the top the tab row shows under the title, scrolled down it
|
|
|
|
|
|
scrolls away with the content and the chosen tab's name shows small under the
|
|
|
|
|
|
title, which, tapped, scrolls back up to the row. The row alone is the scroll view's
|
|
|
|
|
|
header, nothing pinned, so the tabs' lists need no room kept for it and stay plain
|
|
|
|
|
|
`ListView`s.
|
feat(app): what's new after an update, drafted by the release tool (ADR 0030, D61)
halkyon_app's WhatsNew reads what each release changed from the app's
assets/whats_new/ (<version>.<language>.txt, English and German, a change
a line): after an update a sheet lists every release since the one the
device showed last, newest first, once; Settings shows the running
version's again, above About. Nothing on a fresh install.
tool/release.dart drafts the missing texts from the commits that count
after "Go on?" (German marked TRANSLATE:), opens them in the editor until
they fit (no mark left, F-Droid's 500 characters) and commits them with
the version. The English is the Forgejo notes' What's new, each language
F-Droid's changelog of the build, written at publishing and copied in by
tool/fdroid_repo.sh. Learn and Train bundle their texts, Learn's 0.22.0
and Train's 0.1.0 written to start.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-31 19:36:20 +01:00
|
|
|
|
- **D61 What's new, in every Halkyon app** (the developer's; 30 and 31 Oct): after an
|
|
|
|
|
|
update, a sheet lists what changed, once per device, and Settings shows it again
|
|
|
|
|
|
(`WhatsNewTile`, in the About card), shared in halkyon_app (`WhatsNew`,
|
|
|
|
|
|
`WhatsNewOnUpdate` around the tabs, `WhatsNewScope` around the app). Before it,
|
|
|
|
|
|
Forgejo's notes listed the feat/fix subjects, and F-Droid and the Snap Store
|
|
|
|
|
|
showed nothing (the Snap Store has no field for it).
|
|
|
|
|
|
- **One file a release and language** in the app's `assets/whats_new/`
|
|
|
|
|
|
(`<version>.<language>.txt`, English and German, a change a line as `- …`),
|
|
|
|
|
|
bundled with the app; over one changelog per language, which the app and the
|
|
|
|
|
|
tool would parse by sections and which grows with each release.
|
|
|
|
|
|
- **Drafted, then edited**: `tool/release.dart`, after *Go on?*, drafts the
|
|
|
|
|
|
missing files from the commits that count (features, then fixes, without types,
|
|
|
|
|
|
scopes and closing references; German the same lines marked `TRANSLATE:`) and
|
|
|
|
|
|
opens them in `$VISUAL`/`$EDITOR` (or waits for Enter) until each lists a change,
|
|
|
|
|
|
has no mark left and fits F-Droid's 500 characters; they go into the release
|
|
|
|
|
|
commit with the version (the first release's alone, where the pubspec keeps its
|
|
|
|
|
|
version). A run stopped there keeps them for the next, the only untracked files
|
|
|
|
|
|
a release starts with. The dry run prints them, or the drafts.
|
|
|
|
|
|
- **The same text everywhere**: the English as the Forgejo notes' *What's new*, in
|
|
|
|
|
|
place of the commits' list, and each language as F-Droid's changelog of the build
|
|
|
|
|
|
(`metadata/<app id>/<locale>/changelogs/<build>.txt`, `en-US` and `de`), written
|
|
|
|
|
|
by the tool at publishing and copied in by `tool/fdroid_repo.sh`
|
|
|
|
|
|
(`HALKYON_FDROID_METADATA`), so nothing is committed twice.
|
|
|
|
|
|
- **Every release since**: someone who skipped updates sees each release's changes
|
|
|
|
|
|
since the version the device showed last (`whats_new.json` in the support
|
|
|
|
|
|
folder), newest first; over the newest alone. Nothing on a fresh install; a device
|
|
|
|
|
|
used before (an instance chosen) is shown the running version's on its first start
|
|
|
|
|
|
with this. In the app's language, else English.
|
2026-11-03 11:48:19 +01:00
|
|
|
|
- **D62 Train on the desktop as a snap** (the developer's, 3 Nov 2026, on the agent's
|
|
|
|
|
|
recommendation): to the Snap Store's edge channel with each release, as Learn's and
|
|
|
|
|
|
Todo's, built as Todo's in a clone of its own (`apps/train/snap/`, Todo's ADR 0033,
|
|
|
|
|
|
step 5), for train.halkyon.eu. D58 had released the APK alone, before the release
|
|
|
|
|
|
tool could build a snap from an app's own folder. The desktop records nothing and
|
|
|
|
|
|
connects to no device (*Consequences*); it shows the workouts with their maps, plans
|
|
|
|
|
|
routes and strength sessions, and keeps the statistics, with what the phones
|
|
|
|
|
|
recorded coming through the server. Its listing says so.
|
2026-11-03 22:02:06 +01:00
|
|
|
|
- **D63 The maps after the developer's review** (the developer's, 3 Nov 2026; the
|
|
|
|
|
|
labels' language on the agent's recommendation, chosen by the developer):
|
|
|
|
|
|
- **A workout's page as Strava's**: with a track, the map runs to the foot of the
|
|
|
|
|
|
screen under a sheet (its name first, then what was there before), which starts at
|
|
|
|
|
|
55 % of the page, snaps back there, goes down to 15 % and up to the app bar's foot.
|
|
|
|
|
|
Once it settles (200 ms), the map fits the whole track anew, eased over 450 ms, into
|
|
|
|
|
|
what the sheet and the round buttons leave free, with 32 px around and at least
|
|
|
|
|
|
96 px high (`track_camera.dart`, tested); pulled down, the map is larger and the
|
|
|
|
|
|
track fitted closer, as Strava's, whose video showed the camera moving only after
|
|
|
|
|
|
the sheet stops. Pulled up, the round buttons over the map fold into an app bar over
|
|
|
|
|
|
the sheet's last stretch: its colour and title come in, their rounds go, and the
|
|
|
|
|
|
arrow back turns a quarter into a chevron down that lowers the sheet again. From
|
|
|
|
|
|
expanded windows on, the map and the rest side by side (a 400 px pane, Material's
|
|
|
|
|
|
supporting pane, as the Maps tab's list and map, D60). Without a track or without
|
|
|
|
|
|
the map, the rest alone, as before. The page stays in Home (back leads there) but
|
|
|
|
|
|
opens on the root navigator, over the shell, so without the floating bar (D60 had
|
|
|
|
|
|
it run on beneath the bar).
|
|
|
|
|
|
- **What the map's source sees** ("Map by OpenFreeMap: it sees…", and the link to what
|
|
|
|
|
|
it keeps) moves behind halkyon_ui's question mark (`HelpButton`, which gained an
|
|
|
|
|
|
action beside OK for the link) at the end of the page's first line: it explains,
|
|
|
|
|
|
rather than being the person's own (D23, D50). The instance's privacy page doesn't
|
|
|
|
|
|
name the map's source yet; it should, now that the note is one tap away (open).
|
|
|
|
|
|
That the map can't be loaded stays in view, as it is a state.
|
|
|
|
|
|
- **The map's credit readable**: flutter_map's credit is built on
|
|
|
|
|
|
`flutter/material`, whose `Theme` is not the app's (`material_ui`'s, a class of its
|
|
|
|
|
|
own), so it drew the theme's text on the fallback theme's white. Every Train map now
|
|
|
|
|
|
has `mapAttribution`: on the theme's `surfaceContainerHigh`, in `onSurface`, its
|
|
|
|
|
|
button a tonal one, with a tooltip. `map_tiles.dart` was the only file reading
|
|
|
|
|
|
`flutter/material`'s `Theme`, and so chose the dark style of the instance's own map
|
|
|
|
|
|
(D50) never; it reads the app's now.
|
|
|
|
|
|
- **Places in the app's language**: OpenFreeMap's styles ask for the English name
|
|
|
|
|
|
(`name_en`) before the name on the spot; the style is rewritten as it is read
|
|
|
|
|
|
(`labelledIn`) to ask for `name:<language>` instead (OpenMapTiles' fields), the name
|
|
|
|
|
|
on the spot where there is none. English stays as the style has it, and the
|
|
|
|
|
|
instance's own map is named in the app's language by its server already (D50). A
|
|
|
|
|
|
place named only in its own language keeps its name (a "Tennisplatz" whose
|
|
|
|
|
|
OpenStreetMap entry has no English name stays one in English).
|
|
|
|
|
|
- **The route planner**: its hint ("Tap the map to add a waypoint…") moves from a card
|
|
|
|
|
|
over the map to a question mark below Undo. A new route's map opens at zoom 14 (a
|
|
|
|
|
|
town's district, as map apps open on where one is; Google Maps' "my location" is
|
|
|
|
|
|
about 15, Strava's route builder 13 to 14) at once where the phone was last known to
|
|
|
|
|
|
be (`lastPosition`, the newest of Android's providers, never asking for the
|
|
|
|
|
|
permission), else where the newest workout with a track started
|
|
|
|
|
|
(`lastWorkoutStart`, of the 5 newest), else on the region as before; the phone's
|
|
|
|
|
|
fix then sets the first waypoint as before (D44). From expanded windows on, the
|
|
|
|
|
|
route's details beside its map (400 px). It ran without the bar already (D60).
|
2026-11-03 23:14:02 +01:00
|
|
|
|
- **D64 A day's page, its figures, held charts and the weight** (the developer's
|
feat(train): weight, entered on Progress and read from Health Connect, sealed and synced (ADR 0030, D63)
A person's weights are each a sealed `body_weight` row, as the days and
the places: the weight in grams, when it was measured (UTC), where it
came from and, for Health Connect's, the record's id and app are the
payload; the server sees the space and the ciphertext alone.
Progress gets a Weight card after Your body: the latest and its day,
its line over time (held, each weight and its day), the plus to enter
one (a comma taken as a point, 20 to 400 kg, when measured, today by
default) and All, the list to delete one. The latest weight is also
Your body's, which the band's and the zones' figures work from. In
kilograms alone: Train is metric throughout and has no units setting
yet.
Health Connect's weight records are read in when switched on in its
screen (READ_WEIGHT, asked as the others): each record once, its id kept
in health_connect.json also after it is deleted here, never Train's own,
and none within 10 minutes and 0.1 kg of a weight entered in Train.
The migration (20261103204323683-body-weights):
- creates (or replaces, unchanged) `gen_random_uuid_v7()`, the
default of every UUID id, as each of Serverpod's migrations does;
- CREATE TABLE "body_weight": "id" uuid primary key defaulting to a
UUID v7, "spaceId" bigint (the sync space), "keyId" text (the key
that sealed it), "payload" bytea NOT NULL (the sealed content),
"nonce" bytea;
- CREATE INDEX "body_weight_space_idx" on "spaceId", for a space's
rows as the sync reads them;
- a foreign key from "spaceId" to "offline_sync_spaces"("id"),
ON DELETE CASCADE, so that an account's deletion takes its weights
with its space;
- the version rows of Train's and every module's migrations.
Nothing existing changes, so nothing is lost.
Tests: the row written and read back in its owner's space, and the
denials: another person reads none of them, and writing one into
another's space throws OfflineSyncSpaceMembershipException and leaves
it empty; an account's deletion takes them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-03 22:11:57 +01:00
|
|
|
|
feedback of 3 Nov 2026; the weight's sources the developer's answer, the rest the
|
|
|
|
|
|
agent's on that feedback):
|
|
|
|
|
|
- **A day over the tabs**: Home's day (`/days/<date>`) is a route of its own above
|
|
|
|
|
|
the shell, without the navigation bar, back at the top left, each section in a
|
|
|
|
|
|
`SectionCard` over the top gradient. A section's explanation (what Nightly
|
|
|
|
|
|
Recharge's figures mean, the nervous system's −10 to +10) sits behind
|
|
|
|
|
|
halkyon_ui's `HelpButton` beside its title; the person's own values stay in sight.
|
|
|
|
|
|
- **The night's chart**: the cause of the rows drawn over Nightly Recharge and the
|
|
|
|
|
|
skin temperature was a phase starting at or after the night's end: its right edge
|
|
|
|
|
|
was clamped to `left + 1 .. width` with `left + 1` above `width`, `clamp` threw
|
|
|
|
|
|
while painting, and the canvas was left translated for everything painted after it.
|
|
|
|
|
|
The left edge now stays inside the box first.
|
|
|
|
|
|
- **Polar's no-value marks**: Nightly Recharge writes −100 or −1 where it has no
|
|
|
|
|
|
value; `Recharge.known` drops what lies outside each figure's range (the nervous
|
|
|
|
|
|
system's status outside −10..10, the recovery level outside 1..6, rates, intervals
|
|
|
|
|
|
and deviations not above or at zero), and the page shows only what remains.
|
|
|
|
|
|
- **Charts held**: a long press on a line chart (`WorkoutChart`, shared by the day's,
|
|
|
|
|
|
the workouts' and the weight's) shows a line, a dot and the exact value under the
|
|
|
|
|
|
finger, and its time where the chart knows it, following the drag, with a tick of
|
|
|
|
|
|
haptic feedback as the press takes hold.
|
|
|
|
|
|
- **Sync in Settings**: Settings' first card says where syncing stands and offers
|
|
|
|
|
|
*Sync now*, halkyon_app's `SyncStatusCard` as Learn's and Todo's, the cog badged
|
|
|
|
|
|
when syncing fails.
|
|
|
|
|
|
- **Weight**: a sealed synced table, `body_weight`, as the days' and the places':
|
|
|
|
|
|
the weight, when it was measured, and where it came from are the payload (grams as
|
|
|
|
|
|
a whole number, the time in UTC, a Health Connect record's id and app), the
|
|
|
|
|
|
server sees only the space and the ciphertext. Its migration creates the table
|
|
|
|
|
|
(`id` a UUID v7 by default, `spaceId`, `keyId`, `payload` not null, `nonce`), an
|
|
|
|
|
|
index on `spaceId` for a space's rows, and a foreign key to `offline_sync_spaces`
|
|
|
|
|
|
cascading on delete, so that an account's deletion takes its weights with its
|
|
|
|
|
|
space; then the version rows of every module. Nothing existing is changed, so
|
|
|
|
|
|
nothing is lost. Entered on Progress's *Weight* card (the plus; a comma taken as
|
|
|
|
|
|
a point, 20 to 400 kg, when measured, today by default), with its line over time
|
|
|
|
|
|
and *All* to delete one; the latest weight is also *Your body*'s, which the
|
|
|
|
|
|
band's and the zones' figures work from. Health Connect's weight records are read
|
|
|
|
|
|
in when switched on in its screen (its own permission, asked as the others), each
|
|
|
|
|
|
record once (its id kept, also after deletion), not Train's own, and not one
|
2026-11-04 00:15:55 +01:00
|
|
|
|
within 10 minutes and 0.1 kg of a weight entered here. **In kilograms or pounds**
|
|
|
|
|
|
(the developer's call, 4 Nov 2026, weight alone): every weight is kept in
|
|
|
|
|
|
kilograms, and shown and entered in the person's unit, *Your body*'s *Weight in*
|
|
|
|
|
|
(kg | lb, the profile's `weightUnit`), else their region's (pounds in the US,
|
|
|
|
|
|
Liberia and Myanmar, `WeightUnit.ofRegion`, from the app's locale); a pound is
|
|
|
|
|
|
0.45359237 kg, the entry's range the same 20 to 400 kg (44 to 880 lb). A weight
|
|
|
|
|
|
left untouched in *Your body* is saved as it was, not rounded through pounds.
|
|
|
|
|
|
The other figures stay metric; a units setting for all of them would come on
|
|
|
|
|
|
its own. A profile saved on Train 0.2.0, which doesn't know the field, loses
|
|
|
|
|
|
the unit, and the region's applies again until it is picked anew.
|
2026-10-30 12:56:00 +01:00
|
|
|
|
|
docs(adr): ADR 0030 (Halkyon Train) and ADR 0031 (the apps' icons)
ADR 0030 records what Train does and where its data comes from: running,
cycling and rowing, in the order recording, intervals, routes, music; the
Polar Loop taken over through Polar's BLE SDK; music from Jellyfin;
devices on phones through flutter_reactive_ble; Health Connect both ways
through the health package; every row sealed; the WP 5000 an FTMS rower,
as scanned; no WaterRower USB driver for now; the Loop's firmware from
Polar directly or through the instance; flutter_map, the instance's map
source, BRouter as a sidecar, offline use and working with CoMaps; and
Train's shell after Todo's.
ADR 0031 gives the apps' icons one family: Lucide glyphs fading from
cobalt through a colour between into each app's own, Learn's graduation
cap, Todo's list, Train's bike, Meditate's circle-dot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-28 00:43:21 +01:00
|
|
|
|
## 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.
|