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:
parent
57cbd97fd1
commit
5c13e6f3c5
22 changed files with 1209 additions and 62 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue