mirror of
https://github.com/cagnulein/qdomyos-zwift.git
synced 2026-02-17 16:07:43 +01:00
SunnyFit Stepper (#4245)
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(tshark:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(git log:*)"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
389
src/devices/sunnyfitstepper/sunnyfitstepper.cpp
Normal file
389
src/devices/sunnyfitstepper/sunnyfitstepper.cpp
Normal 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();
|
||||
}
|
||||
99
src/devices/sunnyfitstepper/sunnyfitstepper.h
Normal file
99
src/devices/sunnyfitstepper/sunnyfitstepper.h
Normal 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
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
225
tst/Devices/TestSunnyfitStepper.h
Normal file
225
tst/Devices/TestSunnyfitStepper.h
Normal 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";
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user