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
8 changed files with 1209 additions and 62 deletions
|
|
@ -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'),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue