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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 15:11:20 +01:00
commit 5c13e6f3c5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 1209 additions and 62 deletions

View file

@ -519,5 +519,26 @@
"workoutExportTitle": "Training exportieren",
"workoutExportGpxHint": "Die Strecke, für Karten und andere Apps",
"workoutExportTcxHint": "Runden, Puls und Kadenz, für die meisten Trainingsseiten",
"workoutExportFitHint": "Was Strava, Garmin und intervals.icu am besten lesen"
"workoutExportFitHint": "Was Strava, Garmin und intervals.icu am besten lesen",
"recordSpeedSensor": "Geschwindigkeits- und Trittfrequenzsensor",
"recordNoSpeedSensor": "Keiner gewählt",
"recordPowerMeter": "Leistungsmesser",
"recordNoPowerMeter": "Keiner gewählt",
"speedSensorSheetTitle": "Geschwindigkeits- und Trittfrequenzsensor",
"speedSensorSearching": "Suche Sensoren in der Nähe …",
"speedSensorSearchingHint": "Dreh das Rad oder die Kurbel, um ihn zu wecken. Einer, den eine andere App hält, ist nicht zu finden: Schließ diese App zuerst.",
"speedSensorUnnamed": "Sensor ohne Namen",
"speedSensorForget": "Sensor vergessen",
"speedSensorRefused": "Train braucht Geräte in der Nähe, um deinen Sensor zu finden. Du kannst das in den Systemeinstellungen erlauben.",
"powerMeterSheetTitle": "Leistungsmesser",
"powerMeterSearching": "Suche Leistungsmesser in der Nähe …",
"powerMeterSearchingHint": "Dreh die Kurbel, um ihn zu wecken. Einer, den eine andere App hält, ist nicht zu finden: Schließ diese App zuerst.",
"powerMeterUnnamed": "Leistungsmesser ohne Namen",
"powerMeterForget": "Leistungsmesser vergessen",
"powerMeterRefused": "Train braucht Geräte in der Nähe, um deinen Leistungsmesser zu finden. Du kannst das in den Systemeinstellungen erlauben.",
"recordingCadence": "Umdrehungen pro Minute",
"workoutPedalling": "Trittfrequenz",
"workoutAverageCadence": "durchschnittliche Trittfrequenz",
"workoutNormalisedPower": "normalisierte Leistung",
"workoutRpmValue": "{rpm} U/min"
}

View file

@ -658,5 +658,27 @@
"workoutExportTitle": "Export the workout",
"workoutExportGpxHint": "The track, for maps and other apps",
"workoutExportTcxHint": "Laps, heart rate and cadence, for most training sites",
"workoutExportFitHint": "What Strava, Garmin and intervals.icu read best"
"workoutExportFitHint": "What Strava, Garmin and intervals.icu read best",
"recordSpeedSensor": "Speed and cadence sensor",
"recordNoSpeedSensor": "None chosen",
"recordPowerMeter": "Power meter",
"recordNoPowerMeter": "None chosen",
"speedSensorSheetTitle": "Speed and cadence sensor",
"speedSensorSearching": "Looking for speed and cadence sensors nearby…",
"speedSensorSearchingHint": "Turn the wheel or the crank to wake it. One another app holds can't be found: close that app first.",
"speedSensorUnnamed": "Unnamed sensor",
"speedSensorForget": "Forget sensor",
"speedSensorRefused": "Train needs nearby devices to find your sensor. You can allow it in the system's settings.",
"powerMeterSheetTitle": "Power meter",
"powerMeterSearching": "Looking for power meters nearby…",
"powerMeterSearchingHint": "Turn the crank to wake it. One another app holds can't be found: close that app first.",
"powerMeterUnnamed": "Unnamed power meter",
"powerMeterForget": "Forget power meter",
"powerMeterRefused": "Train needs nearby devices to find your power meter. You can allow it in the system's settings.",
"recordingCadence": "turns a minute",
"workoutPedalling": "Cadence",
"workoutAverageCadence": "average cadence",
"workoutNormalisedPower": "normalised power",
"workoutRpmValue": "{rpm} rpm",
"@workoutRpmValue": {"placeholders": {"rpm": {"type": "int"}}}
}

View file

