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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 15:50:36 +01:00
commit de5fd43e06
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,6 +1,6 @@
# ADR 0030: Halkyon Train, what it does and where its data comes from
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D24 below). The shell (D12)
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D27 below). The shell (D12)
is built; recording (D13–D24) is being built.
- **Numbering:** 0025 is held by the federation draft (stashed), 0026–0029 by the .NET
rewrite on the local branch `rewrite-dotnet`, abandoned on 27 Oct; Train takes the next
@ -385,6 +385,51 @@ Strava. What was found, 27 Oct:
the elevation counts a climb once it passes 3 m above where it last turned, so a
steady climb's last metres short of the next 3 are not counted (a 20 m hill reads as
18 or 19 m): the simple filter, to be compared with recorded runs.
- **D25 iOS recording waits for a Mac** (the developer's; 28 Oct): 5e and 5f come before
5d, which moves to the end of step 5. iOS's side (CoreLocation with background
location, the pedometer, a Live Activity for the notification) needs Xcode, which runs
on macOS alone, and this machine has none; written unbuilt it would look done and not
compile, and CI's runner has no Xcode either. The heart-rate strap and the WP 5000 are
built and tested on Android here meanwhile.
- **D26 The recorder service talks to the strap itself** (recommended, the developer's;
28 Oct; over `flutter_reactive_ble` for finding the strap beside it, two stacks touching
one strap for one screen's sake, and over D5's Dart alone, whose heart rate would stop
where a swipe stops the Flutter engine, D18, while GPS records on): Android's own
Bluetooth API in Kotlin, as OpenTracks has it, connects during a recording and writes
the `h` lines, so heart rate is recorded as long as positions are; finding and choosing
a strap goes through the recorder's channel too, so Train has one Bluetooth stack and
no Bluetooth package. This revises D5: its open licence holds, as nothing third-party
is left, and the platform's API sits behind `RecorderPlatform` as the library would
have behind Train's own interface. The profiles' parsers (Heart Rate Measurement now,
FTMS's Rower Data in 5f) are Kotlin, tested on the JVM on recorded frames, and iOS (5d)
needs them again in Swift.
- **D27 The strap, from choosing it to the workout's page** (the developer's; 28 Oct): the
Record sheet's *Heart rate* row names the strap or "No strap" and opens a sheet that
looks for devices advertising the Heart Rate Service (0x180D), the nearest first;
the one chosen is remembered on the device, in the person's folder (`strap.json`, its
address and name, never synced: each phone finds it anew), *Forget strap* letting it
go. Nearby devices are asked for as the sheet first looks (D19), declared never to
locate (Android 12 on; before, scanning needs location). No bonding: the service
connects by address. A remembered strap records with every workout unless switched
off on the sheet for one; the recording doesn't wait for it. The recording screen
shows the heart rate ("–" while connecting), and "Strap lost" where GPS's state shows
while it is gone; the notification stays time and distance, as a rate changing each
second would repost it each second. A lost strap is asked for again in the background
for as long as the recording runs; the minutes without it stay a gap. Only the rate is
kept (`h` lines), not the R-R intervals, until something uses them. The workout's
page: the heart's average and highest rate while moving among the totals, each
split's average, and its chart over the distance (over the moving time without a
track). The track is coloured by heart-rate zones once Train's settings hold the
person's maximum rate, as colouring by each workout's own range paints an easy run
as red as a race.
Built so (28 Oct), with choices of mine for the developer to look at: the file gains
`strap <time> connecting|connected|lost` lines (the format's version stays 1, as a
reader before skipped lines it didn't know); a rate older than 5 s shows as "–"; the
first attempt connects directly, falling back on Android's background connection
once it fails and after every loss; a sighting stronger than −65 dBm is "close by";
the heart-rate chart averages the rates of each stretch (as the pace chart's
stretches, or 10 s, at least, over the time). Not yet tried with a real strap.
## Consequences