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

View file

@ -1109,8 +1109,10 @@ void main() {
recording: (
platform: platform,
file: file(),
straps: DeviceStore(File('${folder.path}/strap.json')),
rowers: DeviceStore(File('${folder.path}/rower.json')),
devices: {
for (final kind in DeviceKind.values)
kind: DeviceStore(File('${folder.path}/${kind.name}.json')),
},
announces: AnnounceStore(File('${folder.path}/announce.json')),
),
workouts: workouts,
@ -1167,6 +1169,36 @@ void main() {
});
});
testWidgets("a ride offers the bike's sensors, found nearby and chosen; "
"a run doesn't (D49)", (tester) async {
platform.nearbyBike = [
const FoundDevice(address: 'AA:02', name: 'Assioma', rssi: -50),
];
await withClock(Clock(() => at), () async {
await startRecording(tester);
await tester.tap(find.text('Record'));
await tester.pumpAndSettle();
expect(find.text('Power meter'), findsNothing);
await tester.tap(find.text('Cycling'));
await tester.pump();
expect(find.text('Speed and cadence sensor'), findsOneWidget);
await tester.ensureVisible(find.text('Power meter'));
await tester.pump();
await tester.tap(find.text('Power meter'));
await pass(tester, 2);
await tester.tap(find.text('Assioma'));
await pass(tester, 2);
expect(find.text('Assioma'), findsOneWidget);
await tester.tap(find.text('Start'));
await tester.runAsync(pumpEventQueue);
await pass(tester, 2);
expect(platform.started.single.powerMeter, 'AA:02');
expect(platform.started.single.speedSensor, isNull);
expect(platform.scanned, [DeviceKind.powerMeter]);
});
});
testWidgets('announcements are switched for each activity and '
'remembered, a row offered none (D49)', (tester) async {
await withClock(Clock(() => at), () async {
@ -2054,8 +2086,10 @@ void main() {
: (
platform: FakeRecorderPlatform()..at = at,
file: File('${folder.path}/recording.txt'),
straps: DeviceStore(File('${folder.path}/strap.json')),
rowers: DeviceStore(File('${folder.path}/rower.json')),
devices: {
for (final kind in DeviceKind.values)
kind: DeviceStore(File('${folder.path}/${kind.name}.json')),
},
announces: AnnounceStore(
File('${folder.path}/announce.json'),
),

View file

@ -407,4 +407,29 @@ void main() {
expect(live.track, hasLength(2));
});
});
test("a ride's sensors give its cadence and watts while fresh (D49)", () {
final ride = Recording(
activity: Activity.cycling,
startedAt: at(0),
speedSensors: [LinkMark(time: at(0), state: LinkState.connected)],
pedalling: [Reading(time: at(8), value: 88)],
power: [Reading(time: at(9), value: 240)],
);
final live = liveOf(ride, at(10));
expect(live.bikeSensors, isTrue);
expect(live.cadence, 88);
expect(live.watts, 240);
final later = liveOf(ride, at(20));
expect(later.cadence, isNull);
expect(later.watts, isNull);
expect(
liveOf(
Recording(activity: Activity.cycling, startedAt: at(0)),
at(1),
).bikeSensors,
isFalse,
);
});
}

View file

@ -121,6 +121,36 @@ void main() {
expect(series, isEmpty);
});
test("the bike's readings are series of their own, read back as they "
'were (D49)', () {
final wheel = [
Reading(time: at(1), value: 2),
Reading(time: at(2), value: 4),
];
final pedalling = [Reading(time: at(1), value: 90)];
final power = [Reading(time: at(2), value: 250)];
final (content, series) = recordedWorkout(
Recording(
activity: Activity.cycling,
startedAt: start,
fixes: [north(0, 0), north(10, 30)],
wheel: wheel,
pedalling: pedalling,
power: power,
endedAt: at(10),
),
);
expect(
series.singleWhere((c) => c.kind == SeriesKind.power).columns['watts'],
[250],
);
final read = recordingOf(content, series);
expect(read.wheel, wheel);
expect(read.pedalling, pedalling);
expect(read.power, power);
});
test("the barometer's pressure is a series of its own, read back as it "
'was (D49)', () {
final pressures = [

View file

@ -71,8 +71,10 @@ void main() {
platform,
workouts,
file,
DeviceStore(File('${folder.path}/strap.json')),
DeviceStore(File('${folder.path}/rower.json')),
{
for (final kind in DeviceKind.values)
kind: DeviceStore(File('${folder.path}/${kind.name}.json')),
},
notice: notice,
ticks: ticks.stream,
loop: () => loop,
@ -510,6 +512,38 @@ void main() {
});
});
test("the bike's sensors chosen go to the recorder on a ride, not on a "
'run, and are remembered (D49)', () async {
var bloc = await open();
const sensor = KnownDevice(address: 'AA:01', name: 'Speed');
const meter = KnownDevice(address: 'AA:02', name: 'Power');
bloc
..add(const RecordingEventDeviceChosen(DeviceKind.speedSensor, sensor))
..add(const RecordingEventDeviceChosen(DeviceKind.powerMeter, meter));
await bloc.stream.firstWhere((s) => s.powerMeter != null);
await bloc.close();
bloc = await open();
expect(bloc.state.speedSensor, sensor);
expect(bloc.state.powerMeter, meter);
Future<void> record(Activity activity) async {
bloc.add(RecordingEventStartRequested(activity));
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
bloc.add(const RecordingEventStopRequested());
await bloc.stream.firstWhere((s) => s.phase != RecordingPhase.recording);
bloc.add(const RecordingEventDiscardRequested());
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.idle);
}
await record(Activity.cycling);
await record(Activity.running);
expect(platform.started.first.speedSensor, 'AA:01');
expect(platform.started.first.powerMeter, 'AA:02');
expect(platform.started.last.speedSensor, isNull);
expect(platform.started.last.powerMeter, isNull);
});
test('announcements go to the recorder where switched on (D49)', () async {
final bloc = await open();
Future<void> record(Activity activity, {required bool announce}) async {

View file

@ -17,6 +17,30 @@ void main() {
start.add(Duration(seconds: seconds)).millisecondsSinceEpoch;
final header = 'halkyon-train-recording 1 running ${at(0)}\n';
test("the bike's sensors read: the wheel, the crank, the watts and their "
'states (D49)', () {
final recording = readRecording(
'$header'
'csc ${at(0)} connected\n'
'power ${at(0)} connecting\n'
'w ${at(1)} 3\n'
'c ${at(1)} 88\n'
'pw ${at(2)} 240\n'
'pw ${at(3)} x\n',
)!;
DateTime time(int seconds) => start.add(Duration(seconds: seconds));
expect(recording.wheel, [Reading(time: time(1), value: 3)]);
expect(recording.pedalling, [Reading(time: time(1), value: 88)]);
expect(recording.power, [Reading(time: time(2), value: 240)]);
expect(recording.speedSensors, [
LinkMark(time: time(0), state: LinkState.connected),
]);
expect(recording.powerMeters, [
LinkMark(time: time(0), state: LinkState.connecting),
]);
});
test("the barometer's pressure reads in tenths of a pascal (D49)", () {
final recording = readRecording(
'$header'

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),

View file

@ -32,6 +32,8 @@ class FakeRecorderPlatform({
Map<String, Object?>? route,
Map<String, Object?>? music,
Map<String, Object?>? announce,
String? speedSensor,
String? powerMeter,
})
>[];
@ -42,6 +44,9 @@ class FakeRecorderPlatform({
/// whether Bluetooth is on.
List<FoundDevice> nearby = [];
List<FoundDevice> nearbyRowers = [];
/// The bike's sensors nearby (D49).
List<FoundDevice> nearbyBike = [];
bool bluetoothOn = true;
/// The scans started, of which kind.
@ -88,6 +93,8 @@ class FakeRecorderPlatform({
Map<String, Object?>? route,
Map<String, Object?>? music,
Map<String, Object?>? announce,
String? speedSensor,
String? powerMeter,
}) async {
started.add((
path: path,
@ -100,6 +107,8 @@ class FakeRecorderPlatform({
route: route,
music: music,
announce: announce,
speedSensor: speedSensor,
powerMeter: powerMeter,
));
notices.add(notice);
_file = File(path)
@ -163,7 +172,11 @@ class FakeRecorderPlatform({
if (!bluetoothOn) {
return Stream.error(BluetoothOff());
}
final found = kind == DeviceKind.strap ? nearby : nearbyRowers;
final found = switch (kind) {
DeviceKind.strap => nearby,
DeviceKind.rower => nearbyRowers,
DeviceKind.speedSensor || DeviceKind.powerMeter => nearbyBike,
};
// Open until cancelled, as a scan is.
return Stream.multi((listener) => found.forEach(listener.add));
}