@ -3141,6 +3141,132 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'What Strava, Garmin and intervals.icu read best'**
String get workoutExportFitHint;
/// No description provided for @recordSpeedSensor.
///
/// In en, this message translates to:
/// **'Speed and cadence sensor'**
String get recordSpeedSensor;
/// No description provided for @recordNoSpeedSensor.
///
/// In en, this message translates to:
/// **'None chosen'**
String get recordNoSpeedSensor;
/// No description provided for @recordPowerMeter.
///
/// In en, this message translates to:
/// **'Power meter'**
String get recordPowerMeter;
/// No description provided for @recordNoPowerMeter.
///
/// In en, this message translates to:
/// **'None chosen'**
String get recordNoPowerMeter;
/// No description provided for @speedSensorSheetTitle.
///
/// In en, this message translates to:
/// **'Speed and cadence sensor'**
String get speedSensorSheetTitle;
/// No description provided for @speedSensorSearching.
///
/// In en, this message translates to:
/// **'Looking for speed and cadence sensors nearby…'**
String get speedSensorSearching;
/// No description provided for @speedSensorSearchingHint.
///
/// In en, this message translates to:
/// **'Turn the wheel or the crank to wake it. One another app holds can\'t be found: close that app first.'**
String get speedSensorSearchingHint;
/// No description provided for @speedSensorUnnamed.
///
/// In en, this message translates to:
/// **'Unnamed sensor'**
String get speedSensorUnnamed;
/// No description provided for @speedSensorForget.
///
/// In en, this message translates to:
/// **'Forget sensor'**
String get speedSensorForget;
/// No description provided for @speedSensorRefused.
///
/// In en, this message translates to:
/// **'Train needs nearby devices to find your sensor. You can allow it in the system\'s settings.'**
String get speedSensorRefused;
/// No description provided for @powerMeterSheetTitle.
///
/// In en, this message translates to:
/// **'Power meter'**
String get powerMeterSheetTitle;
/// No description provided for @powerMeterSearching.
///
/// In en, this message translates to:
/// **'Looking for power meters nearby…'**
String get powerMeterSearching;
/// No description provided for @powerMeterSearchingHint.
///
/// In en, this message translates to:
/// **'Turn the crank to wake it. One another app holds can\'t be found: close that app first.'**
String get powerMeterSearchingHint;
/// No description provided for @powerMeterUnnamed.
///
/// In en, this message translates to:
/// **'Unnamed power meter'**
String get powerMeterUnnamed;
/// No description provided for @powerMeterForget.
///
/// In en, this message translates to:
/// **'Forget power meter'**
String get powerMeterForget;
/// No description provided for @powerMeterRefused.
///
/// In en, this message translates to:
/// **'Train needs nearby devices to find your power meter. You can allow it in the system\'s settings.'**
String get powerMeterRefused;
/// No description provided for @recordingCadence.
///
/// In en, this message translates to:
/// **'turns a minute'**
String get recordingCadence;
/// No description provided for @workoutPedalling.
///
/// In en, this message translates to:
/// **'Cadence'**
String get workoutPedalling;
/// No description provided for @workoutAverageCadence.
///
/// In en, this message translates to:
/// **'average cadence'**
String get workoutAverageCadence;
/// No description provided for @workoutNormalisedPower.
///
/// In en, this message translates to:
/// **'normalised power'**
String get workoutNormalisedPower;
/// No description provided for @workoutRpmValue.
///
/// In en, this message translates to:
/// **'{rpm} rpm'**
String workoutRpmValue(int rpm);
}
class _AppLocalizationsDelegate

View file

@ -1889,4 +1889,74 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get workoutExportFitHint =>
'Was Strava, Garmin und intervals.icu am besten lesen';
@override
String get recordSpeedSensor => 'Geschwindigkeits- und Trittfrequenzsensor';
@override
String get recordNoSpeedSensor => 'Keiner gewählt';
@override
String get recordPowerMeter => 'Leistungsmesser';
@override
String get recordNoPowerMeter => 'Keiner gewählt';
@override
String get speedSensorSheetTitle =>
'Geschwindigkeits- und Trittfrequenzsensor';
@override
String get speedSensorSearching => 'Suche Sensoren in der Nähe …';
@override
String get speedSensorSearchingHint =>
'Dreh das Rad oder die Kurbel, um ihn zu wecken. Einer, den eine andere App hält, ist nicht zu finden: Schließ diese App zuerst.';
@override
String get speedSensorUnnamed => 'Sensor ohne Namen';
@override
String get speedSensorForget => 'Sensor vergessen';
@override
String get speedSensorRefused =>
'Train braucht Geräte in der Nähe, um deinen Sensor zu finden. Du kannst das in den Systemeinstellungen erlauben.';
@override
String get powerMeterSheetTitle => 'Leistungsmesser';
@override
String get powerMeterSearching => 'Suche Leistungsmesser in der Nähe …';
@override
String get powerMeterSearchingHint =>
'Dreh die Kurbel, um ihn zu wecken. Einer, den eine andere App hält, ist nicht zu finden: Schließ diese App zuerst.';
@override
String get powerMeterUnnamed => 'Leistungsmesser ohne Namen';
@override
String get powerMeterForget => 'Leistungsmesser vergessen';
@override
String get powerMeterRefused =>
'Train braucht Geräte in der Nähe, um deinen Leistungsmesser zu finden. Du kannst das in den Systemeinstellungen erlauben.';
@override
String get recordingCadence => 'Umdrehungen pro Minute';
@override
String get workoutPedalling => 'Trittfrequenz';
@override
String get workoutAverageCadence => 'durchschnittliche Trittfrequenz';
@override
String get workoutNormalisedPower => 'normalisierte Leistung';
@override
String workoutRpmValue(int rpm) {
return '$rpm U/min';
}
}

View file

@ -1877,6 +1877,76 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get workoutExportFitHint =>
'What Strava, Garmin and intervals.icu read best';
@override
String get recordSpeedSensor => 'Speed and cadence sensor';
@override
String get recordNoSpeedSensor => 'None chosen';
@override
String get recordPowerMeter => 'Power meter';
@override
String get recordNoPowerMeter => 'None chosen';
@override
String get speedSensorSheetTitle => 'Speed and cadence sensor';
@override
String get speedSensorSearching =>
'Looking for speed and cadence sensors nearby…';
@override
String get speedSensorSearchingHint =>
'Turn the wheel or the crank to wake it. One another app holds can\'t be found: close that app first.';
@override
String get speedSensorUnnamed => 'Unnamed sensor';
@override
String get speedSensorForget => 'Forget sensor';
@override
String get speedSensorRefused =>
'Train needs nearby devices to find your sensor. You can allow it in the system\'s settings.';
@override
String get powerMeterSheetTitle => 'Power meter';
@override
String get powerMeterSearching => 'Looking for power meters nearby…';
@override
String get powerMeterSearchingHint =>
'Turn the crank to wake it. One another app holds can\'t be found: close that app first.';
@override
String get powerMeterUnnamed => 'Unnamed power meter';
@override
String get powerMeterForget => 'Forget power meter';
@override
String get powerMeterRefused =>
'Train needs nearby devices to find your power meter. You can allow it in the system\'s settings.';
@override
String get recordingCadence => 'turns a minute';
@override
String get workoutPedalling => 'Cadence';
@override
String get workoutAverageCadence => 'average cadence';
@override
String get workoutNormalisedPower => 'normalised power';
@override
String workoutRpmValue(int rpm) {
return '$rpm rpm';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -143,8 +143,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
recording.platform,
train.workouts,
recording.file,
recording.straps,
recording.rowers,
recording.devices,
notice: (live) => noticeOf(
AppLocalizations.of(context),
Localizations.localeOf(context).toString(),

View file

@ -46,13 +46,13 @@ import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// Where a device records workouts (ADR 0030, D13, D18, D27, D28): its
/// recorder, and the person's recording file, strap and rower in their
/// folder, and the activities they have announced (D49).
/// recorder, and the person's recording file, their devices (strap, rower,
/// the bike's sensors, D49) in their folder, and the activities they have
/// announced (D49).
typedef RecordingDevice = ({
RecorderPlatform platform,
File file,
DeviceStore straps,
DeviceStore rowers,
Map<DeviceKind, DeviceStore> devices,
AnnounceStore announces,
});
@ -452,8 +452,15 @@ Future<TrainPerson> _openPersonData(
? (
platform: recorder,
file: File(p.join(folder.path, 'recording.txt')),
straps: DeviceStore(File(p.join(folder.path, 'strap.json'))),
rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))),
devices: {
for (final (kind, name) in const [
(DeviceKind.strap, 'strap'),
(DeviceKind.rower, 'rower'),
(DeviceKind.speedSensor, 'speed_sensor'),
(DeviceKind.powerMeter, 'power_meter'),
])
kind: DeviceStore(File(p.join(folder.path, '$name.json'))),
},
announces: AnnounceStore(
File(p.join(folder.path, 'announce.json')),
),

View file

@ -25,7 +25,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// it away once stopped. As it opens, it finds a recording still running (the
/// app was gone, the service not), one cut off, or one stopped and not saved.
/// It remembers the person's heart-rate strap, which a recording uses unless
/// it is switched off for it (D27), and their rower, which a row needs
/// it is switched off for it (D27), the bike's sensors a ride uses (D49),
/// and their rower, which a row needs
/// (D28); their Loop, where they have one, gives the heart rate while no
/// strap does (D41). A recording may run an interval plan (D42), which the
/// recorder runs, its cues given there, and follow a route (D45), which the
@ -40,9 +41,9 @@ class RecordingBloc(
/// The person's recording file, in their folder.
final File _file,
/// The person's strap and rower on this device, in their folder.
final DeviceStore _straps,
final DeviceStore _rowers, {
/// The person's devices on this device, each kind in a file of their
/// folder: the strap, the rower, the bike's sensors (D49).
final Map<DeviceKind, DeviceStore> _devices, {
/// What the notification shows of [LiveRecording], in the app's language.
required final RecorderNotice Function(LiveRecording live) notice,
@ -120,26 +121,23 @@ class RecordingBloc(
/// What the notification shows now, so it is told only of changes.
(String, String, bool)? _shown;
DeviceStore _storeOf(DeviceKind kind) => switch (kind) {
DeviceKind.strap => _straps,
DeviceKind.rower => _rowers,
};
DeviceStore _storeOf(DeviceKind kind) => _devices[kind]!;
Future<void> _onOpened(
RecordingEventOpened event,
Emitter<RecordingState> emit,
) async {
final lastActivity = await _lastActivity();
final strap = _straps.read();
final rower = _rowers.read();
final devices = {
for (final MapEntry(:key, :value) in _devices.entries) key: ?value.read(),
};
if (await _platform.isRecording()) {
_follow();
emit(
RecordingState(
phase: RecordingPhase.recording,
lastActivity: lastActivity,
strap: strap,
rower: rower,
devices: devices,
),
);
return;
@ -154,8 +152,7 @@ class RecordingBloc(
},
lastActivity: lastActivity,
recording: kept,
strap: strap,
rower: rower,
devices: devices,
),
);
}
@ -238,6 +235,12 @@ class RecordingBloc(
notice: notice,
strap: event.useStrap ? state.strap?.address : null,
rower: rows ? rower?.address : null,
speedSensor: activity == Activity.cycling
? state.speedSensor?.address
: null,
powerMeter: activity == Activity.cycling
? state.powerMeter?.address
: null,
loop: loop?.call(),
plan: plan,
route: route,
@ -251,8 +254,7 @@ class RecordingBloc(
phase: RecordingPhase.recording,
lastActivity: activity,
live: liveOf(Recording(activity: activity, startedAt: now), now),
strap: state.strap,
rower: state.rower,
devices: state.devices,
),
);
}
@ -269,8 +271,7 @@ class RecordingBloc(
phase: recording == null ? RecordingPhase.idle : RecordingPhase.stopped,
lastActivity: state.lastActivity,
recording: recording,
strap: state.strap,
rower: state.rower,
devices: state.devices,
),
);
}
@ -297,8 +298,7 @@ class RecordingBloc(
RecordingState(
phase: RecordingPhase.idle,
lastActivity: recording.activity,
strap: state.strap,
rower: state.rower,
devices: state.devices,
),
);
}
@ -312,8 +312,7 @@ class RecordingBloc(
RecordingState(
phase: RecordingPhase.idle,
lastActivity: state.lastActivity,
strap: state.strap,
rower: state.rower,
devices: state.devices,
),
);
}
@ -440,11 +439,15 @@ final class const RecordingState({
/// Whether a row was to start without a rower chosen.
final bool rowerMissing = false,
/// The person's strap and rower on this device, if they chose them (D27,
/// D28).
final KnownDevice? strap,
final KnownDevice? rower,
/// The person's devices on this device, those they chose: the strap and
/// the rower (D27, D28), the bike's sensors (D49).
final Map<DeviceKind, KnownDevice> devices = const {},
}) with Equatable {
KnownDevice? get strap => devices[DeviceKind.strap];
KnownDevice? get rower => devices[DeviceKind.rower];
KnownDevice? get speedSensor => devices[DeviceKind.speedSensor];
KnownDevice? get powerMeter => devices[DeviceKind.powerMeter];
RecordingState copyWith({
LiveRecording? live,
bool? locationRefused,
@ -460,8 +463,7 @@ final class const RecordingState({
saveFailed: saveFailed ?? this.saveFailed,
bluetoothRefused: bluetoothRefused ?? this.bluetoothRefused,
rowerMissing: rowerMissing ?? this.rowerMissing,
strap: strap,
rower: rower,
devices: devices,
);
RecordingState _withDevice(DeviceKind kind, KnownDevice? device) =>
@ -474,8 +476,11 @@ final class const RecordingState({
saveFailed: saveFailed,
bluetoothRefused: bluetoothRefused,
rowerMissing: rowerMissing,
strap: kind == DeviceKind.strap ? device : strap,
rower: kind == DeviceKind.rower ? device : rower,
devices: {
for (final MapEntry(:key, :value) in devices.entries)
if (key != kind) key: value,
kind: ?device,
},
);
@override
@ -488,8 +493,7 @@ final class const RecordingState({
saveFailed,
bluetoothRefused,
rowerMissing,
strap,
rower,
devices,
];
}

View file

@ -15,6 +15,12 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
static const _scans = {
DeviceKind.strap: EventChannel('app.halkyon.train/recorder/straps'),
DeviceKind.rower: EventChannel('app.halkyon.train/recorder/rowers'),
DeviceKind.speedSensor: EventChannel(
'app.halkyon.train/recorder/speedSensors',
),
DeviceKind.powerMeter: EventChannel(
'app.halkyon.train/recorder/powerMeters',
),
};
@override
@ -41,6 +47,8 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
Map<String, Object?>? route,
Map<String, Object?>? music,
Map<String, Object?>? announce,
String? speedSensor,
String? powerMeter,
}) => _channel.invokeMethod<void>('start', {
'path': path,
'activity': activity.name,
@ -53,6 +61,8 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
'route': route == null ? null : jsonEncode(route),
'music': music == null ? null : jsonEncode(music),
'announce': announce == null ? null : jsonEncode(announce),
'speedSensor': speedSensor,
'powerMeter': powerMeter,
});
@override

View file

@ -57,6 +57,12 @@ LiveRecording liveOf(Recording recording, DateTime now) {
final lastRowing = recording.rowing.lastOrNull;
final rowingFresh =
lastRowing != null && now.difference(lastRowing.time) <= freshHeartRate;
int? fresh(List<Reading> readings) => switch (readings.lastOrNull) {
final reading?
when !paused && now.difference(reading.time) <= freshHeartRate =>
reading.value,
_ => null,
};
final track = [
for (final fix in recording.fixes)
if (fix.accuracyCm <= maxAccuracyCm)
@ -87,7 +93,10 @@ LiveRecording liveOf(Recording recording, DateTime now) {
rower: recording.rowers.lastOrNull?.state,
split: paused ? null : _splitOf(recording, now),
strokeRate: rowingFresh && !paused ? lastRowing.strokeRate : null,
watts: rowingFresh && !paused ? lastRowing.watts : null,
watts: rowingFresh && !paused ? lastRowing.watts : fresh(recording.power),
bikeSensors:
recording.speedSensors.isNotEmpty || recording.powerMeters.isNotEmpty,
cadence: fresh(recording.pedalling),
plan: _planOf(recording, now),
route: _routeOf(recording, track.lastOrNull),
track: track,

View file

@ -229,6 +229,18 @@ List<SeriesChunk> seriesOf(Recording recording) {
final pauses = recording.pauses;
final rowing = recording.rowing;
final pressures = recording.pressures;
List<SeriesChunk> readings(
SeriesKind kind,
String column,
List<Reading> readings,
) => chunksOf(
kind,
start: start,
times: times(readings.map((reading) => reading.time)),
columns: {
column: [for (final reading in readings) reading.value],
},
);
return [
...chunksOf(
SeriesKind.position,
@ -284,6 +296,9 @@ List<SeriesChunk> seriesOf(Recording recording) {
'dpa': [for (final pressure in pressures) pressure.decipascals],
},
),
...readings(SeriesKind.wheel, 'revs', recording.wheel),
...readings(SeriesKind.pedalling, 'rpm', recording.pedalling),
...readings(SeriesKind.power, 'watts', recording.power),
...chunksOf(
SeriesKind.pauses,
start: start,

View file

@ -50,6 +50,13 @@ enum DeviceKind() {
/// A rower (the Fitness Machine Service).
rower,
/// A bike's speed and cadence sensor (the Cycling Speed and Cadence
/// Service, D49).
speedSensor,
/// A bike's power meter (the Cycling Power Service, D49).
powerMeter,
}
/// What the recording's notification shows (D21), in the app's language.
@ -118,6 +125,10 @@ abstract interface class RecorderPlatform() {
/// The words each distance passed is announced in (D49,
/// `recorderAnnouncementsOf`); null: none is.
Map<String, Object?>? announce,
/// The bike's speed and cadence sensor and power meter it reads (D49).
String? speedSensor,
String? powerMeter,
});
/// Pauses, or goes on, by hand.

View file

@ -29,6 +29,11 @@ const recordingFileMagic = 'halkyon-train-recording';
/// strap <time> connecting|connected|lost
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
/// rower <time> connecting|connected|lost
/// w <time> <the wheel's revolutions since its first frame>
/// c <time> <the crank's turns a minute>
/// pw <time> <watts>
/// csc <time> connecting|connected|lost
/// power <time> connecting|connected|lost
/// loop <time> connecting|connected|lost
/// plan <time> <the plan's payload, JSON>
/// step <time> <its index in the plan's run>|done
@ -72,6 +77,11 @@ Recording? readRecording(String text) {
final loops = <LinkMark>[];
final rowing = <RowingSample>[];
final rowers = <LinkMark>[];
final wheel = <Reading>[];
final pedalling = <Reading>[];
final power = <Reading>[];
final speedSensors = <LinkMark>[];
final powerMeters = <LinkMark>[];
PlanContent? plan;
int? planMaxHeartRate;
final planSteps = <StepMark>[];
@ -166,14 +176,27 @@ Recording? readRecording(String text) {
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
);
case [final link, _, final name]
when link == 'strap' || link == 'rower' || link == 'loop':
when const {'strap', 'rower', 'loop', 'csc', 'power'}.contains(
link,
):
if (LinkState.values.asNameMap()[name] case final state?) {
(switch (link) {
'strap' => straps,
'loop' => loops,
'csc' => speedSensors,
'power' => powerMeters,
_ => rowers,
}).add(LinkMark(time: time, state: state));
}
case [final kind, _, final value]
when kind == 'w' || kind == 'c' || kind == 'pw':
if (int.tryParse(value) case final value?) {
(switch (kind) {
'w' => wheel,
'c' => pedalling,
_ => power,
}).add(Reading(time: time, value: value));
}
case ['r', _, final rate, final strokes, final metres, final watts]:
rowing.add(
RowingSample(
@ -200,6 +223,11 @@ Recording? readRecording(String text) {
loops: loops,
rowing: rowing,
rowers: rowers,
wheel: wheel,
pedalling: pedalling,
power: power,
speedSensors: speedSensors,
powerMeters: powerMeters,
plan: plan,
planSteps: planSteps,
planMaxHeartRate: planMaxHeartRate,

View file

@ -113,6 +113,11 @@ final class const LiveRecording({
final double? strokeRate,
final int? watts,
/// On a bike with its sensors (D49): whether it has one, and the crank's
/// cadence of its last few seconds; its watts are [watts].
final bool bikeSensors = false,
final int? cadence,
/// The plan it runs; null without one (D42).
final LivePlan? plan,
@ -142,6 +147,8 @@ final class const LiveRecording({
split,
strokeRate,
watts,
bikeSensors,
cadence,
plan,
route,
track,

View file

@ -41,6 +41,17 @@ final class const Pressure({
List<Object?> get props => [time, decipascals];
}
/// A reading of one of the bike's sensors at [time] (D49): the wheel's
/// revolutions since its first frame, the crank's turns a minute, or the
/// power meter's watts.
final class const Reading({
required final DateTime time,
required final int value,
}) with Equatable {
@override
List<Object?> get props => [time, value];
}
/// A frame of the rower's (D28): its stroke rate, and its strokes and metres
/// since the recording's first frame; each null where the frame lacks it.
final class const RowingSample({
@ -161,6 +172,15 @@ final class const Recording({
final List<RowingSample> rowing = const [],
final List<LinkMark> rowers = const [],
/// The bike's sensors' readings and states (D49): the wheel's
/// revolutions, the crank's cadence and the watts; the speed and cadence
/// sensor's and the power meter's states.
final List<Reading> wheel = const [],
final List<Reading> pedalling = const [],
final List<Reading> power = const [],
final List<LinkMark> speedSensors = const [],
final List<LinkMark> powerMeters = const [],
/// The interval plan it ran, where it ran one, its steps' starts, and
/// the highest heart rate its zones were worked out from (D42).
final PlanContent? plan,
@ -188,6 +208,7 @@ final class const Recording({
for (final mark in pauses) mark.time,
for (final sample in rowing) sample.time,
for (final pressure in pressures) pressure.time,
for (final reading in [...wheel, ...pedalling, ...power]) reading.time,
].reduce((a, b) => b.isAfter(a) ? b : a);
@override
@ -203,6 +224,11 @@ final class const Recording({
loops,
rowing,
rowers,
wheel,
pedalling,
power,
speedSensors,
powerMeters,
plan,
planSteps,
planMaxHeartRate,

View file

@ -58,6 +58,24 @@ _Texts _textsOf(AppLocalizations l10n, DeviceKind kind) => switch (kind) {
refused: l10n.rowerRefused,
icon: LucideIcons.waves,
),
DeviceKind.speedSensor => (
title: l10n.speedSensorSheetTitle,
searching: l10n.speedSensorSearching,
hint: l10n.speedSensorSearchingHint,
unnamed: l10n.speedSensorUnnamed,
forget: l10n.speedSensorForget,
refused: l10n.speedSensorRefused,
icon: LucideIcons.gauge,
),
DeviceKind.powerMeter => (
title: l10n.powerMeterSheetTitle,
searching: l10n.powerMeterSearching,
hint: l10n.powerMeterSearchingHint,
unnamed: l10n.powerMeterUnnamed,
forget: l10n.powerMeterForget,
refused: l10n.powerMeterRefused,
icon: LucideIcons.zap,
),
};
class const _DeviceSheet({
@ -69,9 +87,7 @@ class const _DeviceSheet({
final l10n = AppLocalizations.of(context);
final texts = _textsOf(l10n, kind);
final theme = Theme.of(context);
final remembered = kind == DeviceKind.strap
? bloc.state.strap
: bloc.state.rower;
final remembered = bloc.state.devices[kind];
return SheetScaffold(
title: texts.title,
body: BlocBuilder<DeviceSearchCubit, DeviceSearchState>(

View file

@ -166,6 +166,40 @@ class _RecordSheetState() extends State<_RecordSheet> {
onTap: () => _chooseDevice(context, DeviceKind.rower),
),
),
if (_activity == Activity.cycling)
for (final (kind, title, none, icon) in [
(
DeviceKind.speedSensor,
l10n.recordSpeedSensor,
l10n.recordNoSpeedSensor,
LucideIcons.gauge,
),
(
DeviceKind.powerMeter,
l10n.recordPowerMeter,
l10n.recordNoPowerMeter,
LucideIcons.zap,
),
])
BlocBuilder<RecordingBloc, RecordingState>(
bloc: widget.bloc,
buildWhen: (before, now) =>
before.devices[kind] != now.devices[kind],
builder: (context, state) => ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(switch (state.devices[kind]) {
null => none,
final device =>
device.name ??
(kind == DeviceKind.speedSensor
? l10n.speedSensorUnnamed
: l10n.powerMeterUnnamed),
}),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () => _chooseDevice(context, kind),
),
),
BlocBuilder<RecordingBloc, RecordingState>(
bloc: widget.bloc,
buildWhen: (before, now) => before.strap != now.strap,

View file

@ -299,6 +299,25 @@ class const _Figures({required final LiveRecording live})
),
],
),
if (live.bikeSensors) ...[
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _Figure(
value: live.cadence?.toString() ?? '–',
label: l10n.recordingCadence,
),
),
Expanded(
child: _Figure(
value: live.watts?.toString() ?? '–',
label: l10n.recordingWatts,
),
),
],
),
],
if (live.strap != null || live.loop != null) ...[
const SizedBox(height: 24),
_Figure(

View file

@ -95,6 +95,13 @@ final class const WorkoutAnalysis({
final int? maxWatts,
final int? strokes,
/// On a bike with its sensors (D49): the crank's cadence over the
/// distance and its average while pedalling, and the normalised power
/// (its watts are [watts]).
final List<ChartPoint> pedalling = const [],
final int? averageCadence,
final int? normalisedPower,
/// The plan's steps as run (D43), and where its work steps ran on the
/// charts, to shade.
final List<LapFigures> laps = const [],
@ -137,9 +144,30 @@ WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
for (final (sample, moving) in frames)
if (sample.strokeRate case final rate?) (value: rate, x: xAt(moving)),
];
final powered = _whileMoving(
recording,
recording.power,
(reading) => reading.time,
);
final watts = [
for (final (sample, moving) in frames)
if (sample.watts case final watts?) (value: watts, x: xAt(moving)),
// A bike's power meter (D49).
for (final (reading, moving) in powered)
(value: reading.value, x: xAt(moving)),
];
final pedalling = [
for (final (reading, moving) in _whileMoving(
recording,
recording.pedalling,
(reading) => reading.time,
))
(value: reading.value, x: xAt(moving)),
];
// Coasting counts in the chart, not in the cadence pedalled.
final pedalled = [
for (final p in pedalling)
if (p.value > 0) p.value,
];
final bpms = [for (final rate in rates) rate.value];
final wattsAlone = [for (final w in watts) w.value];
@ -182,6 +210,11 @@ WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
averageWatts: wattsAlone.isEmpty ? null : _mean(wattsAlone).round(),
maxWatts: wattsAlone.isEmpty ? null : wattsAlone.reduce(max),
strokes: rowed ? _rowedStrokes(recording) : null,
pedalling: byDistance ? _binned(pedalling, bin) : const [],
averageCadence: pedalled.isEmpty ? null : _mean(pedalled).round(),
normalisedPower: normalisedPowerOf([
for (final (reading, moving) in powered) (reading.value, moving),
]),
laps: [
for (final lap in content.plan?.laps ?? const <WorkoutLap>[])
_lapFigures(
@ -424,6 +457,11 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
final heartRates = <HeartRate>[];
final rowing = <RowingSample>[];
final pressures = <Pressure>[];
final readings = {
SeriesKind.wheel: <Reading>[],
SeriesKind.pedalling: <Reading>[],
SeriesKind.power: <Reading>[],
};
for (final chunk in series) {
final times = chunk.absoluteTimes;
final columns = chunk.columns;
@ -484,6 +522,17 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
pressures.add(Pressure(time: times[i], decipascals: decipascals));
}
}
case SeriesKind.wheel || SeriesKind.pedalling || SeriesKind.power:
final column = switch (chunk.kind) {
SeriesKind.wheel => 'revs',
SeriesKind.pedalling => 'rpm',
_ => 'watts',
};
for (var i = 0; i < times.length; i++) {
if (columns[column]?[i] case final value?) {
readings[chunk.kind]!.add(Reading(time: times[i], value: value));
}
}
case SeriesKind.cadence:
// A recording holds its steps, which a cadence can't give back; the
// music reads it from the series (`cadenceModelOf`).
@ -494,6 +543,9 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
pauses.sort((a, b) => a.time.compareTo(b.time));
heartRates.sort((a, b) => a.time.compareTo(b.time));
pressures.sort((a, b) => a.time.compareTo(b.time));
for (final list in readings.values) {
list.sort((a, b) => a.time.compareTo(b.time));
}
return Recording(
activity: content.activity,
startedAt: content.startedAt,
@ -502,10 +554,54 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
heartRates: heartRates,
rowing: rowing,
pressures: pressures,
wheel: readings[SeriesKind.wheel]!,
pedalling: readings[SeriesKind.pedalling]!,
power: readings[SeriesKind.power]!,
endedAt: content.endedAt,
);
}
/// The normalised power of [watts] read at their moving times (D49), as
/// Coggan defines it: the watts a second (the last reading held 5 s, none
/// after), their 30 s rolling average, its fourth powers' mean, and that's
/// fourth root; null under a minute of readings.
int? normalisedPowerOf(List<(int, Duration)> watts) {
if (watts.isEmpty) {
return null;
}
final first = watts.first.$2.inSeconds;
final seconds = watts.last.$2.inSeconds - first + 1;
if (seconds < 60) {
return null;
}
final each = List<double>.filled(seconds, 0);
var next = 0;
int? held;
var heldAt = 0;
for (var s = 0; s < seconds; s++) {
while (next < watts.length && watts[next].$2.inSeconds - first <= s) {
held = watts[next].$1;
heldAt = watts[next].$2.inSeconds - first;
next++;
}
each[s] = held != null && s - heldAt <= 5 ? held.toDouble() : 0;
}
var window = 0.0;
var sum = 0.0;
var count = 0;
for (var s = 0; s < seconds; s++) {
window += each[s];
if (s >= 30) {
window -= each[s - 30];
}
if (s >= 29) {
sum += pow(window / 30, 4);
count++;
}
}
return pow(sum / count, 0.25).round();
}
/// The climb, in metres, the barometer's elevation must make before it
/// counts (D49): its noise is a fraction of a metre.
const barometerClimbMetres = 1;
@ -582,6 +678,11 @@ Recording withBarometer(Recording recording) {
loops: recording.loops,
rowing: recording.rowing,
rowers: recording.rowers,
wheel: recording.wheel,
pedalling: recording.pedalling,
power: recording.power,
speedSensors: recording.speedSensors,
powerMeters: recording.powerMeters,
plan: recording.plan,
planSteps: recording.planSteps,
planMaxHeartRate: recording.planMaxHeartRate,

View file

@ -62,8 +62,9 @@ final class const ExportTrack({
/// [content] and its [series] as the formats write them: the fixes precise
/// enough taken while moving, as the saved distance counts them, else the
/// rower's frames, else the heart rates; each with the heart rate of the
/// last 5 s, the cadence of the window it fell in (on foot, as strides) and
/// the rower's stroke rate and watts.
/// last 5 s, the cadence of the window it fell in (on foot, as strides),
/// a bike's crank cadence and watts of the last 5 s (D49), and the rower's
/// stroke rate and watts.
ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
final recording = withBarometer(recordingOf(content, series));
final heart = _Latest(
@ -79,6 +80,15 @@ ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
]..sort((a, b) => a.$1.compareTo(b.$1)),
cadenceWindow,
);
// A bike's crank and power meter (D49).
final crank = _Latest(
[for (final reading in recording.pedalling) (reading.time, reading.value)],
const Duration(seconds: 5),
);
final power = _Latest(
[for (final reading in recording.power) (reading.time, reading.value)],
const Duration(seconds: 5),
);
final points = <ExportPoint>[];
if (recording.fixes.isNotEmpty) {
for (final point in _movingFixes(recording)) {
@ -96,8 +106,9 @@ ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
bpm: heart.at(fix.time),
cadence: switch (steps.at(fix.time)) {
final spm? => (spm / 2).round(),
null => null,
null => crank.at(fix.time),
},
watts: power.at(fix.time),
afterPause: point.afterPause,
),
);

Some files were not shown because too many files have changed in this diff Show more