@@ -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
5582TEST_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+
140242TEST_F (FtmsRowerPm5RegressionTest, Pm5WithFtmsMustIgnoreConcept2DistanceEvenWhenConcept2DistanceIsPositive) {
141243 ftmsrower::ParserRegressionState state;
142244 state.hasFtmsService = true ;
0 commit comments