feat(train): a ride's watts, cadences and wheel to Health Connect, and auto-pause by the wheel (ADR 0030, D51)

Health Connect gets the power meter's watts, the crank's cadence
(CyclingPedalingCadenceRecord), a run's step cadence (StepsCadenceRecord)
and, where the wheel measured the ride, the wheel's speed. The cadences'
permissions are asked with writing's and each record is left out where
its permission isn't granted; healthRecordsVersion in the fingerprint
writes every workout once more. On a bike, the wheel's speed decides
auto-pause while its sensor sends, GPS's once it has been silent 5 s.

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-29 17:53:54 +01:00
commit 64366d5420
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 400 additions and 29 deletions

View file

@ -368,11 +368,18 @@ Map<HealthRecordKind, List<String>> _without(
key: gone,
};
/// A fingerprint of [payload] that stays the same across runs (FNV-1a, 32
/// bits): only whether a workout changed since it was written, nothing to
/// keep secret.
/// What [healthRecordsOf] gives, counted up as it gives more (2: the
/// cadences, the power meter's watts and the wheel's speed, D51), so that
/// workouts written before are written again.
const healthRecordsVersion = 2;
/// A fingerprint of [payload] and [healthRecordsVersion] that stays the
/// same across runs (FNV-1a, 32 bits): only whether a workout, or what Train
/// writes of it, changed since it was written, nothing to keep secret. The
/// first version's fingerprints were of the payload alone.
int _fingerprintOf(ByteData payload) {
var hash = 0x811c9dc5;
hash = ((hash ^ healthRecordsVersion) * 0x01000193) & 0xffffffff;
for (var i = 0; i < payload.lengthInBytes; i++) {
hash = ((hash ^ payload.getUint8(i)) * 0x01000193) & 0xffffffff;
}

View file

@ -33,6 +33,12 @@ enum HealthRecordKind() {
heartRate,
speed,
power,
/// A ride's crank cadence (D51).
pedalling,
/// A run's or walk's steps a minute (D51).
stepsCadence,
distance,
elevation,
}

View file

@ -1,14 +1,16 @@
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The workout [id] as Health Connect's records (ADR 0030, D31), each at
/// [version]: its session (activity, name, notes, its pauses, its route as
/// its page draws it), its heart rate, speed and the rower's watts as
/// samples in records of [maxChunkLength] at most, its distance and
/// elevation gained as totals. Each record's client ID is the workout's,
/// its page draws it), its heart rate, speed (the wheel's where the
/// distance is, D51), the rower's or the power meter's watts, the crank's
/// cadence and the steps' as samples in records of [maxChunkLength] at
/// most, its distance and elevation gained as totals. Each record's client ID is the workout's,
/// with its kind and its stretch's number after it.
List<HealthRecord> healthRecordsOf(
String id,
@ -34,16 +36,25 @@ List<HealthRecord> healthRecordsOf(
pauses.add((pausedAt, end));
}
final speeds = [
for (final piece in analysis.track)
for (var i = 1; i < piece.length; i++)
if (piece[i].time.difference(piece[i - 1].time) case final gap
when gap > Duration.zero)
(
piece[i].time,
metresBetween(piece[i - 1], piece[i]) / (gap.inMilliseconds / 1000),
),
];
final speeds = rodeByWheel(recording)
? _wheelSpeeds(recording)
: [
for (final piece in analysis.track)
for (var i = 1; i < piece.length; i++)
if (piece[i].time.difference(piece[i - 1].time) case final gap
when gap > Duration.zero)
(
piece[i].time,
metresBetween(piece[i - 1], piece[i]) /
(gap.inMilliseconds / 1000),
),
];
final steps = [
for (final chunk in series)
if (chunk.kind == SeriesKind.cadence)
for (final (i, time) in chunk.absoluteTimes.indexed)
if (chunk.columns['spm']?[i] case final spm?) (time, spm),
]..sort((a, b) => a.$1.compareTo(b.$1));
/// [samples] of [kind] in records of [maxChunkLength] at most, numbered
/// by their stretch from the start.
@ -99,7 +110,12 @@ List<HealthRecord> healthRecordsOf(
...stretched(HealthRecordKind.power, [
for (final sample in recording.rowing)
if (sample.watts case final watts?) (sample.time, watts),
for (final reading in recording.power) (reading.time, reading.value),
]),
...stretched(HealthRecordKind.pedalling, [
for (final reading in recording.pedalling) (reading.time, reading.value),
]),
...stretched(HealthRecordKind.stepsCadence, steps),
if (content.distanceMetres case final metres? when metres > 0)
total(HealthRecordKind.distance, metres),
if (analysis.elevationGainMetres case final metres? when metres > 0)
@ -107,6 +123,37 @@ List<HealthRecord> healthRecordsOf(
];
}
/// The wheel's speed (D51), in metres a second, over each stretch of 5 s
/// at least between its readings, those while paused left out.
List<(DateTime, double)> _wheelSpeeds(Recording recording) {
final mm = recording.wheelMm;
if (mm == null) {
return const [];
}
final pauses = recording.pauses;
var next = 0;
var paused = false;
Reading? from;
final speeds = <(DateTime, double)>[];
for (final reading in recording.wheel) {
while (next < pauses.length && !pauses[next].time.isAfter(reading.time)) {
paused = pauses[next++].paused;
from = null;
}
if (paused) {
continue;
}
final start = from ??= reading;
final gap = reading.time.difference(start.time);
if (gap >= const Duration(seconds: 5)) {
final metres = (reading.value - start.value) * mm / 1000;
speeds.add((reading.time, metres / (gap.inMilliseconds / 1000)));
from = reading;
}
}
return speeds;
}
/// [records]' client IDs by their kind, as a deletion names them.
Map<HealthRecordKind, List<String>> clientIdsOf(List<HealthRecord> records) {
final ids = <HealthRecordKind, List<String>>{};