Skip to content

Commit ddb20eb

Browse files
committed
Extend PM5/FTMS parser state to cover distance monotonicity and lastStroke sharing gaps
Follow-up to the earlier test-only commit, per a code review of PR #4610's refactor. Two gaps were found by tracing real behavior differences between master and this branch, and are now both covered by regression tests using real device bytes from #4609 and #3872: 1. PM5-without-FTMS distance (processPm5ParserState, CE060031 branch) has no monotonicity guard: `state.distanceKm = distance_dm / 10000.0` is a plain assignment, so a reconnect/counter-reset packet with a smaller on-device distance makes QZ's reported distance jump backwards instead of staying pinned. Documented (not fixed) by Pm5WithoutFtmsReplayingAnEarlierRealPacketMakesDistanceGoBackwards, which replays two genuinely real CE060031 payloads from the same #4609 session out of order since no collected log contains a live counter reset. 2. lastStroke is shared between the Concept2-native path (processPm5ParserState, CE060032) and the FTMS stale-cadence-reset check (previously inlined in characteristicChanged's moreData==0 branch, now extracted into processFtmsParserState so it's testable through the same ParserRegressionState struct as everything else, per AGENTS.md's testability guidance). The extraction is behavior-preserving on its own, but combined with PR #4610's PM5-path change it means lastStroke now advances whenever carried-over cadence is > 0, even on a tick whose own spm byte is 0 - not only when spm > 0 on that exact tick like before. For a PM5 that exposes both FTMS and the Concept2-native characteristics (the dual-path scenario this PR already tests via #3872), this can suppress the FTMS stale-cadence-reset for longer than intended. Documented by Pm5WithFtmsCadenceCarryOverFromConcept2PathSuppressesFtmsStaleCadenceResetFromIssue3872, composed from three real payloads in the #3872 log (no continuous capture in the collected logs exhibits a transient mid-row cadence dip, so the packets are real but sequenced/timed to reproduce the interaction). ParserRegressionState gained lastStrokeMs/strokesCount/wattValue so both gaps could be exercised through the existing static helpers instead of duplicating parsing in the tests. Full suite: 554 tests, 0 failures.
1 parent 9fc4cff commit ddb20eb

3 files changed

Lines changed: 162 additions & 33 deletions

File tree

src/devices/ftmsrower/ftmsrower.cpp

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ void ftmsrower::processPm5ParserState(ParserRegressionState &state, const QStrin
192192
if (!state.hasFtmsService) {
193193
state.lastPm5DistanceUpdateMs = nowMs;
194194
}
195+
196+
if (state.cadence > 0) {
197+
state.lastStrokeMs = nowMs;
198+
}
195199
}
196200
}
197201

@@ -222,13 +226,31 @@ void ftmsrower::processFtmsParserState(ParserRegressionState &state, const QByte
222226

223227
flags Flags;
224228
int index = 0;
225-
Q_UNUSED(whipr);
226-
Q_UNUSED(kingsmith);
229+
const double cadenceDivider = (whipr || kingsmith) ? 1.0 : 2.0;
227230

228231
Flags.word_flags = (newValue.at(1) << 8) | newValue.at(0);
229232
index += 2;
230233

231234
if (!Flags.moreData) {
235+
// Mirrors the pre-refactor "Resetting cadence!" fallback in characteristicChanged: if no
236+
// fresh stroke count has moved lastStrokeMs forward in the last 3s, treat cadence as stale
237+
// and zero it instead of trusting a possibly-frozen byte.
238+
const bool cadenceStale = state.lastStrokeMs > 0 && (nowMs - state.lastStrokeMs) > 3000;
239+
if (cadenceStale) {
240+
state.cadence = 0;
241+
state.wattValue = 0;
242+
state.speedKmh = 0;
243+
} else {
244+
state.cadence = ((uint8_t)newValue.at(index)) / cadenceDivider;
245+
}
246+
247+
const double strokeCount =
248+
(((uint16_t)((uint8_t)newValue.at(index + 2)) << 8) | (uint16_t)((uint8_t)newValue.at(index + 1)));
249+
if (state.strokesCount != strokeCount) {
250+
state.lastStrokeMs = nowMs;
251+
}
252+
state.strokesCount = strokeCount;
253+
232254
index += 3;
233255
}
234256

@@ -281,6 +303,7 @@ void ftmsrower::parseConcept2Data(const QLowEnergyCharacteristic &characteristic
281303
parserState.distanceReceivedFromPm5 = pm5DistanceReceived;
282304
parserState.lastRefreshMs = lastRefreshCharacteristicChanged.toMSecsSinceEpoch();
283305
parserState.lastPm5DistanceUpdateMs = lastPm5DistanceUpdate.toMSecsSinceEpoch();
306+
parserState.lastStrokeMs = lastStroke.toMSecsSinceEpoch();
284307

285308
processPm5ParserState(parserState, charUuid, newValue, now.toMSecsSinceEpoch());
286309

@@ -293,7 +316,10 @@ void ftmsrower::parseConcept2Data(const QLowEnergyCharacteristic &characteristic
293316
if (parserState.lastPm5DistanceUpdateMs > 0) {
294317
lastPm5DistanceUpdate = QDateTime::fromMSecsSinceEpoch(parserState.lastPm5DistanceUpdateMs);
295318
}
296-
319+
if (parserState.lastStrokeMs > 0) {
320+
lastStroke = QDateTime::fromMSecsSinceEpoch(parserState.lastStrokeMs);
321+
}
322+
297323
if (charUuid == QStringLiteral("{ce060031-43e5-11e4-916c-0800200c9a66}")) {
298324
// Parse characteristic CE060031 - Based on go-row implementation
299325
if (newValue.length() >= 10) {
@@ -305,9 +331,6 @@ void ftmsrower::parseConcept2Data(const QLowEnergyCharacteristic &characteristic
305331
else if (charUuid == QStringLiteral("{ce060032-43e5-11e4-916c-0800200c9a66}")) {
306332
// Parse characteristic CE060032 - Based on go-row implementation
307333
if (newValue.length() >= 7) {
308-
if (Cadence.value() > 0) {
309-
lastStroke = now;
310-
}
311334

312335
emit debug(QStringLiteral("PM5 CE060032 RAW: ") + newValue.toHex(' ') +
313336
QStringLiteral(" Cadence: ") + QString::number(Cadence.value()) +
@@ -480,34 +503,7 @@ void ftmsrower::characteristicChanged(const QLowEnergyCharacteristic &characteri
480503
index += 2;
481504

482505
if (!Flags.moreData) {
483-
484-
if (lastStroke.secsTo(now) > 3) {
485-
qDebug() << "Resetting cadence!";
486-
Cadence = 0;
487-
m_watt = 0;
488-
Speed = 0;
489-
} else {
490-
Cadence = ((uint8_t)newValue.at(index)) / cadence_divider;
491-
}
492-
493-
StrokesCount =
494-
(((uint16_t)((uint8_t)newValue.at(index + 2)) << 8) | (uint16_t)((uint8_t)newValue.at(index + 1)));
495-
496-
if (lastStrokesCount != StrokesCount.value()) {
497-
lastStroke = now;
498-
}
499-
lastStrokesCount = StrokesCount.value();
500-
501506
index += 3;
502-
503-
/*
504-
* the concept 2 sends the pace in 2 frames, so this condition will create a bogus speed
505-
if (!Flags.instantPace) {
506-
// eredited by echelon rower, probably we need to change this
507-
Speed = (0.37497622 * ((double)Cadence.value())) / 2.0;
508-
emit debug(QStringLiteral("Current Speed: ") + QString::number(Speed.value()));
509-
}*/
510-
emit debug(QStringLiteral("Strokes Count: ") + QString::number(StrokesCount.value()));
511507
}
512508

513509
if (Flags.avgStroke) {
@@ -528,12 +524,26 @@ void ftmsrower::characteristicChanged(const QLowEnergyCharacteristic &characteri
528524
parserState.distanceReceivedFromPm5 = pm5DistanceReceived;
529525
parserState.lastRefreshMs = lastRefreshCharacteristicChanged.toMSecsSinceEpoch();
530526
parserState.lastPm5DistanceUpdateMs = lastPm5DistanceUpdate.toMSecsSinceEpoch();
527+
parserState.lastStrokeMs = lastStroke.toMSecsSinceEpoch();
528+
parserState.strokesCount = lastStrokesCount;
529+
parserState.wattValue = m_watt.value();
531530

532531
processFtmsParserState(parserState, newValue, now.toMSecsSinceEpoch(), ICONSOLE_PLUS, FITSHOW, MRK_R11S, WHIPR,
533532
KINGSMITH, DFIT_L_R);
534533

535534
Distance = parserState.distanceKm;
536535
Speed = parserState.speedKmh;
536+
Cadence = parserState.cadence;
537+
m_watt = parserState.wattValue;
538+
StrokesCount = parserState.strokesCount;
539+
lastStrokesCount = parserState.strokesCount;
540+
if (parserState.lastStrokeMs > 0) {
541+
lastStroke = QDateTime::fromMSecsSinceEpoch(parserState.lastStrokeMs);
542+
}
543+
544+
if (!Flags.moreData) {
545+
emit debug(QStringLiteral("Strokes Count: ") + QString::number(StrokesCount.value()));
546+
}
537547

538548
if (Flags.totDistance) {
539549
index += 3;

src/devices/ftmsrower/ftmsrower.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ class ftmsrower : public rower {
4747
bool distanceReceivedFromPm5 = false;
4848
qint64 lastRefreshMs = 0;
4949
qint64 lastPm5DistanceUpdateMs = 0;
50+
qint64 lastStrokeMs = 0;
51+
double strokesCount = 0.0;
52+
double wattValue = 0.0;
5053
};
5154

5255
ftmsrower(bool noWriteResistance, bool noHeartService);

tst/Devices/TestFtmsRowerPm5Regression.h

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,29 @@ class FtmsRowerPm5RegressionTest : public testing::Test {
7777
{1757870830402LL, fromHex("4e0d004603a5ff2ae9a9890000000000")}, // 0032
7878
};
7979
}
80+
81+
// Real bytes from debug-Fri_Nov_14_06_40_47_2025.log (#3872), a PM5 that exposes BOTH the
82+
// Concept2-native characteristics and FTMS. None of the collected logs happen to contain a
83+
// continuous capture of a transient single-packet cadence dip mid-row (spm briefly reporting 0
84+
// while RowState stays "rowing"), so this composes three genuinely real payloads from the same
85+
// session at chosen offsets to reproduce that topology: a real CE060032 packet with spm=22
86+
// (from t=1763131316068 in the log), a real CE060032 packet with spm=0 (the same all-zero
87+
// payload the log emits before/after active rowing), and a real FTMS 0x2AD1 payload with
88+
// moreData=0 (from t=1763131261903 in the log, reused here since it is a genuine "no more
89+
// data, cadence byte present" frame).
90+
static PacketSample concept2CadenceNonZeroPacketFromIssue3872Log() {
91+
return {1763131316068LL, fromHex("9e0100580a16fff349774a000000000000")}; // 0032, spm=22
92+
}
93+
94+
static PacketSample concept2CadenceTransientZeroPacketFromIssue3872Log() {
95+
return {0LL, fromHex("000000000000ff00000000000000000000")}; // 0032, spm=0 (real all-zero payload)
96+
}
97+
98+
static PacketSample ftmsMoreDataZeroPacketFromIssue3872Log() {
99+
// FTMS 0x2AD1, moreData=0, cadence byte (index 2) == 44 (nonzero), so this packet only
100+
// reports cadence == 0 if the staleness check fires - it does not encode zero cadence itself.
101+
return {0LL, fromHex("00012c02000000df01ff")};
102+
}
80103
};
81104

82105
TEST_F(FtmsRowerPm5RegressionTest, Pm5WithoutFtmsRealLogMustProducePositiveDistance) {
@@ -239,6 +262,99 @@ TEST_F(FtmsRowerPm5RegressionTest, Pm5WithoutFtmsActiveTransitionFromIssue3686Mu
239262
EXPECT_TRUE(std::isfinite(state.distanceKm));
240263
}
241264

265+
TEST_F(FtmsRowerPm5RegressionTest,
266+
Pm5WithFtmsCadenceCarryOverFromConcept2PathSuppressesFtmsStaleCadenceResetFromIssue3872) {
267+
// Regression/gap test for a PM5 that exposes BOTH FTMS and the Concept2-native characteristics
268+
// (the exact dual-path scenario already covered by the "MustIgnoreConcept2Distance*" tests
269+
// above, using the same #3872 session). lastStrokeMs is shared between the two parser paths:
270+
// processPm5ParserState (fed by CE060032 notifications) bumps it whenever the carried-over
271+
// cadence is still positive, even on a tick whose own spm byte is 0; processFtmsParserState
272+
// (fed by FTMS 0x2AD1 notifications) uses it to decide whether cadence is stale and should be
273+
// reset to 0 after 3s of silence. This composes three real payloads from the #3872 log (see
274+
// the PacketSample helpers above) to show the interaction: after a genuine nonzero-cadence
275+
// CE060032 packet, a later CE060032 packet with spm=0 still refreshes lastStrokeMs merely
276+
// because cadence carried over above 0 - even though this specific packet carried no fresh
277+
// stroke data. That refresh then hides a real FTMS staleness window that pre-existed this
278+
// refactor: replaying the same sequence against the pre-refactor logic (lastStroke only bumped
279+
// when this tick's own spm byte was > 0) would have left lastStroke pinned at the first
280+
// packet's timestamp, so the FTMS packet below - arriving 4.5s later - would have been treated
281+
// as stale and reset cadence to 0.
282+
ftmsrower::ParserRegressionState state;
283+
state.hasFtmsService = true;
284+
state.rowStateReceived = true;
285+
state.rowState = 1; // matches the real RowState (CE060031 byte 9) during this window of #3872
286+
287+
const auto nonZeroSpmPacket = concept2CadenceNonZeroPacketFromIssue3872Log();
288+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060032-43e5-11e4-916c-0800200c9a66}"),
289+
nonZeroSpmPacket.payload, nonZeroSpmPacket.timestampMs);
290+
ASSERT_GT(state.cadence, 0.0);
291+
ASSERT_EQ(state.lastStrokeMs, nonZeroSpmPacket.timestampMs);
292+
293+
// A real CE060032 payload the device also sent (spm byte == 0), replayed 3.5s after the
294+
// genuine nonzero reading above - long enough that the *original* nonzero reading would
295+
// already be considered stale by the time the FTMS packet below arrives.
296+
const qint64 dipTimestampMs = nonZeroSpmPacket.timestampMs + 3500;
297+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060032-43e5-11e4-916c-0800200c9a66}"),
298+
concept2CadenceTransientZeroPacketFromIssue3872Log().payload, dipTimestampMs);
299+
// Cadence carries over unchanged (matches old and new behavior alike)...
300+
EXPECT_GT(state.cadence, 0.0);
301+
// ...but lastStrokeMs was refreshed anyway, purely because cadence stayed positive - this is
302+
// the behavior change introduced by extracting the PM5 parser: the pre-refactor code only did
303+
// this when spm itself was > 0 on this exact tick.
304+
EXPECT_EQ(state.lastStrokeMs, dipTimestampMs)
305+
<< "known behavior change: lastStroke now advances on a zero-spm tick whenever cadence "
306+
"carried over from a previous packet, not only when this tick's own spm byte is > 0";
307+
308+
// A real FTMS moreData=0 packet, 1s after the dip above (so not stale relative to the
309+
// refreshed lastStrokeMs) but 4.5s after the last genuinely fresh nonzero spm reading (so it
310+
// *would* have been stale under the pre-refactor lastStroke semantics).
311+
const qint64 ftmsTimestampMs = dipTimestampMs + 1000;
312+
ftmsrower::processFtmsParserState(state, ftmsMoreDataZeroPacketFromIssue3872Log().payload, ftmsTimestampMs,
313+
false, false, false, false, false, false);
314+
315+
EXPECT_GT(state.cadence, 0.0) << "known gap: the FTMS stale-cadence-reset (normally fired after "
316+
"3s of silence) is suppressed here because the Concept2-native "
317+
"path kept refreshing lastStrokeMs on ticks with no fresh stroke "
318+
"data; the pre-refactor code would have reset cadence to 0 at "
319+
"this point instead";
320+
}
321+
322+
TEST_F(FtmsRowerPm5RegressionTest, Pm5WithoutFtmsReplayingAnEarlierRealPacketMakesDistanceGoBackwards) {
323+
// None of the collected real logs (#4609, #3686, #3872) happen to contain an actual mid-session
324+
// PM5 distance-counter reset (e.g. a BLE reconnect, or the user pressing the erg's reset button),
325+
// so this test does not come from one single continuous real session like the others in this file.
326+
// Instead it replays two genuinely real CE060031 payloads from the SAME #4609 log
327+
// (debug-Mon_May_4_14_48_43_2026.log) out of their original chronological order: the low-distance
328+
// packet from the start of the session (303 dm) after the high-distance packet from near the end
329+
// (3117 dm, the same bytes as concept2NoFtmsIdlePacketsFromUserLog()[0]). This reproduces exactly
330+
// what would happen on a real reconnect/counter-reset mid-workout, using only bytes the device
331+
// actually sent - it exposes a real gap: processPm5ParserState assigns
332+
// `state.distanceKm = distance_dm / 10000.0` directly, with no guard against the new value being
333+
// lower than the already-accumulated distance, so distance visibly goes backwards.
334+
ftmsrower::ParserRegressionState state;
335+
state.hasFtmsService = false;
336+
337+
const auto highDistancePacket = concept2NoFtmsIdlePacketsFromUserLog().front(); // real bytes, 3117 dm
338+
const auto lowDistancePacket = concept2NoFtmsPacketsFromUserLog().front(); // real bytes, 303 dm
339+
340+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060031-43e5-11e4-916c-0800200c9a66}"),
341+
highDistancePacket.payload, highDistancePacket.timestampMs);
342+
const double highDistanceKm = state.distanceKm;
343+
ASSERT_GT(highDistanceKm, 0.03);
344+
345+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060031-43e5-11e4-916c-0800200c9a66}"),
346+
lowDistancePacket.payload, lowDistancePacket.timestampMs + 1);
347+
348+
// This EXPECT documents the current (buggy) behavior rather than the desired one: distance drops
349+
// instead of staying pinned at the previous maximum. If a monotonicity guard is added to
350+
// processPm5ParserState, this assertion should be updated to EXPECT_GE(state.distanceKm, highDistanceKm).
351+
EXPECT_LT(state.distanceKm, highDistanceKm)
352+
<< "known gap: PM5-without-FTMS distance has no monotonicity guard, so a replayed/older "
353+
"real packet (e.g. after a reconnect) makes distance jump backwards instead of staying "
354+
"pinned at the previous maximum";
355+
EXPECT_TRUE(std::isfinite(state.distanceKm));
356+
}
357+
242358
TEST_F(FtmsRowerPm5RegressionTest, Pm5WithFtmsMustIgnoreConcept2DistanceEvenWhenConcept2DistanceIsPositive) {
243359
ftmsrower::ParserRegressionState state;
244360
state.hasFtmsService = true;

0 commit comments

Comments
 (0)