Skip to content

Commit 9fc4cff

Browse files
cagnuleinclaude
andcommitted
Add PM5 regression tests from issue #3686 debug log
Validates that PR #4610's PM5-without-FTMS parsing fix does not regress other real-world Concept2 PM5 tickets. Adds two tests exercising the shared production parser (ftmsrower::processPm5ParserState) with actual packets extracted from the #3686 debug log ("Metrics at 0 with Concept 2 PM5 Rowerg"): - Pm5WithoutFtmsSustainedIdleBurstFromIssue3686MustNotDriftDistance: the device repeated the same idle CE060031/CE060032 payload (RowState == 0) for ~21s after connecting; distance must stay pinned and speed/cadence must stay at 0 the whole time. - Pm5WithoutFtmsActiveTransitionFromIssue3686MustAdvanceMetrics: once RowState flips from 0 to 1, distance must resume advancing and speed/cadence must become positive again. Surveyed other Concept2/PM5-tagged tickets (#4244, #3503, #1856, #1486) but none of them exercise this code path: #4244 and part of #1486 use the CSAFE serial protocol (csaferower.cpp), #3503's PM5 is handled by ftmsbike.cpp, and the rest of #1856/#1486 never reach a successful device connection in the logs. Full test suite (552 tests) still passes with 0 failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 14b7533 commit 9fc4cff

1 file changed

Lines changed: 102 additions & 0 deletions

File tree

tst/Devices/TestFtmsRowerPm5Regression.h

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,33 @@ class FtmsRowerPm5RegressionTest : public testing::Test {
5050
static PacketSample concept2ZeroDistancePacket32FromIssue3872Log() {
5151
return {1763131313972LL, fromHex("000000000000ff00000000000000000000")};
5252
}
53+
54+
// Extracted from debug-Sun_Sep_14_19_26_19_2025.log (#3686, "Metrics at 0 with
55+
// Concept 2 PM5 Rowerg"). This PM5 has no FTMS service and reported the exact same
56+
// CE060031/CE060032 payload (RowState idle, byte9 == 0) for roughly 48 seconds after
57+
// connecting, which is what the reporter saw as "metrics stuck at 0". Distance must stay
58+
// pinned at the value derived from that first packet the whole time, and speed/cadence must
59+
// stay at 0 while idle.
60+
static std::vector<PacketSample> concept2NoFtmsIdleBurstFromIssue3686Log() {
61+
return {
62+
{1757870782324LL, fromHex("2e0d00df0100010101000100000000000080e3")}, // 0031, RowState=0
63+
{1757870782391LL, fromHex("2e0d008c034eff21d7177e0000000000")}, // 0032
64+
{1757870796894LL, fromHex("2e0d00df0100010101000100000000000080e3")}, // 0031, RowState=0
65+
{1757870796897LL, fromHex("2e0d008c034eff21d7177e0000000000")}, // 0032
66+
{1757870803083LL, fromHex("2e0d00df0100010101000100000000000080e3")}, // 0031, RowState=0
67+
{1757870803086LL, fromHex("2e0d008c034eff21d7177e0000000000")}, // 0032
68+
};
69+
}
70+
71+
// Extracted from the same #3686 log a few packets after the idle burst above, once the
72+
// rower actually starts moving: RowState (byte9 of CE060031) flips from 0 to 1 and the
73+
// reported distance advances from 0x0001df (479 dm) to 0x0001e1 (481 dm).
74+
static std::vector<PacketSample> concept2NoFtmsActiveTransitionFromIssue3686Log() {
75+
return {
76+
{1757870830394LL, fromHex("4e0d00e10100010101010200000000000080e3")}, // 0031, RowState=1
77+
{1757870830402LL, fromHex("4e0d004603a5ff2ae9a9890000000000")}, // 0032
78+
};
79+
}
5380
};
5481

5582
TEST_F(FtmsRowerPm5RegressionTest, Pm5WithoutFtmsRealLogMustProducePositiveDistance) {
@@ -137,6 +164,81 @@ TEST_F(FtmsRowerPm5RegressionTest, Pm5WithFtmsMustIgnoreConcept2DistancePackets)
137164
EXPECT_TRUE(std::isfinite(state.distanceKm));
138165
}
139166

