diff --git a/src/devices/echelonrower/echelonrower.cpp b/src/devices/echelonrower/echelonrower.cpp index 738580290f..1f68429ee4 100644 --- a/src/devices/echelonrower/echelonrower.cpp +++ b/src/devices/echelonrower/echelonrower.cpp @@ -195,13 +195,16 @@ double echelonrower::bikeResistanceToPeloton(double resistance) { void echelonrower::characteristicChanged(const QLowEnergyCharacteristic &characteristic, const QByteArray &newvalue) { // qDebug() << "characteristicChanged" << characteristic.uuid() << newValue << newValue.length(); - Q_UNUSED(characteristic); QSettings settings; QString heartRateBeltName = settings.value(QZSettings::heart_rate_belt_name, QZSettings::default_heart_rate_belt_name).toString(); qDebug() << QStringLiteral(" << ") + newvalue.toHex(' '); + if (auto *virtualRower = dynamic_cast(VirtualDevice())) { + virtualRower->relayEchelonPacket(characteristic.uuid(), newvalue); + } + if (lastPacket.count() > 0 && lastPacket.count() + newvalue.count() == 21 && ((unsigned char)lastPacket.at(0)) == 0xf0) { lastPacket = lastPacket.append(newvalue); qDebug() << QStringLiteral(" << concatenated ") + lastPacket.toHex(' '); @@ -284,7 +287,9 @@ void echelonrower::characteristicChanged(const QLowEnergyCharacteristic &charact settings.value(QZSettings::ios_peloton_workaround, QZSettings::default_ios_peloton_workaround).toBool(); bool virtual_device_rower = settings.value(QZSettings::virtual_device_rower, QZSettings::default_virtual_device_rower).toBool(); - if (ios_peloton_workaround && cadence && !virtual_device_rower && h && firstStateChanged) { + bool virtual_device_echelon = + settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); + if (ios_peloton_workaround && cadence && !virtual_device_rower && h && firstStateChanged && !virtual_device_echelon) { h->virtualbike_setCadence(currentCrankRevolutions(), lastCrankEventTime()); h->virtualbike_setHeartRate((uint8_t)metrics_override_heartrate()); } @@ -397,7 +402,9 @@ void echelonrower::stateChanged(QLowEnergyService::ServiceState state) { settings.value(QZSettings::bike_cadence_sensor, QZSettings::default_bike_cadence_sensor).toBool(); bool ios_peloton_workaround = settings.value(QZSettings::ios_peloton_workaround, QZSettings::default_ios_peloton_workaround).toBool(); - if (ios_peloton_workaround && cadence && !virtual_device_rower) { + bool virtual_device_echelon = + settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); + if (ios_peloton_workaround && cadence && !virtual_device_rower && !virtual_device_echelon) { qDebug() << "ios_peloton_workaround activated!"; h = new lockscreen(); h->virtualbike_ios(); @@ -508,6 +515,17 @@ bool echelonrower::connected() { return m_control->state() == QLowEnergyController::DiscoveredState; } +void echelonrower::proxyVirtualRowerCommand(const QByteArray &value) { + if (!gattCommunicationChannelService || !gattWriteCharacteristic.isValid() || !m_control || + m_control->state() == QLowEnergyController::UnconnectedState) { + qDebug() << QStringLiteral("proxyVirtualRowerCommand ignored because the Echelon rower is not ready"); + return; + } + + writeCharacteristic(reinterpret_cast(const_cast(value.constData())), value.size(), + QStringLiteral("virtual echelon rower proxy")); +} + uint16_t echelonrower::watts() { if (currentCadence().value() == 0) { return 0; diff --git a/src/devices/echelonrower/echelonrower.h b/src/devices/echelonrower/echelonrower.h index 9bf119be3c..ed56a73c45 100644 --- a/src/devices/echelonrower/echelonrower.h +++ b/src/devices/echelonrower/echelonrower.h @@ -40,6 +40,7 @@ class echelonrower : public rower { resistance_t resistanceFromPowerRequest(uint16_t power) override; resistance_t maxResistance() override{ return max_resistance; } bool connected() override; + void proxyVirtualRowerCommand(const QByteArray &value); private: const resistance_t max_resistance = 32; diff --git a/src/devices/echelonstride/echelonstride.cpp b/src/devices/echelonstride/echelonstride.cpp index 52e2e0cb21..faebc09782 100644 --- a/src/devices/echelonstride/echelonstride.cpp +++ b/src/devices/echelonstride/echelonstride.cpp @@ -252,11 +252,14 @@ void echelonstride::characteristicChanged(const QLowEnergyCharacteristic &charac QSettings settings; QString heartRateBeltName = settings.value(QZSettings::heart_rate_belt_name, QZSettings::default_heart_rate_belt_name).toString(); - Q_UNUSED(characteristic); QByteArray value = newValue; qDebug() << QStringLiteral(" << ") + newValue.toHex(' '); + if (auto *virtualTreadmill = dynamic_cast(VirtualDevice())) { + virtualTreadmill->relayEchelonPacket(characteristic.uuid(), newValue); + } + lastPacket = newValue; if (((unsigned char)newValue.at(0)) == 0xf0 && ((unsigned char)newValue.at(1)) == 0xd3) { @@ -533,6 +536,17 @@ bool echelonstride::connected() { return m_control->state() == QLowEnergyController::DiscoveredState; } +void echelonstride::proxyVirtualTreadmillCommand(const QByteArray &value) { + if (!gattCommunicationChannelService || !gattWriteCharacteristic.isValid() || !m_control || + m_control->state() == QLowEnergyController::UnconnectedState) { + qDebug() << QStringLiteral("proxyVirtualTreadmillCommand ignored because the Echelon treadmill is not ready"); + return; + } + + writeCharacteristic(reinterpret_cast(const_cast(value.constData())), value.size(), + QStringLiteral("virtual echelon treadmill proxy")); +} + bool echelonstride::autoPauseWhenSpeedIsZero() { if (lastStart == 0 || QDateTime::currentMSecsSinceEpoch() > (lastStart + 10000)) return true; diff --git a/src/devices/echelonstride/echelonstride.h b/src/devices/echelonstride/echelonstride.h index c7906450ab..ce06570cad 100644 --- a/src/devices/echelonstride/echelonstride.h +++ b/src/devices/echelonstride/echelonstride.h @@ -39,6 +39,7 @@ class echelonstride : public treadmill { double forceInitSpeed = 0.0, double forceInitInclination = 0.0); bool connected() override; double minStepInclination() override; + void proxyVirtualTreadmillCommand(const QByteArray &value); bool autoPauseWhenSpeedIsZero() override; bool autoStartWhenSpeedIsGreaterThenZero() override; diff --git a/src/devices/fakerower/fakerower.cpp b/src/devices/fakerower/fakerower.cpp index 07c88fb5ad..95ce25c37e 100644 --- a/src/devices/fakerower/fakerower.cpp +++ b/src/devices/fakerower/fakerower.cpp @@ -81,7 +81,9 @@ void fakerower::update() { settings.value(QZSettings::bike_cadence_sensor, QZSettings::default_bike_cadence_sensor).toBool(); bool ios_peloton_workaround = settings.value(QZSettings::ios_peloton_workaround, QZSettings::default_ios_peloton_workaround).toBool(); - if (ios_peloton_workaround && cadence && !virtual_device_rower) { + bool virtual_device_echelon = + settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); + if (ios_peloton_workaround && cadence && !virtual_device_rower && !virtual_device_echelon) { qDebug() << "ios_peloton_workaround activated!"; h = new lockscreen(); h->virtualbike_ios(); @@ -123,7 +125,9 @@ void fakerower::update() { settings.value(QZSettings::bike_cadence_sensor, QZSettings::default_bike_cadence_sensor).toBool(); bool ios_peloton_workaround = settings.value(QZSettings::ios_peloton_workaround, QZSettings::default_ios_peloton_workaround).toBool(); - if (ios_peloton_workaround && cadence && h && firstStateChanged) { + bool virtual_device_echelon = + settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); + if (ios_peloton_workaround && cadence && h && firstStateChanged && !virtual_device_echelon) { h->virtualbike_setCadence(currentCrankRevolutions(), lastCrankEventTime()); h->virtualbike_setHeartRate((uint8_t)metrics_override_heartrate()); } diff --git a/src/devices/faketreadmill/faketreadmill.cpp b/src/devices/faketreadmill/faketreadmill.cpp index 52e310ce96..eb690b974e 100644 --- a/src/devices/faketreadmill/faketreadmill.cpp +++ b/src/devices/faketreadmill/faketreadmill.cpp @@ -108,7 +108,9 @@ void faketreadmill::update() { settings.value(QZSettings::bike_cadence_sensor, QZSettings::default_bike_cadence_sensor).toBool(); bool ios_peloton_workaround = settings.value(QZSettings::ios_peloton_workaround, QZSettings::default_ios_peloton_workaround).toBool(); - if (ios_peloton_workaround && cadence && h && firstStateChanged) { + bool virtual_device_echelon = + settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); + if (ios_peloton_workaround && cadence && h && firstStateChanged && !virtual_device_echelon) { h->virtualbike_setCadence(currentCrankRevolutions(), lastCrankEventTime()); h->virtualbike_setHeartRate((uint8_t)metrics_override_heartrate()); } diff --git a/src/virtualdevices/virtualrower.cpp b/src/virtualdevices/virtualrower.cpp index 1ca92853c4..bdea0264a3 100644 --- a/src/virtualdevices/virtualrower.cpp +++ b/src/virtualdevices/virtualrower.cpp @@ -1,4 +1,5 @@ #include "virtualdevices/virtualrower.h" +#include "devices/echelonrower/echelonrower.h" #include "qsettings.h" #include "qzsettings.h" #include "rower.h" @@ -11,6 +12,21 @@ #include #include +namespace { +QByteArray echelonPacket(std::initializer_list bytes) { + QByteArray value; + uint8_t checksum = 0; + + for (uint8_t byte : bytes) { + value.append(static_cast(byte)); + checksum = static_cast(checksum + byte); + } + + value.append(static_cast(checksum)); + return value; +} +} + // PM5 Concept2 BLE UUIDs // Base UUID: CE06XXXX-43E5-11E4-916C-0800200C9A66 @@ -86,6 +102,9 @@ virtualrower::virtualrower(bluetoothdevice *t, bool noWriteResistance, bool noHe QSettings settings; bool heart_only = settings.value(QZSettings::virtual_device_onlyheart, QZSettings::default_virtual_device_onlyheart).toBool(); + bool echelon = settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); + const QString echelonAdvertisingName = + !t->bluetoothDevice.name().isEmpty() ? t->bluetoothDevice.name() : QStringLiteral("ROW-S-015244"); // Check if PM5 mode is enabled pm5Mode = settings.value(QZSettings::virtual_device_rower_pm5, QZSettings::default_virtual_device_rower_pm5).toBool(); @@ -116,7 +135,9 @@ virtualrower::virtualrower(bluetoothdevice *t, bool noWriteResistance, bool noHe advertisingData.setDiscoverability(QLowEnergyAdvertisingData::DiscoverabilityGeneral); advertisingData.setIncludePowerLevel(true); - if (pm5Mode) { + if (echelon) { + advertisingData.setLocalName(echelonAdvertisingName); + } else if (pm5Mode) { // PM5 device name format: "PM5 XXXXXX" where XXXXXX is serial number advertisingData.setLocalName(QStringLiteral("PM5 430000000")); qDebug() << "Advertising as PM5 Concept2 rower"; @@ -126,7 +147,7 @@ virtualrower::virtualrower(bluetoothdevice *t, bool noWriteResistance, bool noHe QList services; - if (!heart_only) { + if (!heart_only && !echelon) { if (pm5Mode) { // PM5 uses Concept2 proprietary services // Only advertise the discovery service UUID (128-bit UUIDs are large) @@ -136,18 +157,23 @@ virtualrower::virtualrower(bluetoothdevice *t, bool noWriteResistance, bool noHe services << ((QBluetoothUuid::ServiceClassUuid)0x1826); } } + + if (echelon) { + services << QBluetoothUuid(QStringLiteral("0bf669f0-45f2-11e7-9598-0800200c9a66")); + } + if (!this->noHeartService || heart_only) { services << QBluetoothUuid::HeartRate; } - if (!pm5Mode) { + if (!pm5Mode && !echelon) { services << ((QBluetoothUuid::ServiceClassUuid)0xFF00); } advertisingData.setServices(services); //! [Advertising Data] - if (!heart_only) { + if (!heart_only && !echelon) { if (pm5Mode) { // Setup PM5 Concept2 rowing service setupPM5Services(); @@ -248,11 +274,81 @@ virtualrower::virtualrower(bluetoothdevice *t, bool noWriteResistance, bool noHe serviceDataHR.addCharacteristic(charDataHR); } + if (echelon) { + serviceDataEchelonDiscovery.setType(QLowEnergyServiceData::ServiceTypePrimary); + serviceDataEchelonDiscovery.setUuid(QBluetoothUuid(QStringLiteral("0bf669f0-45f2-11e7-9598-0800200c9a66"))); + + serviceDataEchelon.setType(QLowEnergyServiceData::ServiceTypePrimary); + serviceDataEchelon.setUuid(QBluetoothUuid(QStringLiteral("0bf669f1-45f2-11e7-9598-0800200c9a66"))); + + QLowEnergyCharacteristicData echelonWrite; + echelonWrite.setUuid(QBluetoothUuid(QStringLiteral("0bf669f2-45f2-11e7-9598-0800200c9a66"))); + echelonWrite.setProperties(QLowEnergyCharacteristic::Write | QLowEnergyCharacteristic::WriteNoResponse); + + QLowEnergyCharacteristicData echelonNotify1; + echelonNotify1.setUuid(QBluetoothUuid(QStringLiteral("0bf669f3-45f2-11e7-9598-0800200c9a66"))); + echelonNotify1.setProperties(QLowEnergyCharacteristic::Notify); + echelonNotify1.addDescriptor(QLowEnergyDescriptorData(QBluetoothUuid::ClientCharacteristicConfiguration, + QByteArray::fromHex("0100"))); + + QLowEnergyCharacteristicData echelonNotify2; + echelonNotify2.setUuid(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + echelonNotify2.setProperties(QLowEnergyCharacteristic::Notify); + echelonNotify2.addDescriptor(QLowEnergyDescriptorData(QBluetoothUuid::ClientCharacteristicConfiguration, + QByteArray::fromHex("0100"))); + + serviceDataEchelon.addCharacteristic(echelonWrite); + serviceDataEchelon.addCharacteristic(echelonNotify1); + serviceDataEchelon.addCharacteristic(echelonNotify2); + + // The real Echelon rower also exposes a Device Information service and the Nordic Secure DFU + // service (it runs on a Nordic chip). They are never read during the unlock handshake in the + // snoop, but we replicate them so the Echelon app sees an identical GATT layout. + QLowEnergyCharacteristicData disManufacturer; + disManufacturer.setUuid(QBluetoothUuid::CharacteristicType::ManufacturerNameString); + disManufacturer.setProperties(QLowEnergyCharacteristic::Read); + disManufacturer.setValue(QByteArray("Echelon")); + + QLowEnergyCharacteristicData disModel; + disModel.setUuid(QBluetoothUuid::CharacteristicType::ModelNumberString); + disModel.setProperties(QLowEnergyCharacteristic::Read); + disModel.setValue(echelonAdvertisingName.toUtf8()); + + QLowEnergyCharacteristicData disHardware; + disHardware.setUuid(QBluetoothUuid::CharacteristicType::HardwareRevisionString); + disHardware.setProperties(QLowEnergyCharacteristic::Read); + disHardware.setValue(QByteArray("1.0")); + + QLowEnergyCharacteristicData disFirmware; + disFirmware.setUuid(QBluetoothUuid::CharacteristicType::FirmwareRevisionString); + disFirmware.setProperties(QLowEnergyCharacteristic::Read); + disFirmware.setValue(QByteArray("1.0.0")); + + serviceDataEchelonDIS.setType(QLowEnergyServiceData::ServiceTypePrimary); + serviceDataEchelonDIS.setUuid(QBluetoothUuid::DeviceInformation); + serviceDataEchelonDIS.addCharacteristic(disManufacturer); + serviceDataEchelonDIS.addCharacteristic(disModel); + serviceDataEchelonDIS.addCharacteristic(disHardware); + serviceDataEchelonDIS.addCharacteristic(disFirmware); + + // Nordic Secure DFU service (0xFE59) with the buttonless DFU characteristic. We only expose it + // for parity; QZ does not implement firmware flashing, so it intentionally has no handler. + QLowEnergyCharacteristicData dfuButtonless; + dfuButtonless.setUuid(QBluetoothUuid(QStringLiteral("8ec90003-f315-4f60-9fb8-838830daea50"))); + dfuButtonless.setProperties(QLowEnergyCharacteristic::Write | QLowEnergyCharacteristic::Indicate); + dfuButtonless.addDescriptor(QLowEnergyDescriptorData(QBluetoothUuid::ClientCharacteristicConfiguration, + QByteArray::fromHex("0000"))); + + serviceDataEchelonDFU.setType(QLowEnergyServiceData::ServiceTypePrimary); + serviceDataEchelonDFU.setUuid(QBluetoothUuid(QStringLiteral("0000fe59-0000-1000-8000-00805f9b34fb"))); + serviceDataEchelonDFU.addCharacteristic(dfuButtonless); + } + //! [Start Advertising] leController = QLowEnergyController::createPeripheral(); Q_ASSERT(leController); - if (pm5Mode) { + if (pm5Mode && !echelon) { // Add PM5 services in correct order // GAP service first (standard BLE requirement) servicePM5GAP = leController->addService(serviceDataPM5GAP); @@ -268,19 +364,32 @@ virtualrower::virtualrower(bluetoothdevice *t, bool noWriteResistance, bool noHe QThread::msleep(100); qDebug() << "PM5 services added: GAP, DeviceInfo, Control, Rowing"; - } else { + } else if (!echelon) { serviceFIT = leController->addService(serviceDataFIT); QThread::msleep(100); // give time to Android to add the service async.ly } + if (echelon) { + serviceEchelonDiscovery = leController->addService(serviceDataEchelonDiscovery); + QThread::msleep(100); + serviceEchelon = leController->addService(serviceDataEchelon); + QThread::msleep(100); + serviceEchelonDIS = leController->addService(serviceDataEchelonDIS); + QThread::msleep(100); + serviceEchelonDFU = leController->addService(serviceDataEchelonDFU); + } if (!this->noHeartService || heart_only) { serviceHR = leController->addService(serviceDataHR); } - if (!pm5Mode && serviceFIT) { + if (!pm5Mode && !echelon && serviceFIT) { QObject::connect(serviceFIT, &QLowEnergyService::characteristicChanged, this, &virtualrower::characteristicChanged); } + if (serviceEchelon) { + QObject::connect(serviceEchelon, &QLowEnergyService::characteristicChanged, this, + &virtualrower::characteristicChanged); + } bool bluetooth_relaxed = settings.value(QZSettings::bluetooth_relaxed, QZSettings::default_bluetooth_relaxed).toBool(); @@ -290,7 +399,12 @@ virtualrower::virtualrower(bluetoothdevice *t, bool noWriteResistance, bool noHe } #ifdef Q_OS_ANDROID - if (pm5Mode) { + if (echelon) { + QAndroidJniObject::callStaticMethod( + "org/cagnulen/qdomyoszwift/BleAdvertiser", "startAdvertisingEchelon", + "(Landroid/content/Context;Ljava/lang/String;)V", QtAndroid::androidContext().object(), + QAndroidJniObject::fromString(echelonAdvertisingName).object()); + } else if (pm5Mode) { QAndroidJniObject::callStaticMethod("org/cagnulen/qdomyoszwift/BleAdvertiser", "startAdvertisingRowerPM5", "(Landroid/content/Context;)V", QtAndroid::androidContext().object()); } else { @@ -396,6 +510,148 @@ void virtualrower::characteristicChanged(const QLowEnergyCharacteristic &charact writeCharacteristic(serviceFIT, characteristic, reply); break; } + + if (characteristic.uuid() == QBluetoothUuid(QStringLiteral("0bf669f2-45f2-11e7-9598-0800200c9a66")) && + serviceEchelon && newValue.length() > 3 && leController->state() == QLowEnergyController::ConnectedState) { + // When QZ is backed by a real Echelon rower, forward the app payload to the rower and let the + // rower's own notifications be mirrored back to the client (firmware unlock passthrough). + if (auto *realEchelon = dynamic_cast(Rower); realEchelon && realEchelon->connected()) { + realEchelon->proxyVirtualRowerCommand(newValue); + return; + } + + // Otherwise (fake rower / no real Echelon hardware) answer the handshake synthetically, exactly + // like the real Echelon rower does in the HCI snoop traces. + QLowEnergyCharacteristic notify1 = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f3-45f2-11e7-9598-0800200c9a66"))); + QLowEnergyCharacteristic notify2 = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + + if (notify1.isValid()) { + const uint8_t command = static_cast(newValue.at(1)); + if (command == 0xA4) { + // The Echelon app opens the handshake with A4 (f0 a4 00 94), like the treadmill/bike. + writeCharacteristic(serviceEchelon, notify1, QByteArray::fromHex("f0a4010095")); + } else if (command == 0xA5) { + writeCharacteristic(serviceEchelon, notify1, QByteArray::fromHex("f0a5010ea4")); + } else if (command == 0xA1) { + // Echelon rower model/info reply (from snoop) + writeCharacteristic(serviceEchelon, notify1, QByteArray::fromHex("f0a106101e0050060520")); + echelonInitDone = true; + } else if (command == 0xA3) { + writeCharacteristic(serviceEchelon, notify1, QByteArray::fromHex("f0a3022001b6")); + } else if (command == 0xB0 && static_cast(newValue.at(3)) == 0x00) { + if (auto *rowerDevice = qobject_cast(Rower)) { + rowerDevice->stop(false); + } + writeCharacteristic(serviceEchelon, notify2, QByteArray::fromHex("f0d00100c1")); + } else if (command == 0xB0 && notify2.isValid()) { + if (auto *rowerDevice = qobject_cast(Rower)) { + rowerDevice->start(); + } + writeCharacteristic(serviceEchelon, notify2, QByteArray::fromHex("f0d00101c2")); + } else if (command == 0xA0) { + // keep-alive echo + writeCharacteristic(serviceEchelon, notify1, newValue); + } + } + } +} + +void virtualrower::relayEchelonPacket(const QBluetoothUuid &sourceUuid, const QByteArray &value) { + if (!serviceEchelon || !leController || leController->state() != QLowEnergyController::ConnectedState) { + return; + } + + QLowEnergyCharacteristic targetCharacteristic; + if (sourceUuid == QBluetoothUuid(QStringLiteral("0bf669f3-45f2-11e7-9598-0800200c9a66"))) { + targetCharacteristic = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f3-45f2-11e7-9598-0800200c9a66"))); + } else if (sourceUuid == QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))) { + targetCharacteristic = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + } else { + return; + } + + if (!targetCharacteristic.isValid()) { + qDebug() << QStringLiteral("virtual echelon rower target characteristic is invalid"); + return; + } + + writeCharacteristic(serviceEchelon, targetCharacteristic, value); +} + +void virtualrower::echelonWriteStatus() { + if (!serviceEchelon) { + return; + } + + QLowEnergyCharacteristic characteristic = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + if (!characteristic.isValid()) { + return; + } + + const QTime elapsed = Rower->elapsedTime(); + const uint16_t elapsedSeconds = + static_cast((elapsed.hour() * 3600) + (elapsed.minute() * 60) + elapsed.second()); + + const uint8_t cadence = static_cast(qRound(Rower->currentCadence().value())); // strokes per minute + const uint32_t strokeCount = static_cast(qRound(static_cast(Rower)->currentStrokesCount().value())); + const uint32_t distanceMeters = static_cast(qRound(Rower->odometer() * 1000.0)); + + // The parser reconstructs the speed as (60 / pace) * 30, so encode pace = 1800 / speed[km/h] + const double speed = Rower->currentSpeed().value(); + uint16_t pace = 0; + if (speed > 0.0) { + pace = static_cast(qBound(1.0, qRound(1800.0 / speed) * 1.0, 65535.0)); + } + + writeCharacteristic(serviceEchelon, characteristic, + echelonPacket({ + 0xf0, + 0xd1, + 0x11, + static_cast(elapsedSeconds >> 8), + static_cast(elapsedSeconds), + 0x00, + 0x00, + 0x00, + 0x00, + static_cast(strokeCount), + 0x00, + cadence, + 0x00, + static_cast(pace >> 8), + static_cast(pace), + static_cast(distanceMeters >> 16), + static_cast(distanceMeters >> 8), + static_cast(distanceMeters), + 0x00, + 0x00, + })); +} + +void virtualrower::echelonWriteResistance() { + if (!serviceEchelon) { + return; + } + + const int resistance = qRound(Rower->currentResistance().value()); + if (echelonLastResistance == resistance) { + return; + } + + QLowEnergyCharacteristic characteristic = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + if (!characteristic.isValid()) { + return; + } + + writeCharacteristic(serviceEchelon, characteristic, + echelonPacket({0xf0, 0xd2, 0x01, static_cast(qBound(0, resistance, 255))})); + echelonLastResistance = resistance; } void virtualrower::writeCharacteristic(QLowEnergyService *service, const QLowEnergyCharacteristic &characteristic, @@ -425,7 +681,10 @@ void virtualrower::reconnect() { qDebug() << QStringLiteral("virtualrower::reconnect") << "pm5Mode:" << pm5Mode; leController->disconnectFromDevice(); - if (pm5Mode) { + const bool echelon = + settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); + + if (pm5Mode && !echelon) { // Add PM5 services in correct order servicePM5GAP = leController->addService(serviceDataPM5GAP); QThread::msleep(100); @@ -435,10 +694,28 @@ void virtualrower::reconnect() { QThread::msleep(100); servicePM5Rowing = leController->addService(serviceDataPM5Rowing); QThread::msleep(100); - } else { + } else if (!echelon) { serviceFIT = leController->addService(serviceDataFIT); QThread::msleep(100); // give time to Android to add the service async.ly } + if (echelon && !serviceDataEchelon.characteristics().isEmpty()) { + serviceEchelonDiscovery = leController->addService(serviceDataEchelonDiscovery); + QThread::msleep(100); + serviceEchelon = leController->addService(serviceDataEchelon); + if (serviceEchelon) { + QObject::connect(serviceEchelon, &QLowEnergyService::characteristicChanged, this, + &virtualrower::characteristicChanged); + } + QThread::msleep(100); + if (!serviceDataEchelonDIS.characteristics().isEmpty()) { + serviceEchelonDIS = leController->addService(serviceDataEchelonDIS); + QThread::msleep(100); + } + if (!serviceDataEchelonDFU.characteristics().isEmpty()) { + serviceEchelonDFU = leController->addService(serviceDataEchelonDFU); + QThread::msleep(100); + } + } if (!this->noHeartService || heart_only) serviceHR = leController->addService(serviceDataHR); @@ -446,7 +723,14 @@ void virtualrower::reconnect() { QLowEnergyAdvertisingParameters pars; pars.setInterval(100, 100); #ifdef Q_OS_ANDROID - if (pm5Mode) { + if (echelon) { + const QString echelonAdvertisingName = + !Rower->bluetoothDevice.name().isEmpty() ? Rower->bluetoothDevice.name() : QStringLiteral("ROW-S-015244"); + QAndroidJniObject::callStaticMethod( + "org/cagnulen/qdomyoszwift/BleAdvertiser", "startAdvertisingEchelon", + "(Landroid/content/Context;Ljava/lang/String;)V", QtAndroid::androidContext().object(), + QAndroidJniObject::fromString(echelonAdvertisingName).object()); + } else if (pm5Mode) { QAndroidJniObject::callStaticMethod("org/cagnulen/qdomyoszwift/BleAdvertiser", "startAdvertisingRowerPM5", "(Landroid/content/Context;)V", QtAndroid::androidContext().object()); } else { @@ -464,6 +748,8 @@ void virtualrower::rowerProvider() { QSettings settings; bool heart_only = settings.value(QZSettings::virtual_device_onlyheart, QZSettings::default_virtual_device_onlyheart).toBool(); + const bool echelon = + settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); double normalizeWattage = Rower->wattsMetricforUI(); if (normalizeWattage < 0) @@ -556,7 +842,7 @@ void virtualrower::rowerProvider() { QByteArray value; - if (!heart_only) { + if (!heart_only && !echelon) { if (pm5Mode) { // PM5 Concept2 protocol if (!servicePM5Rowing) { @@ -689,6 +975,11 @@ void virtualrower::rowerProvider() { writeCharacteristic(serviceFIT, characteristic, value); } } + + if (echelon && echelonInitDone && serviceEchelon) { + echelonWriteResistance(); + echelonWriteStatus(); + } // characteristic // = service->characteristic((QBluetoothUuid::CharacteristicType)0x2AD9); // Fitness Machine Control Point // Q_ASSERT(characteristic.isValid()); diff --git a/src/virtualdevices/virtualrower.h b/src/virtualdevices/virtualrower.h index 731eef499a..283142e1d5 100644 --- a/src/virtualdevices/virtualrower.h +++ b/src/virtualdevices/virtualrower.h @@ -34,6 +34,7 @@ class virtualrower : public virtualdevice { public: virtualrower(bluetoothdevice *t, bool noWriteResistance = false, bool noHeartService = false); bool connected() override; + void relayEchelonPacket(const QBluetoothUuid &sourceUuid, const QByteArray &value); private: QLowEnergyController *leController = nullptr; @@ -44,6 +45,10 @@ class virtualrower : public virtualdevice { QLowEnergyService *servicePM5DeviceInfo = nullptr; QLowEnergyService *servicePM5GAP = nullptr; QLowEnergyService *servicePM5Control = nullptr; + QLowEnergyService *serviceEchelonDiscovery = nullptr; + QLowEnergyService *serviceEchelon = nullptr; + QLowEnergyService *serviceEchelonDIS = nullptr; + QLowEnergyService *serviceEchelonDFU = nullptr; QLowEnergyAdvertisingData advertisingData; QLowEnergyServiceData serviceDataHR; QLowEnergyServiceData serviceDataFIT; @@ -51,6 +56,10 @@ class virtualrower : public virtualdevice { QLowEnergyServiceData serviceDataPM5DeviceInfo; QLowEnergyServiceData serviceDataPM5GAP; QLowEnergyServiceData serviceDataPM5Control; + QLowEnergyServiceData serviceDataEchelonDiscovery; + QLowEnergyServiceData serviceDataEchelon; + QLowEnergyServiceData serviceDataEchelonDIS; + QLowEnergyServiceData serviceDataEchelonDFU; QTimer rowerTimer; bluetoothdevice *Rower; @@ -62,8 +71,13 @@ class virtualrower : public virtualdevice { bool noHeartService = false; + bool echelonInitDone = false; + int echelonLastResistance = -1; + void writeCharacteristic(QLowEnergyService *service, const QLowEnergyCharacteristic &characteristic, const QByteArray &value); + void echelonWriteStatus(); + void echelonWriteResistance(); void setupPM5Services(); QByteArray buildPM5GeneralStatus(); QByteArray buildPM5AdditionalStatus(); diff --git a/src/virtualdevices/virtualtreadmill.cpp b/src/virtualdevices/virtualtreadmill.cpp index d67cb220e4..e4cf1d6aca 100644 --- a/src/virtualdevices/virtualtreadmill.cpp +++ b/src/virtualdevices/virtualtreadmill.cpp @@ -1,4 +1,5 @@ #include "virtualdevices/virtualtreadmill.h" +#include "devices/echelonstride/echelonstride.h" #include #include #include @@ -12,6 +13,21 @@ using namespace std::chrono_literals; +namespace { +QByteArray echelonPacket(std::initializer_list bytes) { + QByteArray value; + uint8_t checksum = 0; + + for (uint8_t byte : bytes) { + value.append(static_cast(byte)); + checksum = static_cast(checksum + byte); + } + + value.append(static_cast(checksum)); + return value; +} +} + virtualtreadmill::virtualtreadmill(bluetoothdevice *t, bool noHeartService) { QSettings settings; treadMill = t; @@ -21,6 +37,9 @@ virtualtreadmill::virtualtreadmill(bluetoothdevice *t, bool noHeartService) { double bikeResistanceGain = settings.value(QZSettings::bike_resistance_gain_f, QZSettings::default_bike_resistance_gain_f).toDouble(); bool bike_cadence_sensor = settings.value(QZSettings::bike_cadence_sensor, QZSettings::default_bike_cadence_sensor).toBool(); + bool echelon = settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); + const QString echelonAdvertisingName = + !t->bluetoothDevice.name().isEmpty() ? t->bluetoothDevice.name() : QStringLiteral("ECHEX-5s-113399"); this->noHeartService = noHeartService; if (settings.value(QZSettings::dircon_yes, QZSettings::default_dircon_yes).toBool()) { dirconManager = new DirconManager(t, bikeResistanceOffset, bikeResistanceGain, this); @@ -60,11 +79,15 @@ virtualtreadmill::virtualtreadmill(bluetoothdevice *t, bool noHeartService) { //! [Advertising Data] advertisingData.setDiscoverability(QLowEnergyAdvertisingData::DiscoverabilityGeneral); advertisingData.setIncludePowerLevel(true); + if (echelon) { + advertisingData.setLocalName(echelonAdvertisingName); + } else { #if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) - advertisingData.setLocalName(QStringLiteral("KICKR RUN")); + advertisingData.setLocalName(QStringLiteral("KICKR RUN")); #else - advertisingData.setLocalName(QStringLiteral("DomyosBridge")); + advertisingData.setLocalName(QStringLiteral("DomyosBridge")); #endif + } QList services; // Add Wahoo Run Service UUID @@ -83,32 +106,45 @@ virtualtreadmill::virtualtreadmill(bluetoothdevice *t, bool noHeartService) { services << QBluetoothUuid::HeartRate; } + if (echelon) { + services << QBluetoothUuid(QStringLiteral("0bf669f0-45f2-11e7-9598-0800200c9a66")); + } + /*services << ((QBluetoothUuid::ServiceClassUuid)0xFF00); services << QBluetoothUuid::DeviceInformation;*/ advertisingData.setServices(services); //! [Advertising Data] - // Add Device Information Service + // Add Device Information Service. When emulating an Echelon treadmill we report Echelon-branded + // values (and a Model Number) so the GATT layout matches the real Stride. QLowEnergyCharacteristicData manufacturerNameChar; manufacturerNameChar.setUuid(QBluetoothUuid::CharacteristicType::ManufacturerNameString); manufacturerNameChar.setProperties(QLowEnergyCharacteristic::Read); - manufacturerNameChar.setValue(QByteArray("Wahoo Fitness")); // Changed to Wahoo Fitness + manufacturerNameChar.setValue(echelon ? QByteArray("Echelon") : QByteArray("Wahoo Fitness")); QLowEnergyCharacteristicData firmwareRevChar; firmwareRevChar.setUuid(QBluetoothUuid::CharacteristicType::FirmwareRevisionString); firmwareRevChar.setProperties(QLowEnergyCharacteristic::Read); - firmwareRevChar.setValue(QByteArray("1.0.11")); + firmwareRevChar.setValue(echelon ? QByteArray("1.0.0") : QByteArray("1.0.11")); QLowEnergyCharacteristicData hardwareRevChar; hardwareRevChar.setUuid(QBluetoothUuid::CharacteristicType::HardwareRevisionString); hardwareRevChar.setProperties(QLowEnergyCharacteristic::Read); - hardwareRevChar.setValue(QByteArray("1")); + hardwareRevChar.setValue(echelon ? QByteArray("1.0") : QByteArray("1")); + + QLowEnergyCharacteristicData modelNumberChar; + modelNumberChar.setUuid(QBluetoothUuid::CharacteristicType::ModelNumberString); + modelNumberChar.setProperties(QLowEnergyCharacteristic::Read); + modelNumberChar.setValue(echelonAdvertisingName.toUtf8()); - // Create Device Information Service + // Create Device Information Service serviceDataDIS.setType(QLowEnergyServiceData::ServiceTypePrimary); serviceDataDIS.setUuid(QBluetoothUuid::DeviceInformation); serviceDataDIS.addCharacteristic(manufacturerNameChar); + if (echelon) { + serviceDataDIS.addCharacteristic(modelNumberChar); + } serviceDataDIS.addCharacteristic(firmwareRevChar); serviceDataDIS.addCharacteristic(hardwareRevChar); @@ -138,6 +174,44 @@ virtualtreadmill::virtualtreadmill(bluetoothdevice *t, bool noHeartService) { serviceDataWahoo.addCharacteristic(wahooNotifyChar); serviceDataWahoo.addCharacteristic(wahooWriteChar); + if (echelon) { + serviceDataEchelon.setType(QLowEnergyServiceData::ServiceTypePrimary); + serviceDataEchelon.setUuid(QBluetoothUuid(QStringLiteral("0bf669f1-45f2-11e7-9598-0800200c9a66"))); + + QLowEnergyCharacteristicData echelonWrite; + echelonWrite.setUuid(QBluetoothUuid(QStringLiteral("0bf669f2-45f2-11e7-9598-0800200c9a66"))); + echelonWrite.setProperties(QLowEnergyCharacteristic::Write | QLowEnergyCharacteristic::WriteNoResponse); + + QLowEnergyCharacteristicData echelonNotify1; + echelonNotify1.setUuid(QBluetoothUuid(QStringLiteral("0bf669f3-45f2-11e7-9598-0800200c9a66"))); + echelonNotify1.setProperties(QLowEnergyCharacteristic::Notify); + echelonNotify1.addDescriptor(QLowEnergyDescriptorData(QBluetoothUuid::ClientCharacteristicConfiguration, + QByteArray::fromHex("0100"))); + + QLowEnergyCharacteristicData echelonNotify2; + echelonNotify2.setUuid(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + echelonNotify2.setProperties(QLowEnergyCharacteristic::Notify); + echelonNotify2.addDescriptor(QLowEnergyDescriptorData(QBluetoothUuid::ClientCharacteristicConfiguration, + QByteArray::fromHex("0100"))); + + serviceDataEchelon.addCharacteristic(echelonWrite); + serviceDataEchelon.addCharacteristic(echelonNotify1); + serviceDataEchelon.addCharacteristic(echelonNotify2); + + // The real Echelon Stride runs on a Nordic chip and also exposes the Nordic Secure DFU service + // (0xFE59). We replicate it for GATT parity only; QZ does not implement firmware flashing, so it + // intentionally has no handler. The Device Information service (0x180A) is already provided above. + QLowEnergyCharacteristicData dfuButtonless; + dfuButtonless.setUuid(QBluetoothUuid(QStringLiteral("8ec90003-f315-4f60-9fb8-838830daea50"))); + dfuButtonless.setProperties(QLowEnergyCharacteristic::Write | QLowEnergyCharacteristic::Indicate); + dfuButtonless.addDescriptor(QLowEnergyDescriptorData(QBluetoothUuid::ClientCharacteristicConfiguration, + QByteArray::fromHex("0000"))); + + serviceDataEchelonDFU.setType(QLowEnergyServiceData::ServiceTypePrimary); + serviceDataEchelonDFU.setUuid(QBluetoothUuid(QStringLiteral("0000fe59-0000-1000-8000-00805f9b34fb"))); + serviceDataEchelonDFU.addCharacteristic(dfuButtonless); + } + //! [Service Data] if (ftmsServiceEnable()) { QLowEnergyCharacteristicData charData; @@ -355,10 +429,18 @@ virtualtreadmill::virtualtreadmill(bluetoothdevice *t, bool noHeartService) { serviceDIS = leController->addService(serviceDataDIS); QThread::msleep(100); - serviceWahoo = leController->addService(serviceDataWahoo); + if (!echelon) { + serviceWahoo = leController->addService(serviceDataWahoo); + } + + if (echelon) { + serviceEchelon = leController->addService(serviceDataEchelon); + QThread::msleep(100); + serviceEchelonDFU = leController->addService(serviceDataEchelonDFU); + } QThread::msleep(100); -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) genericAccessServer = leController->addService(genericAccessServerData); genericAttributeService = leController->addService(genericAttributeServiceData); #endif @@ -375,6 +457,11 @@ virtualtreadmill::virtualtreadmill(bluetoothdevice *t, bool noHeartService) { QObject::connect(serviceWahoo, &QLowEnergyService::characteristicChanged, this, &virtualtreadmill::wahooCharacteristicChanged); + if (serviceEchelon) { + QObject::connect(serviceEchelon, &QLowEnergyService::characteristicChanged, this, + &virtualtreadmill::characteristicChanged); + } + bool bluetooth_relaxed = settings.value(QZSettings::bluetooth_relaxed, QZSettings::default_bluetooth_relaxed).toBool(); QLowEnergyAdvertisingParameters pars = QLowEnergyAdvertisingParameters(); @@ -385,10 +472,17 @@ virtualtreadmill::virtualtreadmill(bluetoothdevice *t, bool noHeartService) { } #ifdef Q_OS_ANDROID - QAndroidJniObject::callStaticMethod("org/cagnulen/qdomyoszwift/BleAdvertiser", - "startAdvertisingTreadmill", - "(Landroid/content/Context;)V", - QtAndroid::androidContext().object()); + if (echelon) { + QAndroidJniObject::callStaticMethod( + "org/cagnulen/qdomyoszwift/BleAdvertiser", "startAdvertisingEchelon", + "(Landroid/content/Context;Ljava/lang/String;)V", QtAndroid::androidContext().object(), + QAndroidJniObject::fromString(echelonAdvertisingName).object()); + } else { + QAndroidJniObject::callStaticMethod("org/cagnulen/qdomyoszwift/BleAdvertiser", + "startAdvertisingTreadmill", + "(Landroid/content/Context;)V", + QtAndroid::androidContext().object()); + } #elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) pars.setInterval(30, 50); @@ -436,6 +530,217 @@ void virtualtreadmill::characteristicChanged(const QLowEnergyCharacteristic &cha } break; } + + if (characteristic.uuid() == QBluetoothUuid(QStringLiteral("0bf669f2-45f2-11e7-9598-0800200c9a66")) && + serviceEchelon && newValue.length() > 3) { + // When QZ is backed by a real Echelon treadmill, forward the app payload to the treadmill and let + // the treadmill's own notifications be mirrored back to the client (firmware unlock passthrough). + if (auto *realEchelon = dynamic_cast(treadMill); realEchelon && realEchelon->connected()) { + realEchelon->proxyVirtualTreadmillCommand(newValue); + return; + } + + // Otherwise (fake treadmill / no real Echelon hardware) answer the handshake synthetically, exactly + // like the real Echelon Stride does in the HCI snoop traces. + QLowEnergyCharacteristic notify1 = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f3-45f2-11e7-9598-0800200c9a66"))); + QLowEnergyCharacteristic notify2 = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + + if (notify1.isValid() && leController->state() == QLowEnergyController::ConnectedState) { + const uint8_t command = static_cast(newValue.at(1)); + if (command == 0xA1) { + writeEchelonCharacteristic(notify1, QByteArray::fromHex("f0a1072064009606ff01b8")); + echelonWriteStatus(); + echelonInitDone = true; + } else if (command == 0xA3) { + writeEchelonCharacteristic(notify1, QByteArray::fromHex("f0a3070c00271001f400d2")); + } else if (command == 0xA4) { + writeEchelonCharacteristic(notify1, QByteArray::fromHex("f0a4010095")); + } else if (command == 0xA5) { + writeEchelonCharacteristic(notify1, QByteArray::fromHex("f0a5010ea4")); + } else if (command == 0xB0 && static_cast(newValue.at(3)) == 0x00) { + if (auto *treadmillDevice = qobject_cast(treadMill)) { + treadmillDevice->stop(false); + } + writeEchelonCharacteristic(notify2, QByteArray::fromHex("f0d00100c1")); + echelonWriteRunningState(); + } else if (command == 0xB0 && notify2.isValid()) { + if (auto *treadmillDevice = qobject_cast(treadMill)) { + treadmillDevice->start(); + } + writeEchelonCharacteristic(notify2, QByteArray::fromHex("f0d00100c1")); + writeEchelonCharacteristic(notify2, QByteArray::fromHex("f0d00111d2")); + echelonWriteRunningState(); + } else if (command == 0xA0) { + // keep-alive echo + writeEchelonCharacteristic(notify1, newValue); + } else if (command == 0xD3 && newValue.length() >= 6) { + // The Echelon app drives the Stride speed: f0 d3 02 (mm/s, /1000 = km/h). + if (auto *treadmillDevice = qobject_cast(treadMill)) { + const uint16_t rawSpeed = + (static_cast(newValue.at(3)) << 8) | static_cast(newValue.at(4)); + treadmillDevice->changeSpeed(static_cast(rawSpeed) / 1000.0); + } + } else if (command == 0xD2 && newValue.length() >= 5) { + // The Echelon app drives the Stride inclination: f0 d2 01 . + if (auto *treadmillDevice = qobject_cast(treadMill)) { + const double inclination = static_cast(newValue.at(3)); + treadmillDevice->changeInclination(inclination, inclination); + } + } + } + } +} + +void virtualtreadmill::relayEchelonPacket(const QBluetoothUuid &sourceUuid, const QByteArray &value) { + if (!serviceEchelon || !leController || leController->state() != QLowEnergyController::ConnectedState) { + return; + } + + QLowEnergyCharacteristic targetCharacteristic; + if (sourceUuid == QBluetoothUuid(QStringLiteral("0bf669f3-45f2-11e7-9598-0800200c9a66"))) { + targetCharacteristic = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f3-45f2-11e7-9598-0800200c9a66"))); + } else if (sourceUuid == QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))) { + targetCharacteristic = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + } else { + return; + } + + if (!targetCharacteristic.isValid()) { + qDebug() << QStringLiteral("virtual echelon treadmill target characteristic is invalid"); + return; + } + + writeEchelonCharacteristic(targetCharacteristic, value); +} + +bool virtualtreadmill::isEchelonVirtualEnabled() const { + QSettings settings; + return settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); +} + +void virtualtreadmill::writeEchelonCharacteristic(const QLowEnergyCharacteristic &characteristic, + const QByteArray &value) { + if (!serviceEchelon || !characteristic.isValid() || !leController || + leController->state() != QLowEnergyController::ConnectedState) { + return; + } + + try { + serviceEchelon->writeCharacteristic(characteristic, value); + } catch (...) { + qDebug() << QStringLiteral("virtualtreadmill echelon error!"); + } +} + +void virtualtreadmill::echelonWriteStatus() { + if (!serviceEchelon) { + return; + } + + QLowEnergyCharacteristic characteristic = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + if (!characteristic.isValid()) { + return; + } + + const QTime elapsed = treadMill->elapsedTime(); + const uint16_t elapsedSeconds = static_cast( + (elapsed.hour() * 3600) + (elapsed.minute() * 60) + elapsed.second()); + const uint32_t distanceMeters = qRound(static_cast(treadMill)->odometerFromStartup() * 1000.0); + const uint16_t calories = static_cast(static_cast(treadMill)->calories().value()); + const uint8_t heartRate = static_cast(static_cast(treadMill)->currentHeart().value()); + + writeEchelonCharacteristic(characteristic, echelonPacket({ + 0xf0, + 0xd1, + 0x09, + static_cast(elapsedSeconds >> 8), + static_cast(elapsedSeconds), + static_cast(distanceMeters >> 24), + static_cast(distanceMeters >> 16), + static_cast(distanceMeters >> 8), + static_cast(distanceMeters), + static_cast(calories >> 8), + static_cast(calories), + heartRate, + })); +} + +void virtualtreadmill::echelonWriteSpeed() { + if (!serviceEchelon) { + return; + } + + // Stream the speed on every tick (the real Stride streams d3 continuously); the app needs it to show + // the current speed, not only on change. + const qint32 speedValue = qRound(static_cast(treadMill)->currentSpeed().value() * 1000.0); + + QLowEnergyCharacteristic characteristic = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + if (!characteristic.isValid()) { + return; + } + + writeEchelonCharacteristic(characteristic, echelonPacket({ + 0xf0, + 0xd3, + 0x02, + static_cast((speedValue >> 8) & 0xff), + static_cast(speedValue & 0xff), + })); + echelonLastSpeed = speedValue; +} + +void virtualtreadmill::echelonWriteInclination() { + if (!serviceEchelon) { + return; + } + + // Stream the inclination on every tick so the app always shows the current value. + const int inclinationValue = qRound(static_cast(treadMill)->currentInclination().value()); + + QLowEnergyCharacteristic characteristic = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + if (!characteristic.isValid()) { + return; + } + + writeEchelonCharacteristic(characteristic, echelonPacket({ + 0xf0, + 0xd2, + 0x01, + static_cast(qBound(0, inclinationValue, 255)), + })); + echelonLastInclination = inclinationValue; +} + +void virtualtreadmill::echelonWriteRunningState() { + if (!serviceEchelon) { + return; + } + + const bool running = static_cast(treadMill)->currentSpeed().value() > 0.0; + if (echelonLastRunning == running) { + return; + } + + QLowEnergyCharacteristic characteristic = + serviceEchelon->characteristic(QBluetoothUuid(QStringLiteral("0bf669f4-45f2-11e7-9598-0800200c9a66"))); + if (!characteristic.isValid()) { + return; + } + + writeEchelonCharacteristic(characteristic, echelonPacket({ + 0xf0, + 0xd5, + 0x01, + static_cast(running ? 0x02 : 0x01), + })); + echelonLastRunning = running; } void virtualtreadmill::slopeChanged() { @@ -488,8 +793,21 @@ void virtualtreadmill::reconnect() { serviceDIS = leController->addService(serviceDataDIS); QThread::msleep(100); - serviceWahoo = leController->addService(serviceDataWahoo); - QThread::msleep(100); + const bool echelon = + settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool(); + if (!echelon) { + serviceWahoo = leController->addService(serviceDataWahoo); + QThread::msleep(100); + } + + if (!serviceDataEchelon.characteristics().isEmpty()) { + serviceEchelon = leController->addService(serviceDataEchelon); + QThread::msleep(100); + if (!serviceDataEchelonDFU.characteristics().isEmpty()) { + serviceEchelonDFU = leController->addService(serviceDataEchelonDFU); + QThread::msleep(100); + } + } #if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) genericAccessServer = leController->addService(genericAccessServerData); @@ -503,12 +821,22 @@ void virtualtreadmill::reconnect() { QLowEnergyAdvertisingParameters pars; pars.setInterval(100, 100); - if (serviceFTMS || serviceRSC || serviceWahoo) { + if (serviceFTMS || serviceRSC || serviceWahoo || serviceEchelon) { #ifdef Q_OS_ANDROID - QAndroidJniObject::callStaticMethod("org/cagnulen/qdomyoszwift/BleAdvertiser", - "startAdvertisingTreadmill", - "(Landroid/content/Context;)V", - QtAndroid::androidContext().object()); + if (echelon) { + const QString echelonAdvertisingName = + !treadMill->bluetoothDevice.name().isEmpty() ? treadMill->bluetoothDevice.name() + : QStringLiteral("ECHEX-5s-113399"); + QAndroidJniObject::callStaticMethod( + "org/cagnulen/qdomyoszwift/BleAdvertiser", "startAdvertisingEchelon", + "(Landroid/content/Context;Ljava/lang/String;)V", QtAndroid::androidContext().object(), + QAndroidJniObject::fromString(echelonAdvertisingName).object()); + } else { + QAndroidJniObject::callStaticMethod("org/cagnulen/qdomyoszwift/BleAdvertiser", + "startAdvertisingTreadmill", + "(Landroid/content/Context;)V", + QtAndroid::androidContext().object()); + } #else leController->startAdvertising(pars, advertisingData, advertisingData); #endif @@ -520,6 +848,7 @@ void virtualtreadmill::treadmillProvider() { const uint64_t slopeTimeoutSecs = 30; QSettings settings; bool bike_cadence_sensor = settings.value(QZSettings::bike_cadence_sensor, QZSettings::default_bike_cadence_sensor).toBool(); + const bool echelon = isEchelonVirtualEnabled(); if ((uint64_t)QDateTime::currentSecsSinceEpoch() > lastSlopeChanged + slopeTimeoutSecs) m_autoInclinationEnabled = false; @@ -625,6 +954,20 @@ void virtualtreadmill::treadmillProvider() { } } + // In Echelon emulation mode we own the telemetry entirely (status/speed/inclination/running state) and + // must stream it on every tick. The FTMS/RSC/HR machinery below is for the non-Echelon virtual treadmill + // and contains early returns that would otherwise short-circuit the Echelon notifications, so handle it + // here and return. + if (echelon) { + if (echelonInitDone && serviceEchelon) { + echelonWriteStatus(); + echelonWriteRunningState(); + echelonWriteInclination(); + echelonWriteSpeed(); + } + return; + } + QByteArray value; if (ftmsServiceEnable()) { @@ -749,6 +1092,9 @@ bool virtualtreadmill::connected() { bool virtualtreadmill::ftmsServiceEnable() { QSettings settings; + if (settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool()) { + return false; + } bool cadence = settings.value(QZSettings::run_cadence_sensor, QZSettings::default_run_cadence_sensor).toBool(); if (!cadence) return true; @@ -759,6 +1105,9 @@ bool virtualtreadmill::ftmsServiceEnable() { bool virtualtreadmill::ftmsTreadmillEnable() { QSettings settings; + if (settings.value(QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon).toBool()) { + return false; + } bool cadence = settings.value(QZSettings::run_cadence_sensor, QZSettings::default_run_cadence_sensor).toBool(); if (!cadence) return true; diff --git a/src/virtualdevices/virtualtreadmill.h b/src/virtualdevices/virtualtreadmill.h index 93e75194d2..5edda3642d 100644 --- a/src/virtualdevices/virtualtreadmill.h +++ b/src/virtualdevices/virtualtreadmill.h @@ -35,6 +35,7 @@ class virtualtreadmill : public virtualdevice { virtualtreadmill(bluetoothdevice *t, bool noHeartService); bool connected() override; bool autoInclinationEnabled() { return m_autoInclinationEnabled; } + void relayEchelonPacket(const QBluetoothUuid &sourceUuid, const QByteArray &value); private: QLowEnergyController *leController = nullptr; @@ -43,8 +44,10 @@ class virtualtreadmill : public virtualdevice { QLowEnergyService *serviceHR = nullptr; QLowEnergyService *serviceDIS = nullptr; QLowEnergyService *serviceWahoo = nullptr; + QLowEnergyService *serviceEchelon = nullptr; + QLowEnergyService *serviceEchelonDFU = nullptr; -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) QLowEnergyService *genericAccessServer = nullptr; QLowEnergyService *genericAttributeService = nullptr; #endif @@ -56,8 +59,10 @@ class virtualtreadmill : public virtualdevice { QLowEnergyServiceData serviceDataHR; QLowEnergyServiceData serviceDataDIS; QLowEnergyServiceData serviceDataWahoo; + QLowEnergyServiceData serviceDataEchelon; + QLowEnergyServiceData serviceDataEchelonDFU; -#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) QLowEnergyServiceData genericAccessServerData; QLowEnergyServiceData genericAttributeServiceData; #endif @@ -79,10 +84,20 @@ class virtualtreadmill : public virtualdevice { bool noHeartService = false; bool m_autoInclinationEnabled = false; + bool echelonInitDone = false; + bool echelonLastRunning = false; + qint32 echelonLastSpeed = -1; + int echelonLastInclination = -1; bool ftmsServiceEnable(); bool ftmsTreadmillEnable(); bool RSCEnable(); + bool isEchelonVirtualEnabled() const; + void writeEchelonCharacteristic(const QLowEnergyCharacteristic &characteristic, const QByteArray &value); + void echelonWriteStatus(); + void echelonWriteSpeed(); + void echelonWriteInclination(); + void echelonWriteRunningState(); #ifdef Q_OS_IOS lockscreen *h = 0;