fix(train): a ride by the wheel goes out with its power, cadence and distance, and learns no wheel from tunnels (ADR 0030, D49, D51)
A trainer ride exported heart rates alone (or nothing) and a ride through tunnels the wheel's totals over GPS's distances; GPX's type came before its description and TCX's watts before the cadence, against their schemas. The wheel's size was learnt from rides GPS covered only in part. The live screen parsed the whole recording again on every sensor frame; now it reads each line once and shows sensors on the second's tick. The offline maps' requests time out after 20 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
236ac95eab
commit
c8f16686ec
5 changed files with 636 additions and 214 deletions
|
|
@ -59,6 +59,10 @@ class OfflineMaps(
|
|||
final Directory _folder, {
|
||||
final http.Client? client,
|
||||
final int parallel = 4,
|
||||
|
||||
/// How long a tile, a style or its sprites may take to come before it
|
||||
/// counts as not come: a stalled connection would hold a worker for good.
|
||||
final Duration timeout = const Duration(seconds: 20),
|
||||
}) {
|
||||
late final http.Client _http = client ?? http.Client();
|
||||
|
||||
|
|
@ -194,10 +198,9 @@ class OfflineMaps(
|
|||
.replaceAll('{x}', x)
|
||||
.replaceAll('{y}', y);
|
||||
try {
|
||||
final response = await _http.get(
|
||||
Uri.parse(url),
|
||||
headers: tileHeadersOf(source),
|
||||
);
|
||||
final response = await _http
|
||||
.get(Uri.parse(url), headers: tileHeadersOf(source))
|
||||
.timeout(timeout);
|
||||
if (response.statusCode == 200) {
|
||||
await _write(file, response.bodyBytes);
|
||||
size = response.bodyBytes.length;
|
||||
|
|
@ -372,9 +375,11 @@ class OfflineMaps(
|
|||
for (final ratio in ['@2x', '']) {
|
||||
try {
|
||||
index = _object(await _get(_sprite(sprite, ratio, '.json')));
|
||||
image = (await _http.get(
|
||||
Uri.parse(_sprite(sprite, ratio, '.png')),
|
||||
)).bodyBytes;
|
||||
image =
|
||||
(await _http
|
||||
.get(Uri.parse(_sprite(sprite, ratio, '.png')))
|
||||
.timeout(timeout))
|
||||
.bodyBytes;
|
||||
break;
|
||||
} on Object {
|
||||
index = null;
|
||||
|
|
@ -467,7 +472,9 @@ class OfflineMaps(
|
|||
}
|
||||
|
||||
Future<Object?> _get(String url, {Map<String, String>? headers}) async {
|
||||
final response = await _http.get(Uri.parse(url), headers: headers);
|
||||
final response = await _http
|
||||
.get(Uri.parse(url), headers: headers)
|
||||
.timeout(timeout);
|
||||
if (response.statusCode != 200) {
|
||||
throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,9 +106,9 @@ class RecordingBloc(
|
|||
emit(state._withDevice(event.kind, null));
|
||||
});
|
||||
on<RecordingEventLineWritten>((event, emit) {
|
||||
_lines.writeln(event.line);
|
||||
// A pause, its end or the end show at once; samples with the next
|
||||
// second.
|
||||
_lines.add(event.line);
|
||||
// A pause, its end or the end show at once; samples, the sensors'
|
||||
// readings among them, with the next second.
|
||||
if (!_sample.hasMatch(event.line)) {
|
||||
_refresh(emit);
|
||||
}
|
||||
|
|
@ -120,10 +120,10 @@ class RecordingBloc(
|
|||
final Stream<void> _ticks =
|
||||
ticks ?? Stream<void>.periodic(const Duration(seconds: 1));
|
||||
|
||||
static final _sample = RegExp('^[phsb] ');
|
||||
static final _sample = RegExp('^(?:[phsbwcr]|pw) ');
|
||||
|
||||
/// The lines the recorder wrote so far.
|
||||
final _lines = StringBuffer();
|
||||
/// The lines the recorder wrote so far, each read as it came.
|
||||
var _lines = RecordingReader();
|
||||
StreamSubscription<String>? _following;
|
||||
StreamSubscription<void>? _ticking;
|
||||
|
||||
|
|
@ -316,8 +316,9 @@ class RecordingBloc(
|
|||
}
|
||||
|
||||
/// The devices, the speed sensor's wheel measured by [recording]'s GPS
|
||||
/// where the person didn't type it (D49): the first measure as it is,
|
||||
/// later ones moving it by a quarter.
|
||||
/// where the person didn't type it and GPS covered the ride (D49,
|
||||
/// `measuredWheelMm`): the first measure as it is, later ones moving it
|
||||
/// by a quarter.
|
||||
Map<DeviceKind, KnownDevice> _measured(Recording recording) {
|
||||
final sensor = state.speedSensor;
|
||||
final measured = measuredWheelMm(recording);
|
||||
|
|
@ -349,7 +350,7 @@ class RecordingBloc(
|
|||
|
||||
/// Follows the recorder's lines, from the file's first.
|
||||
void _follow() {
|
||||
_lines.clear();
|
||||
_lines = RecordingReader();
|
||||
_following = _platform.lines().listen(
|
||||
(line) => add(RecordingEventLineWritten(line)),
|
||||
);
|
||||
|
|
@ -372,7 +373,7 @@ class RecordingBloc(
|
|||
if (state.phase != RecordingPhase.recording) {
|
||||
return;
|
||||
}
|
||||
final recording = readRecording(_lines.toString());
|
||||
final recording = _lines.recording;
|
||||
if (recording == null) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,12 +167,18 @@ int? _wheelTurns(Recording recording) {
|
|||
}
|
||||
|
||||
/// The wheel's circumference, in millimetres, [recording] measured by GPS
|
||||
/// (D49): its track's length over the wheel's revolutions, where it went
|
||||
/// 2 km at least by both and they make a wheel (1.5 to 2.5 m); else null.
|
||||
/// (D49): its track's length over the wheel's revolutions, where GPS
|
||||
/// covered the ride (its distance is GPS's: a tunnel's stretch would read
|
||||
/// as a smaller wheel), it went 2 km at least by both and they make a wheel
|
||||
/// (1.5 to 2.5 m); else null.
|
||||
int? measuredWheelMm(Recording recording) {
|
||||
final gps = _metres(recording);
|
||||
final turns = _wheelTurns(recording);
|
||||
if (gps == null || turns == null || turns == 0 || gps < 2000) {
|
||||
if (gps == null ||
|
||||
turns == null ||
|
||||
turns == 0 ||
|
||||
gps < 2000 ||
|
||||
usesWheel(gps, wheelMetresOf(recording))) {
|
||||
return null;
|
||||
}
|
||||
final mm = (gps * 1000 / turns).round();
|
||||
|
|
|
|||
|
|
@ -51,197 +51,234 @@ const recordingFileMagic = 'halkyon-train-recording';
|
|||
Recording? readRecording(String text) {
|
||||
// What follows the last line break is a line cut short, or nothing.
|
||||
final lines = text.split('\n')..removeLast();
|
||||
if (lines.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final header = lines.first.split(' ');
|
||||
if (header case [
|
||||
recordingFileMagic,
|
||||
final version,
|
||||
final activityName,
|
||||
final started,
|
||||
]) {
|
||||
final activity = Activity.values.asNameMap()[activityName];
|
||||
final startedAt = _time(started);
|
||||
if (int.tryParse(version) != recordingFileVersion ||
|
||||
activity == null ||
|
||||
startedAt == null) {
|
||||
log('A recording not read: $version, $activityName', name: 'Recording');
|
||||
return null;
|
||||
final reader = RecordingReader();
|
||||
lines.forEach(reader.add);
|
||||
return reader.recording;
|
||||
}
|
||||
|
||||
/// A recording file read a line at a time as the recorder appends it, as
|
||||
/// [readRecording] reads it whole: what shows while it records takes in
|
||||
/// only the lines new since it last looked.
|
||||
final class RecordingReader() {
|
||||
/// The file's activity and start once its header was read; null before,
|
||||
/// or where it can't be read.
|
||||
(Activity, DateTime)? _header;
|
||||
|
||||
/// Whether its first line was read.
|
||||
var _started = false;
|
||||
|
||||
final _fixes = <Fix>[];
|
||||
final _heartRates = <HeartRate>[];
|
||||
final _steps = <DateTime>[];
|
||||
final _pauses = <PauseMark>[];
|
||||
final _pressures = <Pressure>[];
|
||||
final _straps = <LinkMark>[];
|
||||
final _loops = <LinkMark>[];
|
||||
final _rowing = <RowingSample>[];
|
||||
final _rowers = <LinkMark>[];
|
||||
final _wheel = <Reading>[];
|
||||
final _pedalling = <Reading>[];
|
||||
final _power = <Reading>[];
|
||||
final _speedSensors = <LinkMark>[];
|
||||
final _powerMeters = <LinkMark>[];
|
||||
int? _wheelMm;
|
||||
PlanContent? _plan;
|
||||
int? _planMaxHeartRate;
|
||||
final _planSteps = <StepMark>[];
|
||||
RouteContent? _route;
|
||||
final _guidance = <GuideMark>[];
|
||||
DateTime? _endedAt;
|
||||
|
||||
/// Takes in [line], whole and without its line break.
|
||||
void add(String line) {
|
||||
if (!_started) {
|
||||
_started = true;
|
||||
_header = _headerOf(line);
|
||||
return;
|
||||
}
|
||||
final fixes = <Fix>[];
|
||||
final heartRates = <HeartRate>[];
|
||||
final steps = <DateTime>[];
|
||||
final pauses = <PauseMark>[];
|
||||
final pressures = <Pressure>[];
|
||||
final straps = <LinkMark>[];
|
||||
final loops = <LinkMark>[];
|
||||
final rowing = <RowingSample>[];
|
||||
final rowers = <LinkMark>[];
|
||||
final wheel = <Reading>[];
|
||||
final pedalling = <Reading>[];
|
||||
final power = <Reading>[];
|
||||
final speedSensors = <LinkMark>[];
|
||||
final powerMeters = <LinkMark>[];
|
||||
int? wheelMm;
|
||||
PlanContent? plan;
|
||||
int? planMaxHeartRate;
|
||||
final planSteps = <StepMark>[];
|
||||
RouteContent? route;
|
||||
final guidance = <GuideMark>[];
|
||||
DateTime? endedAt;
|
||||
for (final line in lines.skip(1)) {
|
||||
final fields = line.split(' ');
|
||||
final time = fields.length > 1 ? _time(fields[1]) : null;
|
||||
if (time == null) {
|
||||
continue;
|
||||
}
|
||||
if (fields.first == 'plan' && fields.length > 2) {
|
||||
// Its JSON may hold spaces: all after the time.
|
||||
final json = line.substring('plan '.length + fields[1].length + 1);
|
||||
plan = decodePlan(ByteData.sublistView(utf8.encode(json)));
|
||||
planMaxHeartRate = switch (_jsonOf(json)) {
|
||||
{'maxHeartRate': final int max} => max,
|
||||
_ => null,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
switch (fields) {
|
||||
case ['route', _, final payload]:
|
||||
route = _routeOf(payload);
|
||||
case ['guide', _, final kind, final index]
|
||||
when kind == 'next' || kind == 'turn':
|
||||
if (int.tryParse(index) case final index? when index >= 0) {
|
||||
guidance.add(
|
||||
GuideMark(
|
||||
time: time,
|
||||
kind: kind == 'next' ? GuideKind.next : GuideKind.turn,
|
||||
index: index,
|
||||
),
|
||||
);
|
||||
}
|
||||
case ['guide', _, 'off', final metres, final bearing]:
|
||||
if ((int.tryParse(metres), int.tryParse(bearing)) case (
|
||||
final metres?,
|
||||
final bearing?,
|
||||
)) {
|
||||
guidance.add(
|
||||
GuideMark(
|
||||
time: time,
|
||||
kind: GuideKind.off,
|
||||
metres: metres,
|
||||
bearing: bearing,
|
||||
),
|
||||
);
|
||||
}
|
||||
case ['guide', _, 'on']:
|
||||
guidance.add(GuideMark(time: time, kind: GuideKind.on));
|
||||
case ['guide', _, 'arrived']:
|
||||
guidance.add(GuideMark(time: time, kind: GuideKind.arrived));
|
||||
case ['step', _, 'done']:
|
||||
planSteps.add(StepMark(time: time));
|
||||
case ['step', _, final index]:
|
||||
if (int.tryParse(index) case final index? when index >= 0) {
|
||||
planSteps.add(StepMark(time: time, index: index));
|
||||
}
|
||||
case ['p', _, final lat, final lon, final accuracy, final altitude]:
|
||||
final (latE7, lonE7, accuracyCm) = (
|
||||
int.tryParse(lat),
|
||||
int.tryParse(lon),
|
||||
int.tryParse(accuracy),
|
||||
);
|
||||
if (latE7 != null && lonE7 != null && accuracyCm != null) {
|
||||
fixes.add(
|
||||
Fix(
|
||||
time: time,
|
||||
latE7: latE7,
|
||||
lonE7: lonE7,
|
||||
accuracyCm: accuracyCm,
|
||||
altitudeCm: int.tryParse(altitude),
|
||||
),
|
||||
);
|
||||
}
|
||||
case ['h', _, final bpm]:
|
||||
if (int.tryParse(bpm) case final bpm?) {
|
||||
heartRates.add(HeartRate(time: time, bpm: bpm));
|
||||
}
|
||||
case ['s', _]:
|
||||
steps.add(time);
|
||||
case ['b', _, final decipascals]:
|
||||
if (int.tryParse(decipascals) case final decipascals?) {
|
||||
pressures.add(Pressure(time: time, decipascals: decipascals));
|
||||
}
|
||||
case [final mark, _, final by]
|
||||
when (mark == 'pause' || mark == 'resume') &&
|
||||
(by == 'auto' || by == 'manual'):
|
||||
pauses.add(
|
||||
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
|
||||
);
|
||||
case [final link, _, final name]
|
||||
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 ['wheelsize', _, final mm]:
|
||||
wheelMm = int.tryParse(mm) ?? wheelMm;
|
||||
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(
|
||||
if (_header == null) {
|
||||
return;
|
||||
}
|
||||
final fields = line.split(' ');
|
||||
final time = fields.length > 1 ? _time(fields[1]) : null;
|
||||
if (time == null) {
|
||||
return;
|
||||
}
|
||||
if (fields.first == 'plan' && fields.length > 2) {
|
||||
// Its JSON may hold spaces: all after the time.
|
||||
final json = line.substring('plan '.length + fields[1].length + 1);
|
||||
_plan = decodePlan(ByteData.sublistView(utf8.encode(json)));
|
||||
_planMaxHeartRate = switch (_jsonOf(json)) {
|
||||
{'maxHeartRate': final int max} => max,
|
||||
_ => null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
switch (fields) {
|
||||
case ['route', _, final payload]:
|
||||
_route = _routeOf(payload);
|
||||
case ['guide', _, final kind, final index]
|
||||
when kind == 'next' || kind == 'turn':
|
||||
if (int.tryParse(index) case final index? when index >= 0) {
|
||||
_guidance.add(
|
||||
GuideMark(
|
||||
time: time,
|
||||
strokeRate: double.tryParse(rate),
|
||||
strokes: int.tryParse(strokes),
|
||||
metres: int.tryParse(metres),
|
||||
watts: int.tryParse(watts),
|
||||
kind: kind == 'next' ? GuideKind.next : GuideKind.turn,
|
||||
index: index,
|
||||
),
|
||||
);
|
||||
case ['end', _]:
|
||||
endedAt = time;
|
||||
}
|
||||
}
|
||||
case ['guide', _, 'off', final metres, final bearing]:
|
||||
if ((int.tryParse(metres), int.tryParse(bearing)) case (
|
||||
final metres?,
|
||||
final bearing?,
|
||||
)) {
|
||||
_guidance.add(
|
||||
GuideMark(
|
||||
time: time,
|
||||
kind: GuideKind.off,
|
||||
metres: metres,
|
||||
bearing: bearing,
|
||||
),
|
||||
);
|
||||
}
|
||||
case ['guide', _, 'on']:
|
||||
_guidance.add(GuideMark(time: time, kind: GuideKind.on));
|
||||
case ['guide', _, 'arrived']:
|
||||
_guidance.add(GuideMark(time: time, kind: GuideKind.arrived));
|
||||
case ['step', _, 'done']:
|
||||
_planSteps.add(StepMark(time: time));
|
||||
case ['step', _, final index]:
|
||||
if (int.tryParse(index) case final index? when index >= 0) {
|
||||
_planSteps.add(StepMark(time: time, index: index));
|
||||
}
|
||||
case ['p', _, final lat, final lon, final accuracy, final altitude]:
|
||||
final (latE7, lonE7, accuracyCm) = (
|
||||
int.tryParse(lat),
|
||||
int.tryParse(lon),
|
||||
int.tryParse(accuracy),
|
||||
);
|
||||
if (latE7 != null && lonE7 != null && accuracyCm != null) {
|
||||
_fixes.add(
|
||||
Fix(
|
||||
time: time,
|
||||
latE7: latE7,
|
||||
lonE7: lonE7,
|
||||
accuracyCm: accuracyCm,
|
||||
altitudeCm: int.tryParse(altitude),
|
||||
),
|
||||
);
|
||||
}
|
||||
case ['h', _, final bpm]:
|
||||
if (int.tryParse(bpm) case final bpm?) {
|
||||
_heartRates.add(HeartRate(time: time, bpm: bpm));
|
||||
}
|
||||
case ['s', _]:
|
||||
_steps.add(time);
|
||||
case ['b', _, final decipascals]:
|
||||
if (int.tryParse(decipascals) case final decipascals?) {
|
||||
_pressures.add(Pressure(time: time, decipascals: decipascals));
|
||||
}
|
||||
case [final mark, _, final by]
|
||||
when (mark == 'pause' || mark == 'resume') &&
|
||||
(by == 'auto' || by == 'manual'):
|
||||
_pauses.add(
|
||||
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
|
||||
);
|
||||
case [final link, _, final name]
|
||||
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 ['wheelsize', _, final mm]:
|
||||
_wheelMm = int.tryParse(mm) ?? _wheelMm;
|
||||
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(
|
||||
time: time,
|
||||
strokeRate: double.tryParse(rate),
|
||||
strokes: int.tryParse(strokes),
|
||||
metres: int.tryParse(metres),
|
||||
watts: int.tryParse(watts),
|
||||
),
|
||||
);
|
||||
case ['end', _]:
|
||||
_endedAt = time;
|
||||
}
|
||||
}
|
||||
|
||||
/// The recording read so far: null where the header is missing, of a
|
||||
/// newer version or names an activity this app doesn't know.
|
||||
Recording? get recording {
|
||||
final header = _header;
|
||||
if (header == null) {
|
||||
return null;
|
||||
}
|
||||
final (activity, startedAt) = header;
|
||||
// Copies: the reader goes on taking lines in.
|
||||
return Recording(
|
||||
activity: activity,
|
||||
startedAt: startedAt,
|
||||
fixes: fixes,
|
||||
heartRates: heartRates,
|
||||
steps: steps,
|
||||
pauses: pauses,
|
||||
pressures: pressures,
|
||||
straps: straps,
|
||||
loops: loops,
|
||||
rowing: rowing,
|
||||
rowers: rowers,
|
||||
wheel: wheel,
|
||||
pedalling: pedalling,
|
||||
power: power,
|
||||
speedSensors: speedSensors,
|
||||
powerMeters: powerMeters,
|
||||
wheelMm: wheelMm,
|
||||
plan: plan,
|
||||
planSteps: planSteps,
|
||||
planMaxHeartRate: planMaxHeartRate,
|
||||
route: route,
|
||||
guidance: guidance,
|
||||
endedAt: endedAt,
|
||||
fixes: [..._fixes],
|
||||
heartRates: [..._heartRates],
|
||||
steps: [..._steps],
|
||||
pauses: [..._pauses],
|
||||
pressures: [..._pressures],
|
||||
straps: [..._straps],
|
||||
loops: [..._loops],
|
||||
rowing: [..._rowing],
|
||||
rowers: [..._rowers],
|
||||
wheel: [..._wheel],
|
||||
pedalling: [..._pedalling],
|
||||
power: [..._power],
|
||||
speedSensors: [..._speedSensors],
|
||||
powerMeters: [..._powerMeters],
|
||||
wheelMm: _wheelMm,
|
||||
plan: _plan,
|
||||
planSteps: [..._planSteps],
|
||||
planMaxHeartRate: _planMaxHeartRate,
|
||||
route: _route,
|
||||
guidance: [..._guidance],
|
||||
endedAt: _endedAt,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
static (Activity, DateTime)? _headerOf(String line) {
|
||||
if (line.split(' ') case [
|
||||
recordingFileMagic,
|
||||
final version,
|
||||
final activityName,
|
||||
final started,
|
||||
]) {
|
||||
final activity = Activity.values.asNameMap()[activityName];
|
||||
final startedAt = _time(started);
|
||||
if (int.tryParse(version) != recordingFileVersion ||
|
||||
activity == null ||
|
||||
startedAt == null) {
|
||||
log('A recording not read: $version, $activityName', name: 'Recording');
|
||||
return null;
|
||||
}
|
||||
return (activity, startedAt);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
RouteContent? _routeOf(String payload) {
|
||||
|
|
|
|||
|
|
@ -59,9 +59,12 @@ final class const ExportTrack({
|
|||
bool get located => points.any((point) => point.latE7 != null);
|
||||
}
|
||||
|
||||
/// [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
|
||||
/// [content] and its [series] as the formats write them: on a ride whose
|
||||
/// distance is the wheel's (D49), the wheel's readings while moving with
|
||||
/// its way, each with the precise fix taken since the one before, if any;
|
||||
/// else the fixes precise enough taken while moving, as the saved distance
|
||||
/// counts them; else the rower's frames; else the power meter's readings,
|
||||
/// else the crank's; 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),
|
||||
/// a bike's crank cadence and watts of the last 5 s (D49), and the rower's
|
||||
/// stroke rate and watts.
|
||||
|
|
@ -89,8 +92,42 @@ ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
|
|||
[for (final reading in recording.power) (reading.time, reading.value)],
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
int? cadenceAt(DateTime time) => switch (steps.at(time)) {
|
||||
final spm? => (spm / 2).round(),
|
||||
null => crank.at(time),
|
||||
};
|
||||
final points = <ExportPoint>[];
|
||||
if (recording.fixes.isNotEmpty) {
|
||||
if (rodeByWheel(recording)) {
|
||||
// The points' way the saved distance's: GPS's would fall short of it.
|
||||
final fixes = _movingFixes(recording);
|
||||
var nextFix = 0;
|
||||
for (final point in _wheelWay(recording)) {
|
||||
Fix? fix;
|
||||
while (nextFix < fixes.length &&
|
||||
!fixes[nextFix].fix.time.isAfter(point.time)) {
|
||||
fix = fixes[nextFix++].fix;
|
||||
}
|
||||
if (fix != null && point.time.difference(fix.time) > _freshFix) {
|
||||
fix = null;
|
||||
}
|
||||
points.add(
|
||||
ExportPoint(
|
||||
time: point.time,
|
||||
latE7: fix?.latE7,
|
||||
lonE7: fix?.lonE7,
|
||||
altitude: switch (fix?.altitudeCm) {
|
||||
final cm? => cm / 100,
|
||||
null => null,
|
||||
},
|
||||
metres: point.metres,
|
||||
bpm: heart.at(point.time),
|
||||
cadence: cadenceAt(point.time),
|
||||
watts: power.at(point.time),
|
||||
afterPause: point.afterPause,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (recording.fixes.isNotEmpty) {
|
||||
for (final point in _movingFixes(recording)) {
|
||||
final fix = point.fix;
|
||||
points.add(
|
||||
|
|
@ -104,10 +141,7 @@ ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
|
|||
},
|
||||
metres: point.metres,
|
||||
bpm: heart.at(fix.time),
|
||||
cadence: switch (steps.at(fix.time)) {
|
||||
final spm? => (spm / 2).round(),
|
||||
null => crank.at(fix.time),
|
||||
},
|
||||
cadence: cadenceAt(fix.time),
|
||||
watts: power.at(fix.time),
|
||||
afterPause: point.afterPause,
|
||||
),
|
||||
|
|
@ -125,6 +159,21 @@ ExportTrack exportTrackOf(WorkoutContent content, List<SeriesChunk> series) {
|
|||
),
|
||||
);
|
||||
}
|
||||
} else if (recording.power.isNotEmpty || recording.pedalling.isNotEmpty) {
|
||||
// A trainer without the wheel: the power meter's, else the crank's.
|
||||
final readings = recording.power.isNotEmpty
|
||||
? recording.power
|
||||
: recording.pedalling;
|
||||
for (final reading in readings) {
|
||||
points.add(
|
||||
ExportPoint(
|
||||
time: reading.time,
|
||||
bpm: heart.at(reading.time),
|
||||
cadence: crank.at(reading.time),
|
||||
watts: power.at(reading.time),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (final rate in recording.heartRates) {
|
||||
points.add(ExportPoint(time: rate.time, bpm: rate.bpm));
|
||||
|
|
@ -242,12 +291,12 @@ String gpxOfWorkout(
|
|||
builder.element(
|
||||
'trk',
|
||||
nest: () {
|
||||
builder
|
||||
..element('name', nest: name)
|
||||
..element('type', nest: content.activity.name);
|
||||
// The schema's order: name, cmt, desc, src, link, number, type.
|
||||
builder.element('name', nest: name);
|
||||
if (content.notes case final notes?) {
|
||||
builder.element('desc', nest: notes);
|
||||
}
|
||||
builder.element('type', nest: content.activity.name);
|
||||
final located = [
|
||||
for (final point in track.points)
|
||||
if (point.latE7 != null && point.lonE7 != null) point,
|
||||
|
|
@ -324,15 +373,16 @@ String tcxOf(WorkoutContent content, ExportTrack track) {
|
|||
'TPX',
|
||||
attributes: {'xmlns': _tcxExtension},
|
||||
nest: () {
|
||||
if (point.watts case final watts?) {
|
||||
builder.element('Watts', nest: '$watts');
|
||||
}
|
||||
// The schema's order: Speed, RunCadence, Watts.
|
||||
if (runCadence != null) {
|
||||
builder.element(
|
||||
'RunCadence',
|
||||
nest: '${runCadence.clamp(0, 254)}',
|
||||
);
|
||||
}
|
||||
if (point.watts case final watts?) {
|
||||
builder.element('Watts', nest: '$watts');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -475,6 +525,45 @@ List<({Fix fix, double metres, bool afterPause})> _movingFixes(
|
|||
return points;
|
||||
}
|
||||
|
||||
/// How old a fix may be to place a reading of the wheel.
|
||||
const _freshFix = Duration(seconds: 5);
|
||||
|
||||
/// The wheel's readings while moving (D49), with its metres up to each, as
|
||||
/// the saved distance counts them (`wheelMetresOf`: its growth while not
|
||||
/// paused, by its circumference), and whether a pause came before.
|
||||
List<({DateTime time, double metres, bool afterPause})> _wheelWay(
|
||||
Recording recording,
|
||||
) {
|
||||
final mm = recording.wheelMm ?? 0;
|
||||
final pauses = recording.pauses;
|
||||
final points = <({DateTime time, double metres, bool afterPause})>[];
|
||||
var next = 0;
|
||||
var paused = false;
|
||||
var broken = false;
|
||||
int? before;
|
||||
var turns = 0;
|
||||
for (final reading in recording.wheel) {
|
||||
while (next < pauses.length && !pauses[next].time.isAfter(reading.time)) {
|
||||
paused = pauses[next++].paused;
|
||||
broken = true;
|
||||
}
|
||||
if (before != null && !paused && reading.value > before) {
|
||||
turns += reading.value - before;
|
||||
}
|
||||
before = reading.value;
|
||||
if (paused) {
|
||||
continue;
|
||||
}
|
||||
points.add((
|
||||
time: reading.time,
|
||||
metres: turns * mm / 1000,
|
||||
afterPause: broken && points.isNotEmpty,
|
||||
));
|
||||
broken = false;
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/// The latest of timed values at or before a time, no older than [_fresh];
|
||||
/// asked in time order.
|
||||
class _Latest(final List<(DateTime, int)> _values, final Duration _fresh) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue