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
2 changed files with 1209 additions and 62 deletions

View file

@ -133,6 +133,49 @@ void main() {
expect(analysis.elevationGainMetres, 12);
});
group("a ride's sensors (D49)", () {
test('normalised power: steady watts are their own, surges weigh more', () {
expect(
normalisedPowerOf([
for (var s = 0; s < 120; s++) (200, Duration(seconds: s)),
]),
200,
);
final surges = [
for (var s = 0; s < 600; s++)
((s ~/ 60).isEven ? 100 : 300, Duration(seconds: s)),
];
// Averaging 200 W, but the minutes at 300 W weigh more.
expect(normalisedPowerOf(surges), greaterThan(220));
expect(normalisedPowerOf(surges.take(50).toList()), isNull);
});
test("a ride's page has its power, normalised power and cadence; "
'coasting left out of the cadence pedalled', () {
final ride = Recording(
activity: Activity.cycling,
startedAt: start,
fixes: [for (var s = 0; s <= 300; s += 10) north(s, s * 8)],
power: [
for (var s = 0; s <= 300; s++) Reading(time: at(s), value: 200),
],
pedalling: [
for (var s = 0; s <= 300; s++)
Reading(time: at(s), value: s < 280 ? 90 : 0),
],
endedAt: at(300),
);
final analysis = analysed(ride);
expect(analysis.averageWatts, 200);
expect(analysis.maxWatts, 200);
expect(analysis.normalisedPower, 200);
expect(analysis.averageCadence, 90);
expect(analysis.pedalling, isNotEmpty);
expect(analysis.watts, isNotEmpty);
});
});
group("the barometer's elevation (D49)", () {
/// The pressure, in tenths of a pascal, [metres] above the standard sea
/// level.

View file

@ -281,6 +281,32 @@ void main() {
expect(session[6], 14);
});
test("a ride's crank cadence and watts go out with each point (D49)", () {
final ride = Recording(
activity: Activity.cycling,
startedAt: start,
fixes: [for (var s = 0; s <= 60; s += 10) north(s, s * 8)],
pedalling: [
for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 92),
],
power: [for (var s = 0; s <= 60; s++) Reading(time: at(s), value: 230)],
endedAt: at(60),
);
final (rode, rideSeries) = recordedWorkout(ride);
final rideTrack = exportTrackOf(rode, rideSeries);
expect(rideTrack.points.first.cadence, 92);
expect(rideTrack.points.first.watts, 230);
final tcx = XmlDocument.parse(tcxOf(rode, rideTrack));
expect(tcx.findAllElements('Cadence').first.innerText, '92');
expect(tcx.findAllElements('Watts').first.innerText, '230');
final record = [
for (final message in readFit(fitOf(rode, rideTrack)))
if (message.global == 20) message.fields,
].first;
expect(record[4], 92);
expect(record[7], 230);
});
test('the file is named by when the workout started, in UTC', () {
expect(
exportNameOf(content, ExportFormat.fit),