SunnyFit Stepper (#4245)

This commit is contained in:
Roberto Viola
2026-02-04 05:00:53 +01:00
committed by GitHub
parent 323c169067
commit 2e0bd25a4a
9 changed files with 743 additions and 2 deletions

View File

@@ -1,6 +1,9 @@
{
"permissions": {
"allow": [
"Bash(tshark:*)",
"Bash(find:*)",
"Bash(python3:*)",
"Bash(git log:*)"
]
}

View File

@@ -2033,6 +2033,19 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
connect(echelonStairclimber, &echelonstairclimber::inclinationChanged, this, &bluetooth::inclinationChanged);
echelonStairclimber->deviceDiscovered(b);
this->signalBluetoothDeviceConnected(echelonStairclimber);
} else if (b.name().toUpper().startsWith(QLatin1String("SF-S")) &&
!sunnyfitStepper && filter) {
this->setLastBluetoothDevice(b);
this->stopDiscovery();
sunnyfitStepper = new sunnyfitstepper(this->pollDeviceTime, noConsole, noHeartService);
emit deviceConnected(b);
connect(sunnyfitStepper, &bluetoothdevice::connectedAndDiscovered, this,
&bluetooth::connectedAndDiscovered);
connect(sunnyfitStepper, &sunnyfitstepper::debug, this, &bluetooth::debug);
connect(sunnyfitStepper, &sunnyfitstepper::speedChanged, this, &bluetooth::speedChanged);
connect(sunnyfitStepper, &sunnyfitstepper::inclinationChanged, this, &bluetooth::inclinationChanged);
sunnyfitStepper->deviceDiscovered(b);
this->signalBluetoothDeviceConnected(sunnyfitStepper);
} else if ((b.name().toUpper().startsWith(QLatin1String("ECH-STRIDE")) ||
b.name().toUpper().startsWith(QLatin1String("ECH-UK-")) ||
b.name().toUpper().startsWith(QLatin1String("ECH-FR-")) ||
@@ -3650,6 +3663,11 @@ void bluetooth::restart() {
delete echelonStairclimber;
echelonStairclimber = nullptr;
}
if (sunnyfitStepper) {
delete sunnyfitStepper;
sunnyfitStepper = nullptr;
}
if (octaneTreadmill) {
delete octaneTreadmill;
@@ -4079,6 +4097,8 @@ bluetoothdevice *bluetooth::device() {
return echelonStride;
} else if (echelonStairclimber) {
return echelonStairclimber;
} else if (sunnyfitStepper) {
return sunnyfitStepper;
} else if (octaneTreadmill) {
return octaneTreadmill;
} else if (ziproTreadmill) {

View File

@@ -112,6 +112,7 @@
#include "signalhandler.h"
#include "devices/skandikawiribike/skandikawiribike.h"
#include "devices/smartrowrower/smartrowrower.h"
#include "devices/sunnyfitstepper/sunnyfitstepper.h"
#include "devices/smartspin2k/smartspin2k.h"
#include "devices/snodebike/snodebike.h"
#include "devices/strydrunpowersensor/strydrunpowersensor.h"
@@ -270,6 +271,7 @@ class bluetooth : public QObject, public SignalHandler {
echelonrower *echelonRower = nullptr;
ftmsrower *ftmsRower = nullptr;
smartrowrower *smartrowRower = nullptr;
sunnyfitstepper *sunnyfitStepper = nullptr;
echelonstride *echelonStride = nullptr;
echelonstairclimber *echelonStairclimber = nullptr;
lifefitnesstreadmill *lifefitnessTreadmill = nullptr;

View File

@@ -0,0 +1,389 @@
#include "sunnyfitstepper.h"
#ifdef Q_OS_ANDROID
#include "keepawakehelper.h"
#endif
#include "virtualdevices/virtualbike.h"
#include "virtualdevices/virtualtreadmill.h"
#include <QBluetoothLocalDevice>
#include <QDateTime>
#include <QFile>
#include <QMetaEnum>
#include <QSettings>
#include <chrono>
using namespace std::chrono_literals;
sunnyfitstepper::sunnyfitstepper(uint32_t pollDeviceTime, bool noConsole, bool noHeartService, double forceInitSpeed,
double forceInitInclination) {
m_watt.setType(metric::METRIC_WATT, deviceType());
Speed.setType(metric::METRIC_SPEED);
this->noConsole = noConsole;
this->noHeartService = noHeartService;
this->pollDeviceTime = pollDeviceTime;
refresh = new QTimer(this);
initDone = false;
frameBuffer.clear();
expectingSecondPart = false;
connect(refresh, &QTimer::timeout, this, &sunnyfitstepper::update);
refresh->start(pollDeviceTime);
}
bool sunnyfitstepper::connected() {
if (!m_control)
return false;
return m_control->state() == QLowEnergyController::DiscoveredState;
}
void sunnyfitstepper::writeCharacteristic(uint8_t *data, uint8_t data_len, const QString &info, bool disable_log,
bool wait_for_response) {
QEventLoop loop;
QTimer timeout;
if (wait_for_response) {
connect(this, &sunnyfitstepper::packetReceived, &loop, &QEventLoop::quit);
timeout.singleShot(300ms, &loop, &QEventLoop::quit);
} else {
connect(gattCommunicationChannelService, &QLowEnergyService::characteristicWritten, &loop, &QEventLoop::quit);
timeout.singleShot(300ms, &loop, &QEventLoop::quit);
}
if (gattCommunicationChannelService->state() != QLowEnergyService::ServiceState::ServiceDiscovered ||
m_control->state() == QLowEnergyController::UnconnectedState) {
emit debug(QStringLiteral("writeCharacteristic error because the connection is closed"));
return;
}
if (writeBuffer) {
delete writeBuffer;
}
writeBuffer = new QByteArray((const char *)data, data_len);
gattCommunicationChannelService->writeCharacteristic(gattWriteCharacteristic, *writeBuffer);
if (!disable_log) {
emit debug(QStringLiteral(" >> ") + writeBuffer->toHex(' ') +
QStringLiteral(" // ") + info);
}
loop.exec();
if (timeout.isActive() == false) {
emit debug(QStringLiteral(" exit for timeout"));
}
}
void sunnyfitstepper::sendPoll() {
// Alternate between two poll commands
counterPoll++;
}
void sunnyfitstepper::changeInclinationRequested(double grade, double percentage) {
if (percentage < 0)
percentage = 0;
changeInclination(grade, percentage);
}
void sunnyfitstepper::processDataFrame(const QByteArray &completeFrame) {
if (completeFrame.length() != 32) {
qDebug() << "ERROR: Frame length is not 32 bytes:" << completeFrame.length();
return;
}
if ((uint8_t)completeFrame.at(0) != 0x5a) {
qDebug() << "ERROR: Frame doesn't start with 0x5a";
return;
}
if ((uint8_t)completeFrame.at(1) != 0x05) {
qDebug() << "WARNING: Expected 0x05 at byte 1, got:" << QString::number((uint8_t)completeFrame.at(1), 16);
}
QDateTime now = QDateTime::currentDateTime();
QSettings settings;
// Extract cadence (bytes 6-7, little-endian)
uint16_t rawCadence = ((uint8_t)completeFrame.at(7) << 8) | (uint8_t)completeFrame.at(6);
Cadence = (double)rawCadence;
// Extract step count (bytes 10-12, little-endian)
uint32_t steps = ((uint32_t)(uint8_t)completeFrame.at(12) << 16) |
((uint32_t)(uint8_t)completeFrame.at(11) << 8) |
(uint32_t)(uint8_t)completeFrame.at(10);
StepCount = steps;
// Calculate elevation manually (0.2 meters per step)
elevationAcc = (double)steps * 0.20;
// Calculate speed from cadence (stairclimber convention)
Speed = Cadence.value() / 3.2;
qDebug() << QStringLiteral("Current Cadence (SPM): ") + QString::number(Cadence.value());
qDebug() << QStringLiteral("Current StepCount: ") + QString::number(StepCount.value());
qDebug() << QStringLiteral("Current Speed: ") + QString::number(Speed.value());
qDebug() << QStringLiteral("Current Elevation: ") + QString::number(elevationAcc.value());
// Calculate metrics
if (!firstCharacteristicChanged) {
if (watts(settings.value(QZSettings::weight, QZSettings::default_weight).toFloat())) {
KCal += ((((0.048 * ((double)watts(settings.value(QZSettings::weight, QZSettings::default_weight).toFloat())) +
1.19) *
settings.value(QZSettings::weight, QZSettings::default_weight).toFloat() * 3.5) /
200.0) /
(60000.0 / ((double)lastTimeCharacteristicChanged.msecsTo(now))));
}
Distance += ((Speed.value() / 3600.0) / (1000.0 / (lastTimeCharacteristicChanged.msecsTo(now))));
}
qDebug() << QStringLiteral("Current Distance: ") + QString::number(Distance.value());
qDebug() << QStringLiteral("Current KCal: ") + QString::number(KCal.value());
qDebug() << QStringLiteral("Current Watt: ") +
QString::number(watts(settings.value(QZSettings::weight, QZSettings::default_weight).toFloat()));
if (m_control->error() != QLowEnergyController::NoError)
qDebug() << QStringLiteral("QLowEnergyController ERROR!!") << m_control->errorString();
lastTimeCharacteristicChanged = now;
firstCharacteristicChanged = false;
}
void sunnyfitstepper::update() {
if (m_control->state() == QLowEnergyController::UnconnectedState) {
emit disconnected();
return;
}
if (initRequest) {
initRequest = false;
btinit();
} else if (m_control->state() == QLowEnergyController::DiscoveredState && gattCommunicationChannelService &&
gattWriteCharacteristic.isValid() && gattNotify1Characteristic.isValid() &&
gattNotify4Characteristic.isValid() && initDone) {
QSettings settings;
// *********** virtual treadmill init *************************************
if (!this->hasVirtualDevice()) {
bool virtual_device_enabled =
settings.value(QZSettings::virtual_device_enabled, QZSettings::default_virtual_device_enabled).toBool();
bool virtual_device_force_bike =
settings.value(QZSettings::virtual_device_force_bike, QZSettings::default_virtual_device_force_bike)
.toBool();
if (virtual_device_enabled) {
if (!virtual_device_force_bike) {
debug("creating virtual treadmill interface...");
auto virtualTreadMill = new virtualtreadmill(this, noHeartService);
connect(virtualTreadMill, &virtualtreadmill::debug, this, &sunnyfitstepper::debug);
connect(virtualTreadMill, &virtualtreadmill::changeInclination, this,
&sunnyfitstepper::changeInclinationRequested);
this->setVirtualDevice(virtualTreadMill, VIRTUAL_DEVICE_MODE::PRIMARY);
} else {
debug("creating virtual bike interface...");
auto virtualBike = new virtualbike(this);
connect(virtualBike, &virtualbike::changeInclination, this,
&sunnyfitstepper::changeInclinationRequested);
this->setVirtualDevice(virtualBike, VIRTUAL_DEVICE_MODE::ALTERNATIVE);
}
}
}
// ************************************************************
update_metrics(true, watts(settings.value(QZSettings::weight, QZSettings::default_weight).toFloat()));
// Send poll every 2 seconds
if (sec1Update++ >= (2000 / refresh->interval())) {
sec1Update = 0;
//sendPoll();
}
}
}
void sunnyfitstepper::serviceDiscovered(const QBluetoothUuid &gatt) {
emit debug(QStringLiteral("serviceDiscovered ") + gatt.toString());
}
void sunnyfitstepper::characteristicChanged(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue) {
emit debug(QStringLiteral(" << ") + newValue.toHex(' '));
// Handle command responses (Notify 1)
if (characteristic.uuid() == QBluetoothUuid(QStringLiteral("fd710003-e950-458e-8a4d-a1cbc5aa4cce"))) {
qDebug() << "Command response:" << newValue.toHex(' ');
emit packetReceived();
return;
}
// Handle main data stream (Notify 4) - SPLIT FRAME LOGIC
if (characteristic.uuid() == QBluetoothUuid(QStringLiteral("fd710006-e950-458e-8a4d-a1cbc5aa4cce"))) {
// First part: 20 bytes starting with 0x5a
if (newValue.length() == 20 && (uint8_t)newValue.at(0) == 0x5a) {
frameBuffer.clear();
frameBuffer.append(newValue);
expectingSecondPart = true;
qDebug() << "First part of frame received (20 bytes)";
return;
}
// Second part: 12 bytes
if (newValue.length() == 12 && expectingSecondPart) {
frameBuffer.append(newValue);
expectingSecondPart = false;
if (frameBuffer.length() == 32) {
emit debug(QStringLiteral(" << COMPLETE FRAME >> ") + frameBuffer.toHex(' '));
processDataFrame(frameBuffer);
frameBuffer.clear();
} else {
qDebug() << "ERROR: Complete frame size mismatch:" << frameBuffer.length();
frameBuffer.clear();
}
return;
}
// Unexpected frame structure
qDebug() << "Unexpected frame - length:" << newValue.length() << "expecting second part:" << expectingSecondPart;
frameBuffer.clear();
expectingSecondPart = false;
}
}
void sunnyfitstepper::btinit() {
uint8_t init1[] = {0x5a, 0x02, 0x00, 0x08, 0x07, 0xa0, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0xe6, 0xa5};
uint8_t init2[] = {0x5a, 0x02, 0x00, 0x03, 0x02, 0xa3, 0x00, 0xaa, 0xa5};
uint8_t init3[] = {0x5a, 0x02, 0x00, 0x03, 0x02, 0xb4, 0x00, 0xbb, 0xa5};
uint8_t init4[] = {0x5a, 0x04, 0x00, 0x03, 0x02, 0xf1, 0x00, 0xfa, 0xa5};
writeCharacteristic(init1, sizeof(init1), QStringLiteral("init1"), false, true);
writeCharacteristic(init2, sizeof(init2), QStringLiteral("init2"), false, true);
writeCharacteristic(init3, sizeof(init3), QStringLiteral("init3"), false, false);
writeCharacteristic(init4, sizeof(init4), QStringLiteral("init4"), false, false);
initDone = true;
}
void sunnyfitstepper::stateChanged(QLowEnergyService::ServiceState state) {
QBluetoothUuid _gattWriteCharacteristicId(QStringLiteral("fd710002-e950-458e-8a4d-a1cbc5aa4cce"));
QBluetoothUuid _gattNotify1CharacteristicId(QStringLiteral("fd710003-e950-458e-8a4d-a1cbc5aa4cce"));
QBluetoothUuid _gattNotify4CharacteristicId(QStringLiteral("fd710006-e950-458e-8a4d-a1cbc5aa4cce"));
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyService::ServiceState>();
qDebug() << QStringLiteral("BTLE stateChanged ") + QString::fromLocal8Bit(metaEnum.valueToKey(state));
if (state == QLowEnergyService::ServiceDiscovered) {
gattWriteCharacteristic = gattCommunicationChannelService->characteristic(_gattWriteCharacteristicId);
gattNotify1Characteristic = gattCommunicationChannelService->characteristic(_gattNotify1CharacteristicId);
gattNotify4Characteristic = gattCommunicationChannelService->characteristic(_gattNotify4CharacteristicId);
Q_ASSERT(gattWriteCharacteristic.isValid());
Q_ASSERT(gattNotify1Characteristic.isValid());
Q_ASSERT(gattNotify4Characteristic.isValid());
// establish hook into notifications
connect(gattCommunicationChannelService, &QLowEnergyService::characteristicChanged, this,
&sunnyfitstepper::characteristicChanged);
connect(gattCommunicationChannelService, &QLowEnergyService::characteristicWritten, this,
&sunnyfitstepper::characteristicWritten);
connect(gattCommunicationChannelService, SIGNAL(error(QLowEnergyService::ServiceError)), this,
SLOT(errorService(QLowEnergyService::ServiceError)));
connect(gattCommunicationChannelService, &QLowEnergyService::descriptorWritten, this,
&sunnyfitstepper::descriptorWritten);
QByteArray descriptor;
descriptor.append((char)0x01);
descriptor.append((char)0x00);
gattCommunicationChannelService->writeDescriptor(
gattNotify1Characteristic.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration), descriptor);
gattCommunicationChannelService->writeDescriptor(
gattNotify4Characteristic.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration), descriptor);
initRequest = true;
}
}
void sunnyfitstepper::descriptorWritten(const QLowEnergyDescriptor &descriptor, const QByteArray &newValue) {
emit debug(QStringLiteral("descriptorWritten ") + descriptor.name() + " " + newValue.toHex(' '));
emit connectedAndDiscovered();
}
void sunnyfitstepper::characteristicWritten(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue) {
Q_UNUSED(characteristic);
emit debug(QStringLiteral("characteristicWritten ") + newValue.toHex(' '));
}
void sunnyfitstepper::serviceScanDone(void) {
qDebug() << QStringLiteral("serviceScanDone");
auto services_list = m_control->services();
for (const QBluetoothUuid &s : qAsConst(services_list)) {
qDebug() << s << "service found!";
}
QBluetoothUuid _gattCommunicationChannelServiceId(QStringLiteral("fd710001-e950-458e-8a4d-a1cbc5aa4cce"));
gattCommunicationChannelService = m_control->createServiceObject(_gattCommunicationChannelServiceId);
if (gattCommunicationChannelService == nullptr) {
qDebug() << "invalid service";
return;
}
connect(gattCommunicationChannelService, &QLowEnergyService::stateChanged, this, &sunnyfitstepper::stateChanged);
gattCommunicationChannelService->discoverDetails();
}
void sunnyfitstepper::errorService(QLowEnergyService::ServiceError err) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyService::ServiceError>();
emit debug(QStringLiteral("sunnyfitstepper::errorService ") + QString::fromLocal8Bit(metaEnum.valueToKey(err)) +
m_control->errorString());
}
void sunnyfitstepper::error(QLowEnergyController::Error err) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyController::Error>();
emit debug(QStringLiteral("sunnyfitstepper::error ") + QString::fromLocal8Bit(metaEnum.valueToKey(err)) +
m_control->errorString());
}
void sunnyfitstepper::controllerStateChanged(QLowEnergyController::ControllerState state) {
qDebug() << QStringLiteral("sunnyfitstepper::controllerStateChanged") << state;
if (state == QLowEnergyController::UnconnectedState) {
emit disconnected();
}
}
void sunnyfitstepper::deviceDiscovered(const QBluetoothDeviceInfo &device) {
{
bluetoothDevice = device;
m_control = QLowEnergyController::createCentral(bluetoothDevice, this);
connect(m_control, &QLowEnergyController::serviceDiscovered, this, &sunnyfitstepper::serviceDiscovered);
connect(m_control, &QLowEnergyController::discoveryFinished, this, &sunnyfitstepper::serviceScanDone);
connect(m_control, SIGNAL(error(QLowEnergyController::Error)), this, SLOT(error(QLowEnergyController::Error)));
connect(m_control, &QLowEnergyController::stateChanged, this, &sunnyfitstepper::controllerStateChanged);
connect(m_control,
static_cast<void (QLowEnergyController::*)(QLowEnergyController::Error)>(&QLowEnergyController::error),
this, [this](QLowEnergyController::Error error) {
Q_UNUSED(error);
Q_UNUSED(this);
emit debug(QStringLiteral("Cannot connect to remote device."));
emit disconnected();
});
connect(m_control, &QLowEnergyController::connected, this, [this]() {
Q_UNUSED(this);
emit debug(QStringLiteral("Controller connected. Search services..."));
m_control->discoverServices();
});
connect(m_control, &QLowEnergyController::disconnected, this, [this]() {
Q_UNUSED(this);
emit debug(QStringLiteral("QLowEnergyController disconnected"));
emit disconnected();
});
m_control->connectToDevice();
}
}
void sunnyfitstepper::startDiscover() {
m_control->discoverServices();
}

View File

@@ -0,0 +1,99 @@
#ifndef SUNNYFITSTEPPER_H
#define SUNNYFITSTEPPER_H
#include <QBluetoothDeviceDiscoveryAgent>
#include <QtBluetooth/qlowenergyadvertisingdata.h>
#include <QtBluetooth/qlowenergyadvertisingparameters.h>
#include <QtBluetooth/qlowenergycharacteristic.h>
#include <QtBluetooth/qlowenergycharacteristicdata.h>
#include <QtBluetooth/qlowenergycontroller.h>
#include <QtBluetooth/qlowenergydescriptordata.h>
#include <QtBluetooth/qlowenergyservice.h>
#include <QtBluetooth/qlowenergyservicedata.h>
#include <QtCore/qbytearray.h>
#ifndef Q_OS_ANDROID
#include <QtCore/qcoreapplication.h>
#else
#include <QtGui/qguiapplication.h>
#endif
#include <QtCore/qlist.h>
#include <QtCore/qmutex.h>
#include <QtCore/qscopedpointer.h>
#include <QtCore/qtimer.h>
#include <QDateTime>
#include <QObject>
#include <QString>
#include "stairclimber.h"
#ifdef Q_OS_IOS
#include "ios/lockscreen.h"
#endif
class sunnyfitstepper : public stairclimber {
Q_OBJECT
public:
sunnyfitstepper(uint32_t pollDeviceTime = 200, bool noConsole = false, bool noHeartService = false,
double forceInitSpeed = 0.0, double forceInitInclination = 0.0);
bool connected() override;
private:
void btinit();
void sendPoll();
void writeCharacteristic(uint8_t *data, uint8_t data_len, const QString &info, bool disable_log = false,
bool wait_for_response = false);
void processDataFrame(const QByteArray &completeFrame);
void startDiscover();
// Bluetooth
QLowEnergyService *gattCommunicationChannelService = nullptr;
QLowEnergyCharacteristic gattWriteCharacteristic;
QLowEnergyCharacteristic gattNotify1Characteristic;
QLowEnergyCharacteristic gattNotify4Characteristic;
// Split-frame handling (CRITICAL)
QByteArray frameBuffer;
bool expectingSecondPart = false;
// State
QTimer *refresh;
uint8_t sec1Update = 0;
uint8_t counterPoll = 0;
bool initDone = false;
bool initRequest = false;
bool noConsole = false;
bool noHeartService = false;
uint32_t pollDeviceTime = 200;
QDateTime lastTimeCharacteristicChanged;
bool firstCharacteristicChanged = true;
#ifdef Q_OS_IOS
lockscreen *h = 0;
#endif
signals:
void disconnected();
void debug(QString string);
void speedChanged(double speed);
void packetReceived();
public slots:
void deviceDiscovered(const QBluetoothDeviceInfo &device);
private slots:
void characteristicChanged(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue);
void characteristicWritten(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue);
void descriptorWritten(const QLowEnergyDescriptor &descriptor, const QByteArray &newValue);
void stateChanged(QLowEnergyService::ServiceState state);
void controllerStateChanged(QLowEnergyController::ControllerState state);
void changeInclinationRequested(double grade, double percentage);
void serviceDiscovered(const QBluetoothUuid &gatt);
void serviceScanDone(void);
void update();
void error(QLowEnergyController::Error err);
void errorService(QLowEnergyService::ServiceError);
};
#endif // SUNNYFITSTEPPER_H

View File

@@ -106,6 +106,7 @@ SOURCES += \
$$PWD/devices/thinkridercontroller/thinkridercontroller.cpp \
$$PWD/devices/stairclimber.cpp \
$$PWD/devices/echelonstairclimber/echelonstairclimber.cpp \
$$PWD/devices/sunnyfitstepper/sunnyfitstepper.cpp \
$$PWD/devices/technogymbike/technogymbike.cpp \
$$PWD/devices/trxappgateusbelliptical/trxappgateusbelliptical.cpp \
$$PWD/fitdatabaseprocessor.cpp \
@@ -367,6 +368,7 @@ HEADERS += \
$$PWD/devices/cycleopsphantombike/cycleopsphantombike.h \
$$PWD/devices/deeruntreadmill/deerruntreadmill.h \
$$PWD/devices/echelonstairclimber/echelonstairclimber.h \
$$PWD/devices/sunnyfitstepper/sunnyfitstepper.h \
$$PWD/devices/elitesquarecontroller/elitesquarecontroller.h \
$$PWD/devices/focustreadmill/focustreadmill.h \
$$PWD/devices/jumprope.h \

View File

@@ -457,7 +457,7 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
sessionMesg.SetAvgStrokeDistance(session.last().avgStrokesLength);
} else if (type == STAIRCLIMBER) {
sessionMesg.SetSport(FIT_SPORT_GENERIC);
sessionMesg.SetSport(FIT_SPORT_FITNESS_EQUIPMENT);
sessionMesg.SetSubSport(FIT_SUB_SPORT_STAIR_CLIMBING);
} else if (type == JUMPROPE) {
@@ -702,7 +702,7 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
lapMesg.SetSport(FIT_SPORT_JUMP_ROPE);
} else if (type == STAIRCLIMBER) {
lapMesg.SetSport(FIT_SPORT_GENERIC);
lapMesg.SetSport(FIT_SPORT_FITNESS_EQUIPMENT);
lapMesg.SetSubSport(FIT_SUB_SPORT_STAIR_CLIMBING);
} else {

View File

@@ -0,0 +1,225 @@
#pragma once
#include "gtest/gtest.h"
#include <QByteArray>
#include <vector>
/**
* @brief Sunnyfit Mini Stepper (SF-S) BLE Packet Test Data
*
* Extracted from btsnoop_hci.log capture of actual device communication.
* These are the 20-byte data frames (0x5a 0x05) from the capture file.
*/
class SunnyfitStepperTestData {
public:
/**
* @brief Raw 20-byte data frames captured from actual device
* Format: 0x5a (start) + 0x05 (command) + 18 bytes of data
*
* Byte positions:
* [0]: 0x5a (start marker)
* [1]: 0x05 (command type - data frame)
* [6]: Cadence (SPM) - single byte
* [16]: Step Counter (increments 0, 1, 2, 3...)
*/
static const std::vector<QByteArray> getTestFrames() {
return {
// Frame 0: cadence=0, step=0
QByteArray::fromHex("5a05001a032200000524000000000003260000052900"),
// Frame 1: cadence=0, step=1
QByteArray::fromHex("5a05001a032200000524010000000003260100052900"),
// Frame 2: cadence=0, step=2
QByteArray::fromHex("5a05001a032200000524020000000003260200052900"),
// Frame 3: cadence=32, step=3
QByteArray::fromHex("5a05001a032220000524020000000003260300052900"),
// Frame 4: cadence=67, step=4
QByteArray::fromHex("5a05001a032243000524040000000003260400052900"),
// Frame 5: cadence=67, step=5
QByteArray::fromHex("5a05001a032243000524040000000003260500052900"),
// Frame 6: cadence=67, step=6
QByteArray::fromHex("5a05001a032243000524040000000003260600052900"),
// Frame 7: cadence=20, step=7
QByteArray::fromHex("5a05001a032214000524050000000003260700052900"),
// Frame 8: cadence=53, step=8
QByteArray::fromHex("5a05001a032235000524070000000003260800052900"),
// Frame 9: cadence=63, step=9
QByteArray::fromHex("5a05001a03223f000524080000000003260900052900"),
// Frame 10: cadence=63, step=10
QByteArray::fromHex("5a05001a03223f000524080000000003260a00052900"),
};
}
/**
* @brief Expected extracted values from each test frame
*/
struct ExpectedMetrics {
int frameIndex;
double expectedCadence;
int expectedStepCount;
double expectedSpeed; // cadence / 3.2
};
static const std::vector<ExpectedMetrics> getExpectedValues() {
return {
{0, 0.0, 0, 0.0}, // cadence=0, step=0
{1, 0.0, 1, 0.0}, // cadence=0, step=1
{2, 0.0, 2, 0.0}, // cadence=0, step=2
{3, 32.0, 3, 10.0}, // cadence=32, step=3, speed=32/3.2=10
{4, 67.0, 4, 20.9375}, // cadence=67, step=4, speed=67/3.2≈20.94
{5, 67.0, 5, 20.9375}, // cadence=67, step=5
{6, 67.0, 6, 20.9375}, // cadence=67, step=6
{7, 20.0, 7, 6.25}, // cadence=20, step=7, speed=20/3.2=6.25
{8, 53.0, 8, 16.5625}, // cadence=53, step=8, speed=53/3.2≈16.56
{9, 63.0, 9, 19.6875}, // cadence=63, step=9, speed=63/3.2≈19.69
{10, 63.0, 10, 19.6875}, // cadence=63, step=10
};
}
/**
* @brief Parse a single 20-byte frame and extract metrics
* @return pair<cadence, stepCount> or returns {-1, -1} on error
*/
static std::pair<double, int> parseFrame(const QByteArray& frame) {
if (frame.length() != 20) {
return {-1, -1};
}
if ((uint8_t)frame[0] != 0x5a) {
return {-1, -1};
}
// Extract cadence from byte 6 (single byte)
double cadence = (double)(uint8_t)frame[6];
// Extract step counter from byte 16 (single byte, little-endian)
int stepCount = (uint8_t)frame[16];
return {cadence, stepCount};
}
};
/**
* @brief Test suite for Sunnyfit Stepper frame parsing
*/
class SunnyfitStepperParsingTest : public testing::Test {
protected:
SunnyfitStepperTestData testData;
};
/**
* @brief Test parsing of individual frames
*/
TEST_F(SunnyfitStepperParsingTest, ParseFrames) {
auto frames = SunnyfitStepperTestData::getTestFrames();
auto expectedValues = SunnyfitStepperTestData::getExpectedValues();
ASSERT_EQ(frames.size(), expectedValues.size())
<< "Test data mismatch: frames and expected values should have same size";
for (size_t i = 0; i < frames.size(); ++i) {
auto [cadence, stepCount] = SunnyfitStepperTestData::parseFrame(frames[i]);
EXPECT_EQ(cadence, expectedValues[i].expectedCadence)
<< "Frame " << i << ": Cadence mismatch";
EXPECT_EQ(stepCount, expectedValues[i].expectedStepCount)
<< "Frame " << i << ": Step count mismatch";
}
}
/**
* @brief Test speed calculation from cadence
*/
TEST_F(SunnyfitStepperParsingTest, CalculateSpeed) {
auto frames = SunnyfitStepperTestData::getTestFrames();
auto expectedValues = SunnyfitStepperTestData::getExpectedValues();
for (size_t i = 0; i < frames.size(); ++i) {
auto [cadence, stepCount] = SunnyfitStepperTestData::parseFrame(frames[i]);
double calculatedSpeed = cadence / 3.2;
EXPECT_DOUBLE_EQ(calculatedSpeed, expectedValues[i].expectedSpeed)
<< "Frame " << i << ": Speed calculation mismatch (cadence=" << cadence << ")";
}
}
/**
* @brief Test step counter increments
*/
TEST_F(SunnyfitStepperParsingTest, StepCounterIncrement) {
auto frames = SunnyfitStepperTestData::getTestFrames();
auto expectedValues = SunnyfitStepperTestData::getExpectedValues();
int previousSteps = -1;
for (size_t i = 0; i < frames.size(); ++i) {
auto [cadence, stepCount] = SunnyfitStepperTestData::parseFrame(frames[i]);
if (previousSteps >= 0) {
int increment = stepCount - previousSteps;
EXPECT_EQ(increment, 1)
<< "Frame " << i << ": Step counter should increment by 1 (was "
<< previousSteps << ", now " << stepCount << ")";
}
previousSteps = stepCount;
}
}
/**
* @brief Test cadence variation detection
*/
TEST_F(SunnyfitStepperParsingTest, CadenceVariation) {
auto frames = SunnyfitStepperTestData::getTestFrames();
std::vector<double> cadences;
for (const auto& frame : frames) {
auto [cadence, stepCount] = SunnyfitStepperTestData::parseFrame(frame);
cadences.push_back(cadence);
}
// Verify we have cadence variation in the test data
double minCadence = *std::min_element(cadences.begin(), cadences.end());
double maxCadence = *std::max_element(cadences.begin(), cadences.end());
EXPECT_LT(minCadence, maxCadence)
<< "Test data should have cadence variation";
EXPECT_EQ(minCadence, 0.0)
<< "Minimum cadence should be 0";
EXPECT_EQ(maxCadence, 67.0)
<< "Maximum cadence should be 67";
}
/**
* @brief Test frame validation
*/
TEST_F(SunnyfitStepperParsingTest, FrameValidation) {
// Invalid length
QByteArray shortFrame = QByteArray::fromHex("5a05001a0322");
auto [cadence, stepCount] = SunnyfitStepperTestData::parseFrame(shortFrame);
EXPECT_EQ(cadence, -1) << "Should reject short frames";
EXPECT_EQ(stepCount, -1) << "Should reject short frames";
// Invalid start marker
QByteArray invalidStart = QByteArray::fromHex("0105001a032200000524000000000003260000052900");
std::tie(cadence, stepCount) = SunnyfitStepperTestData::parseFrame(invalidStart);
EXPECT_EQ(cadence, -1) << "Should reject frames with invalid start marker";
EXPECT_EQ(stepCount, -1) << "Should reject frames with invalid start marker";
// Valid frame
QByteArray validFrame = SunnyfitStepperTestData::getTestFrames()[3];
std::tie(cadence, stepCount) = SunnyfitStepperTestData::parseFrame(validFrame);
EXPECT_EQ(cadence, 32.0) << "Should parse valid frame";
EXPECT_EQ(stepCount, 3) << "Should parse valid frame";
}

View File

@@ -54,6 +54,7 @@ HEADERS += \
Devices/deviceindex.h \
Devices/devicenamepatterngroup.h \
Devices/devicetestdataindex.h \
Devices/TestSunnyfitStepper.h \
Erg/ergtabletestsuite.h \
GarminConnect/garminconnecttestsuite.h \
ToolTests/qfittestsuite.h \