167+
TEST_F(FtmsRowerPm5RegressionTest, Pm5WithoutFtmsSustainedIdleBurstFromIssue3686MustNotDriftDistance) {
168+
// Regression for #3686 "Metrics at 0 with Concept 2 PM5 Rowerg": this real device sent the
169+
// exact same idle CE060031/CE060032 payload repeatedly for ~21 seconds after connecting
170+
// (RowState byte == 0 the whole time). Distance must stay pinned to the value derived from
171+
// the very first packet, and speed/cadence must stay at 0, for as long as the device reports
172+
// RowState == 0 - it must not drift up or down just because time passes.
173+
ftmsrower::ParserRegressionState state;
174+
state.hasFtmsService = false;
175+
176+
const auto idleBurst = concept2NoFtmsIdleBurstFromIssue3686Log();
177+
ASSERT_EQ(idleBurst.size(), 6u);
178+
179+
// Intentionally exercise the shared production parser helper instead of re-implementing parsing here.
180+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060031-43e5-11e4-916c-0800200c9a66}"),
181+
idleBurst[0].payload, idleBurst[0].timestampMs);
182+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060032-43e5-11e4-916c-0800200c9a66}"),
183+
idleBurst[1].payload, idleBurst[1].timestampMs);
184+
const double firstDistanceKm = state.distanceKm;
185+
ASSERT_TRUE(state.rowStateReceived);
186+
ASSERT_EQ(state.rowState, 0);
187+
ASSERT_GT(firstDistanceKm, 0.0);
188+
EXPECT_DOUBLE_EQ(state.speedKmh, 0.0);
189+
EXPECT_DOUBLE_EQ(state.cadence, 0.0);
190+
191+
for (size_t i = 2; i < idleBurst.size(); i += 2) {
192+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060031-43e5-11e4-916c-0800200c9a66}"),
193+
idleBurst[i].payload, idleBurst[i].timestampMs);
194+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060032-43e5-11e4-916c-0800200c9a66}"),
195+
idleBurst[i + 1].payload, idleBurst[i + 1].timestampMs);
196+
EXPECT_EQ(state.rowState, 0);
197+
EXPECT_DOUBLE_EQ(state.speedKmh, 0.0);
198+
EXPECT_DOUBLE_EQ(state.cadence, 0.0);
199+
EXPECT_DOUBLE_EQ(state.distanceKm, firstDistanceKm)
200+
<< "distance must not drift while the PM5 keeps reporting RowState == 0";
201+
}
202+
203+
EXPECT_TRUE(std::isfinite(state.distanceKm));
204+
}
205+
206+
TEST_F(FtmsRowerPm5RegressionTest, Pm5WithoutFtmsActiveTransitionFromIssue3686MustAdvanceMetrics) {
207+
// Regression for #3686: once the same real PM5 device actually starts rowing (RowState flips
208+
// from 0 to 1), distance must advance past the idle-pinned value and speed/cadence must
209+
// become non-zero. This is the exact recovery the reporter expected but initially did not see.
210+
ftmsrower::ParserRegressionState state;
211+
state.hasFtmsService = false;
212+
213+
const auto idleBurst = concept2NoFtmsIdleBurstFromIssue3686Log();
214+
ASSERT_GE(idleBurst.size(), 2u);
215+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060031-43e5-11e4-916c-0800200c9a66}"),
216+
idleBurst[0].payload, idleBurst[0].timestampMs);
217+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060032-43e5-11e4-916c-0800200c9a66}"),
218+
idleBurst[1].payload, idleBurst[1].timestampMs);
219+
const double idleDistanceKm = state.distanceKm;
220+
ASSERT_EQ(state.rowState, 0);
221+
222+
const auto activeTransition = concept2NoFtmsActiveTransitionFromIssue3686Log();
223+
ASSERT_EQ(activeTransition.size(), 2u);
224+
225+
// Intentionally exercise the shared production parser helper instead of re-implementing parsing here.
226+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060031-43e5-11e4-916c-0800200c9a66}"),
227+
activeTransition[0].payload, activeTransition[0].timestampMs);
228+
EXPECT_TRUE(state.rowStateReceived);
229+
EXPECT_EQ(state.rowState, 1);
230+
EXPECT_GE(state.distanceKm, idleDistanceKm);
231+
232+
ftmsrower::processPm5ParserState(state, QStringLiteral("{ce060032-43e5-11e4-916c-0800200c9a66}"),
233+
activeTransition[1].payload, activeTransition[1].timestampMs);
234+
EXPECT_GT(state.distanceKm, idleDistanceKm)
235+
<< "distance must advance once RowState leaves idle instead of staying stuck like the "
236+
"reporter observed";
237+
EXPECT_GT(state.speedKmh, 0.0);
238+
EXPECT_GT(state.cadence, 0.0);
239+
EXPECT_TRUE(std::isfinite(state.distanceKm));
240+
}
241+
140242
TEST_F(FtmsRowerPm5RegressionTest, Pm5WithFtmsMustIgnoreConcept2DistanceEvenWhenConcept2DistanceIsPositive) {
141243
ftmsrower::ParserRegressionState state;
142244
state.hasFtmsService = true;

0 commit comments

Comments
 (0)