Skip to content

Commit d7597c4

Browse files
authored
octane zr8 speed filter (#3926)
1 parent 211651c commit d7597c4

9 files changed

Lines changed: 6927 additions & 24 deletions

File tree

.claude/settings.local.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"permissions": {
33
"allow": [
4+
"Bash(grep:*)",
45
"Bash(tshark:*)",
56
"Bash(find:*)",
67
"Bash(python3:*)",

src/devices/csafe/serialport.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ bool Serialport::isOpen() const {
3030

3131
int Serialport::closePort() {
3232
#ifdef WIN32
33-
return (int)!CloseHandle(devicePort);
33+
if(devicePort != INVALID_HANDLE_VALUE)
34+
return (int)!CloseHandle(devicePort);
3435
#else
3536
tcflush(devicePort, TCIOFLUSH); // Clear out the buffer
3637
return close(devicePort);

src/devices/csafe/serialport.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ class Serialport : public SerialHandler {
8282

8383
// device port
8484
#ifdef WIN32
85-
HANDLE devicePort; // file descriptor for reading from com3
85+
HANDLE devicePort = INVALID_HANDLE_VALUE; // file descriptor for reading from com3
8686
DCB deviceSettings; // serial port settings baud rate et al
8787
#else
8888
int devicePort; // unix!!

src/devices/octanetreadmill/octanetreadmill.cpp

Lines changed: 173 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -168,15 +168,15 @@ octanetreadmill::octanetreadmill(uint32_t pollDeviceTime, bool noConsole, bool n
168168

169169
actualPaceSign.clear();
170170
actualPace2Sign.clear();
171+
actualPace3Sign.clear();
171172

172173
// ZR_PACE
173174
actualPaceSign.append(0x02);
174175
actualPaceSign.append(0x23);
175-
actualPace2Sign.append(0x03);
176176
actualPace2Sign.append(0x01);
177177
actualPace2Sign.append(0x23);
178-
cadenceSign.append(0x2c);
179-
cadenceSign.append(0x01);
178+
actualPace3Sign.append((char)0x00);
179+
actualPace3Sign.append(0x23);
180180
cadenceSign.append(0x3A);
181181

182182
m_watt.setType(metric::METRIC_WATT, deviceType());
@@ -259,11 +259,27 @@ void octanetreadmill::changeInclinationRequested(double grade, double percentage
259259
}
260260

261261
void octanetreadmill::update() {
262+
if(!m_control)
263+
return;
264+
262265
if (m_control->state() == QLowEnergyController::UnconnectedState) {
263266
emit disconnected();
264267
return;
265268
}
266269

270+
// ZR8: Check if cadence has been missing for too long (indicates treadmill stopped)
271+
if (ZR8 && !lastValidCadenceTime.isNull()) {
272+
qint64 secondsSinceLastCadence = lastValidCadenceTime.secsTo(QDateTime::currentDateTime());
273+
if (secondsSinceLastCadence > 30 && Speed.value() > 0) {
274+
emit debug(QStringLiteral("ZR8: No cadence received for ") + QString::number(secondsSinceLastCadence) +
275+
QStringLiteral(" seconds, resetting speed and cadence to 0"));
276+
Speed = 0;
277+
Cadence = 0;
278+
emit speedChanged(0);
279+
lastValidCadenceTime = QDateTime(); // Reset to avoid repeated logging
280+
}
281+
}
282+
267283
qDebug() << m_control->state() << bluetoothDevice.isValid() << gattCommunicationChannelService
268284
<< gattWriteCharacteristic.isValid() << initDone << requestSpeed << requestInclination;
269285

@@ -350,38 +366,171 @@ void octanetreadmill::characteristicChanged(const QLowEnergyCharacteristic &char
350366
emit debug(QStringLiteral("resetting speed"));
351367
Speed = 0;
352368
Cadence = 0;
353-
} else if (ZR8 == true && Speed.lastChanged().secsTo(QDateTime::currentDateTime()) > 15 &&
354-
Cadence.lastChanged().secsTo(QDateTime::currentDateTime()) > 15) {
355-
emit debug(QStringLiteral("resetting speed"));
356-
Speed = 0;
357-
Cadence = 0;
358369
}
359370

360-
if ((newValue.length() != 20))
361-
return;
371+
// ZR8: Packet reassembly logic for fragmented BLE messages
372+
if (ZR8 && (uint8_t)newValue[0] == 0xa5) {
373+
// Start of a new ZR8 packet
374+
if (newValue.length() >= 2) {
375+
uint8_t lengthByte = (uint8_t)newValue[1];
376+
377+
// Map length byte to expected packet size
378+
int expectedLen = 0;
379+
if (lengthByte == 0x1d) {
380+
expectedLen = 29; // A5 1D format
381+
} else if (lengthByte == 0x26) {
382+
expectedLen = 38; // A5 26 format (Gait Analysis)
383+
} else if (lengthByte == 0x23) {
384+
expectedLen = 35; // A5 23 format
385+
} else if (lengthByte == 0x20 || lengthByte == 0x21) {
386+
expectedLen = 32; // A5 20/21 format
387+
}
388+
389+
if (expectedLen > 0) {
390+
packetBuffer.clear();
391+
packetBuffer.append(newValue);
392+
expectedPacketLength = expectedLen;
393+
394+
if (packetBuffer.length() >= expectedPacketLength) {
395+
// Complete packet received
396+
value = packetBuffer.mid(0, expectedPacketLength);
397+
packetBuffer.clear();
398+
expectedPacketLength = 0;
399+
emit debug(QStringLiteral("ZR8: Complete packet assembled: ") +
400+
QString::number(expectedLen) + QStringLiteral(" bytes"));
401+
} else {
402+
// Incomplete, need more fragments
403+
return;
404+
}
405+
} else {
406+
return;
407+
}
408+
} else {
409+
return;
410+
}
411+
} else if (ZR8 && expectedPacketLength > 0) {
412+
// Continuation of a fragmented packet
413+
packetBuffer.append(newValue);
414+
415+
if (packetBuffer.length() >= expectedPacketLength) {
416+
// Complete packet now assembled
417+
value = packetBuffer.mid(0, expectedPacketLength);
418+
packetBuffer.clear();
419+
expectedPacketLength = 0;
420+
emit debug(QStringLiteral("ZR8: Complete packet assembled from fragments"));
421+
} else {
422+
return;
423+
}
424+
} else {
425+
// Non-ZR8 or non-A5 packet
426+
if ((value.length() != 20))
427+
return;
428+
429+
// ZR8: Reject 20-byte packets that don't start with 0xa5 (incomplete/corrupted)
430+
if (ZR8 && (uint8_t)value[0] != 0xa5) {
431+
qDebug() << "ZR8: Rejecting 20-byte non-a5 packet:" << value.toHex().left(6);
432+
return;
433+
}
362434

363-
if (ZR8 && newValue.contains(cadenceSign)) {
364-
int16_t i = newValue.indexOf(cadenceSign) + 3;
435+
if ((uint8_t)newValue[0] == 0xa5 && newValue[1] == 0x17)
436+
return;
437+
}
365438

366-
if (i >= newValue.length())
439+
// ZR8: Speed data is only in packets starting with a5 2[0123] 06
440+
// Other packets containing 02 23 signature do not have valid speed data
441+
if (ZR8) {
442+
qDebug() << "ZR8 Check: value.length()=" << value.length() << "value[0]=" << QString::number((uint8_t)value[0], 16);
443+
if (value.length() < 18) {
444+
qDebug() << "ZR8: Packet too short, rejecting";
367445
return;
446+
}
368447

369-
Cadence = ((uint8_t)newValue.at(i));
448+
// Check for valid speed packet header: a5 20 06, a5 21 06, a5 23 06, or a5 1d (cadence format)
449+
uint8_t byte0 = (uint8_t)value[0];
450+
uint8_t byte1 = (uint8_t)value[1];
451+
uint8_t byte2 = (uint8_t)value[2];
452+
bool isValidSpeedPacket = (byte0 == 0xa5) &&
453+
(byte1 == 0x20 || byte1 == 0x21 || byte1 == 0x23 || byte1 == 0x1d) &&
454+
(byte2 == 0x06);
455+
qDebug() << "ZR8 Header Check: byte0=" << QString::number(byte0, 16)
456+
<< "byte1=" << QString::number(byte1, 16)
457+
<< "byte2=" << QString::number(byte2, 16)
458+
<< "isValidSpeedPacket=" << isValidSpeedPacket;
459+
if (!isValidSpeedPacket) {
460+
qDebug() << "ZR8: Invalid header, rejecting packet";
461+
return; // Invalid packet header, do not process
462+
}
370463
}
371464

372-
if ((uint8_t)newValue[0] == 0xa5 && newValue[1] == 0x17)
373-
return;
465+
// ZR8: Cadence parsing from 0x3A marker with anomaly filtering
466+
if (ZR8 && value.contains(cadenceSign)) {
467+
int16_t cadenceIdx = value.indexOf(cadenceSign) + 1;
468+
469+
if (cadenceIdx >= value.length() || cadenceIdx < 1) {
470+
// Marker not found or invalid position
471+
emit debug(QStringLiteral("ZR8: Cadence marker (0x3A) not found or invalid"));
472+
} else {
473+
uint8_t rawCadence = (uint8_t)value.at(cadenceIdx);
474+
475+
// Anomaly filtering: accept values in realistic range (20-200 RPM)
476+
// Values outside this range are likely parsing errors from packet format shifts
477+
if (rawCadence >= 20 && rawCadence <= 200) {
478+
Cadence = rawCadence;
479+
lastValidCadenceTime = QDateTime::currentDateTime();
480+
emit debug(QStringLiteral("ZR8: Cadence parsed: ") + QString::number(rawCadence));
481+
482+
// Reset cadence zero timer when valid cadence is received
483+
if (!lastCadenceZeroTime.isNull()) {
484+
lastCadenceZeroTime = QDateTime();
485+
}
486+
} else {
487+
// Anomalous cadence value - likely from packet format shift
488+
emit debug(QStringLiteral("ZR8: Cadence anomaly filtered: ") + QString::number(rawCadence) +
489+
QStringLiteral(" RPM (outside 20-200 range)"));
490+
491+
// Track time when cadence drops below 20 (potential stop condition)
492+
if (rawCadence < 20 && rawCadence > 0) {
493+
if (lastCadenceZeroTime.isNull()) {
494+
lastCadenceZeroTime = QDateTime::currentDateTime();
495+
emit debug(QStringLiteral("ZR8: Cadence low (< 20 RPM), starting 5-second timer"));
496+
} else {
497+
qint64 secondsSinceLowCadence = lastCadenceZeroTime.secsTo(QDateTime::currentDateTime());
498+
if (secondsSinceLowCadence >= 5 && Speed.value() > 0) {
499+
emit debug(QStringLiteral("ZR8: Cadence < 20 for 5+ seconds, resetting speed to 0"));
500+
Speed = 0;
501+
Cadence = 0;
502+
emit speedChanged(0);
503+
}
504+
}
505+
} else if (rawCadence == 0) {
506+
// Cadence explicitly zero - reset speed immediately
507+
if (Speed.value() > 0) {
508+
emit debug(QStringLiteral("ZR8: Cadence = 0, resetting speed"));
509+
Speed = 0;
510+
Cadence = 0;
511+
emit speedChanged(0);
512+
}
513+
}
514+
}
515+
}
516+
}
374517

375-
if (!newValue.contains(actualPaceSign) && !newValue.contains(actualPace2Sign))
518+
if (!value.contains(actualPaceSign) && !value.contains(actualPace2Sign) && !value.contains(actualPace3Sign))
376519
return;
377520

378-
int16_t i = newValue.indexOf(actualPaceSign) + 2;
521+
int16_t i = value.indexOf(actualPaceSign) + 2;
522+
if (i <= 1)
523+
i = value.indexOf(actualPace2Sign) + 2;
379524
if (i <= 1)
380-
i = newValue.indexOf(actualPace2Sign) + 3;
525+
i = value.indexOf(actualPace3Sign) + 2;
381526

382-
if (i + 1 >= newValue.length())
527+
if (i + 1 >= value.length())
383528
return;
384529

530+
qDebug() << "ZR8 Speed Debug: i=" << i << "value.toHex()=" << value.toHex()
531+
<< "byte[i]=" << QString::number((uint8_t)value.at(i), 16)
532+
<< "byte[i+1]=" << QString::number((uint8_t)value.at(i+1), 16);
533+
385534
double speed = GetSpeedFromPacket(value, i);
386535
if (isinf(speed))
387536
return;
@@ -396,6 +545,8 @@ void octanetreadmill::characteristicChanged(const QLowEnergyCharacteristic &char
396545
}
397546
emit debug(QStringLiteral("Current speed: ") + QString::number(speed));
398547

548+
lastValidCadenceTime = QDateTime::currentDateTime();
549+
399550
if (Speed.value() != speed) {
400551
emit speedChanged(speed);
401552
}
@@ -433,8 +584,9 @@ void octanetreadmill::characteristicChanged(const QLowEnergyCharacteristic &char
433584

434585
emit debug(QStringLiteral("Current Distance Calculated: ") + QString::number(Distance.value()));
435586
emit debug(QStringLiteral("Current KCal: ") + QString::number(KCal.value()));
587+
emit debug(QStringLiteral("Current Cadence: ") + QString::number(Cadence.value()));
436588

437-
if (m_control->error() != QLowEnergyController::NoError) {
589+
if (m_control && m_control->error() != QLowEnergyController::NoError) {
438590
qDebug() << QStringLiteral("QLowEnergyController ERROR!!") << m_control->errorString();
439591
}
440592

src/devices/octanetreadmill/octanetreadmill.h

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ class octanetreadmill : public treadmill {
3939
bool autoStartWhenSpeedIsGreaterThenZero() override;
4040
bool canStartStop() override { return false; }
4141
bool canHandleSpeedChange() override { return false; }
42-
bool canHandleInclineChange() override { return false; }
42+
bool canHandleInclineChange() override { return false; }
43+
44+
// For testing: activate ZR8 parsing mode
45+
void activateZR8Mode() { ZR8 = true; }
4346

4447
private:
4548
double GetSpeedFromPacket(const QByteArray &packet, int index);
@@ -61,8 +64,17 @@ class octanetreadmill : public treadmill {
6164

6265
QByteArray actualPaceSign;
6366
QByteArray actualPace2Sign;
67+
QByteArray actualPace3Sign;
6468
QByteArray cadenceSign;
6569

70+
QDateTime lastValidSpeedTime;
71+
QDateTime lastCadenceZeroTime;
72+
QDateTime lastValidCadenceTime;
73+
74+
// Packet reassembly buffer for fragmented BLE messages
75+
QByteArray packetBuffer;
76+
int expectedPacketLength = 0;
77+
6678
QTimer *refresh;
6779

6880
QLowEnergyService *gattCommunicationChannelService = nullptr;

0 commit comments

Comments
 (0)