diff --git a/src/android/src/Usbserial.java b/src/android/src/Usbserial.java index 99fff84b52..788bc7cdea 100644 --- a/src/android/src/Usbserial.java +++ b/src/android/src/Usbserial.java @@ -8,6 +8,10 @@ import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbDeviceConnection; import android.hardware.usb.UsbManager; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; +import android.system.StructPollfd; import org.cagnulen.qdomyoszwift.QLog; import android.app.Service; import android.media.RingtoneManager; @@ -39,15 +43,35 @@ public class Usbserial { static UsbSerialPort port = null; + static java.io.FileDescriptor localSerialFd = null; + static boolean localSerialMode = false; static byte[] receiveData = new byte[4096]; static int lastReadLen = 0; + static final String[] localSerialCandidates = { + "/dev/ttyS4", + "/dev/ttyS0", + "/dev/ttyS1", + "/dev/ttyS2", + "/dev/ttyS3" + }; public static void open(Context context) { open(context, 2400); // Default baud rate for Computrainer } public static void open(Context context, int baudRate) { + open(context, baudRate, ""); + } + + public static void open(Context context, int baudRate, String devicePath) { + if (devicePath != null && (devicePath.startsWith("/dev/") || devicePath.equalsIgnoreCase("auto"))) { + openLocalSerial(devicePath, baudRate); + return; + } + QLog.d("QZ","UsbSerial open with baud rate: " + baudRate); + localSerialMode = false; + localSerialFd = null; // Find all available drivers from attached devices. UsbManager manager = (UsbManager) context.getSystemService(Context.USB_SERVICE); List availableDrivers = UsbSerialProber.getDefaultProber().findAllDrivers(manager); @@ -110,7 +134,76 @@ public void onReceive(Context context, Intent intent) { } } + private static void openLocalSerial(String devicePath, int baudRate) { + QLog.d("QZ","UsbSerial local serial open " + devicePath + " with baud rate: " + baudRate); + port = null; + localSerialFd = null; + localSerialMode = false; + lastReadLen = 0; + + if (devicePath.equalsIgnoreCase("auto")) { + for (String candidate : localSerialCandidates) { + if (openLocalSerialPath(candidate, baudRate)) { + return; + } + } + QLog.d("QZ","UsbSerial local serial auto open failed"); + return; + } + + openLocalSerialPath(devicePath, baudRate); + } + + private static boolean openLocalSerialPath(String devicePath, int baudRate) { + try { + configureLocalSerial(devicePath, baudRate); + localSerialFd = Os.open(devicePath, OsConstants.O_RDWR | OsConstants.O_NOCTTY | OsConstants.O_NONBLOCK, 0); + localSerialMode = true; + QLog.d("QZ","UsbSerial local serial opened successfully: " + devicePath); + return true; + } + catch (ErrnoException e) { + QLog.d("QZ","UsbSerial local serial open failed: " + e.getMessage()); + return false; + } + } + + private static void configureLocalSerial(String devicePath, int baudRate) { + try { + String[] command = { + "stty", + "-F", + devicePath, + String.valueOf(baudRate), + "cs8", + "-cstopb", + "-parenb", + "raw", + "-echo" + }; + Process process = Runtime.getRuntime().exec(command); + int rc = process.waitFor(); + QLog.d("QZ","UsbSerial local serial stty exit code: " + rc); + } + catch (Exception e) { + QLog.d("QZ","UsbSerial local serial stty failed: " + e.getMessage()); + } + } + public static void write (byte[] bytes) { + if(localSerialMode) { + if(localSerialFd == null) + return; + + try { + Os.write(localSerialFd, bytes, 0, bytes.length); + } + catch (ErrnoException | IOException e) { + QLog.d("QZ","UsbSerial local serial write failed: " + e.getMessage()); + } + return; + } + if(port == null) return; @@ -128,6 +221,32 @@ public static int readLen() { } public static byte[] read() { + if(localSerialMode) { + if(localSerialFd == null) { + lastReadLen = 0; + return receiveData; + } + + try { + StructPollfd pollFd = new StructPollfd(); + pollFd.fd = localSerialFd; + pollFd.events = (short)OsConstants.POLLIN; + int ready = Os.poll(new StructPollfd[]{pollFd}, 0); + if(ready <= 0) { + lastReadLen = 0; + return receiveData; + } + + lastReadLen = Os.read(localSerialFd, receiveData, 0, receiveData.length); + QLog.d("QZ","UsbSerial local serial reading " + lastReadLen); + } + catch (ErrnoException | IOException e) { + lastReadLen = 0; + QLog.d("QZ","UsbSerial local serial read failed: " + e.getMessage()); + } + return receiveData; + } + if(port == null) { lastReadLen = 0; return receiveData; diff --git a/src/devices/bluetooth.cpp b/src/devices/bluetooth.cpp index 42ddcb277c..ff15c5a032 100644 --- a/src/devices/bluetooth.cpp +++ b/src/devices/bluetooth.cpp @@ -627,6 +627,8 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) { settings.value(QZSettings::computrainer_serialport, QZSettings::default_computrainer_serialport).toString(); QString kettlerUsbSerialPort = settings.value(QZSettings::kettler_usb_serialport, QZSettings::default_kettler_usb_serialport).toString(); + QString freebeatSerialPort = + settings.value(QZSettings::freebeat_serialport, QZSettings::default_freebeat_serialport).toString(); QString csaferowerSerialPort = settings.value(QZSettings::csafe_rower, QZSettings::default_csafe_rower).toString(); bool waterrowerUSBEnabled = settings.value(QZSettings::waterrower_usb, QZSettings::default_waterrower_usb).toBool(); QString csafeellipticalSerialPort = @@ -954,6 +956,19 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) { emit searchingStop(); } this->signalBluetoothDeviceConnected(kettlerUsbBike); + } else if (!freebeatSerialPort.isEmpty() && !freebeatBike) { + this->stopDiscovery(); + freebeatBike = + new freebeatbike(noWriteResistance, noHeartService, bikeResistanceOffset, bikeResistanceGain); + emit deviceConnected(b); + connect(freebeatBike, &bluetoothdevice::connectedAndDiscovered, this, + &bluetooth::connectedAndDiscovered); + connect(freebeatBike, &freebeatbike::debug, this, &bluetooth::debug); + freebeatBike->deviceDiscovered(b); + if (this->discoveryAgent && !this->discoveryAgent->isActive()) { + emit searchingStop(); + } + this->signalBluetoothDeviceConnected(freebeatBike); } else if (!csaferowerSerialPort.isEmpty() && !csafeRower) { this->stopDiscovery(); csafeRower = new csaferower(noWriteResistance, noHeartService, false); @@ -4220,6 +4235,11 @@ void bluetooth::restart() { delete kettlerUsbBike; kettlerUsbBike = nullptr; } + if (freebeatBike) { + + delete freebeatBike; + freebeatBike = nullptr; + } if (csafeRower) { delete csafeRower; @@ -4589,6 +4609,8 @@ bluetoothdevice *bluetooth::device() { return computrainerBike; } else if (kettlerUsbBike) { return kettlerUsbBike; + } else if (freebeatBike) { + return freebeatBike; } else if (csafeRower) { return csafeRower; } else if (csafeElliptical) { diff --git a/src/devices/bluetooth.h b/src/devices/bluetooth.h index c5ffffb3b8..f364bc9286 100644 --- a/src/devices/bluetooth.h +++ b/src/devices/bluetooth.h @@ -36,6 +36,7 @@ #ifndef Q_OS_IOS #include "devices/computrainerbike/computrainerbike.h" #include "devices/kettlerusbbike/kettlerusbbike.h" +#include "devices/freebeatbike/freebeatbike.h" #include "devices/csaferower/csaferower.h" #include "devices/csafeelliptical/csafeelliptical.h" #endif @@ -203,6 +204,7 @@ class bluetooth : public QObject, public SignalHandler { #ifndef Q_OS_IOS computrainerbike *computrainerBike = nullptr; kettlerusbbike *kettlerUsbBike = nullptr; + freebeatbike *freebeatBike = nullptr; csaferower *csafeRower = nullptr; csafeelliptical *csafeElliptical = nullptr; #endif diff --git a/src/devices/freebeatbike/FreebeatUSB.cpp b/src/devices/freebeatbike/FreebeatUSB.cpp new file mode 100644 index 0000000000..a7275ecb5e --- /dev/null +++ b/src/devices/freebeatbike/FreebeatUSB.cpp @@ -0,0 +1,408 @@ +/* + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "FreebeatUSB.h" + +#ifdef Q_OS_ANDROID +#include +#endif + +/* ---------------------------------------------------------------------- + * CONSTRUCTOR/DESTRUCTOR + * ---------------------------------------------------------------------- */ +FreebeatUSB::FreebeatUSB(QObject *parent, QString deviceFilename, int baudrate) : QThread(parent) { + this->deviceFilename = deviceFilename; + this->baudrate = baudrate; + targetResistance = 1; + writeResistance = false; + doStop = false; + doReset = false; + doQuery = true; + devRpm = 0; + devResistance = 0; + devSpeed = 0.0; + devWatt = 0.0; + devValid = false; +#ifdef WIN32 + devicePort = INVALID_HANDLE_VALUE; +#else + devicePort = -1; +#endif +} + +FreebeatUSB::~FreebeatUSB() {} + +/* ---------------------------------------------------------------------- + * PUBLIC CONTROL METHODS + * ---------------------------------------------------------------------- */ +int FreebeatUSB::start() { + QThread::start(); + return 0; +} + +int FreebeatUSB::stop() { + pvars.lock(); + doStop = true; + pvars.unlock(); + return 0; +} + +void FreebeatUSB::setResistance(int resistance) { + if (resistance < 1) resistance = 1; + if (resistance > 100) resistance = 100; + pvars.lock(); + targetResistance = resistance; + writeResistance = true; + pvars.unlock(); +} + +void FreebeatUSB::sendQuery() { + pvars.lock(); + doQuery = true; + pvars.unlock(); +} + +void FreebeatUSB::sendStop() { + pvars.lock(); + doStop = true; + pvars.unlock(); +} + +void FreebeatUSB::sendReset() { + pvars.lock(); + doReset = true; + pvars.unlock(); +} + +void FreebeatUSB::getTelemetry(int &rpm, int &resistance, double &speed, double &watt, bool &valid) { + pvars.lock(); + rpm = devRpm; + resistance = devResistance; + speed = devSpeed; + watt = devWatt; + valid = devValid; + pvars.unlock(); +} + +/* ---------------------------------------------------------------------- + * PROTOCOL HELPERS + * ---------------------------------------------------------------------- */ +// Build 5-byte command: [startCode, action, data, (action+data)&0xFF, 0xA0] +QByteArray FreebeatUSB::buildCmd5(uint8_t startCode, uint8_t action, uint8_t data) { + QByteArray cmd(5, 0); + cmd[0] = startCode; + cmd[1] = action; + cmd[2] = data; + cmd[3] = (action + data) & 0xFF; + cmd[4] = FREEBEAT_END_BYTE; + return cmd; +} + +// Build 4-byte LED heartbeat: [0xB5, 0x08, 0x08, 0xA0] +QByteArray FreebeatUSB::buildLedQuery() { + QByteArray cmd(4, 0); + cmd[0] = (char)FREEBEAT_CMD_LED; + cmd[1] = (char)FREEBEAT_ACT_LED_CHECK; + cmd[2] = (char)FREEBEAT_ACT_LED_CHECK; + cmd[3] = (char)FREEBEAT_END_BYTE; + return cmd; +} + +// Parse incoming data packet and update telemetry +// Data packet: byte[0]=0x55, byte[1]=type(0x15/0x03/0x25), +// bytes[4-5]=speed(16-bit LE), byte[7]=resistance(1-100), +// bytes[8-9]=RPM(16-bit LE), byte[11]=checksum=sum(bytes[1..10])&0xFF, byte[12]=0x3F +bool FreebeatUSB::parsePacket(const QByteArray &pkt) { + if (pkt.size() < 13) + return false; + + uint8_t b0 = (uint8_t)pkt[0]; + if (b0 == FREEBEAT_SYNC_LED) { + // LED status packet (20 bytes) — ignore for telemetry + return true; + } + + if (b0 != FREEBEAT_SYNC_DATA) + return false; + + // Validate checksum: sum bytes[1..10] + uint8_t chk = 0; + for (int i = 1; i <= 10; i++) + chk += (uint8_t)pkt[i]; + chk &= 0xFF; + + if (chk != (uint8_t)pkt[11]) + return false; + + if ((uint8_t)pkt[12] != FREEBEAT_VERIFY_BYTE) + return false; + + uint16_t rawSpeed = ((uint8_t)pkt[4]) | (((uint8_t)pkt[5]) << 8); + uint8_t rawRes = (uint8_t)pkt[7]; + uint16_t rawRpm = ((uint8_t)pkt[8]) | (((uint8_t)pkt[9]) << 8); + + double speed = rawSpeed * 0.1; // km/h + int rpm = rawRpm; + int res = rawRes; + // Power estimate using Freebeat's 4.56 coefficient (standard wheel) + double watt = rpm * 4.56 * res / 100.0; + + pvars.lock(); + devSpeed = speed; + devRpm = rpm; + devResistance = res; + devWatt = watt; + devValid = true; + pvars.unlock(); + + qDebug() << "Freebeat RX: speed=" << speed << "rpm=" << rpm << "res=" << res << "watt=" << watt; + return true; +} + +/* ---------------------------------------------------------------------- + * RAW I/O + * Note: Freebeat communicates via internal UART (/dev/ttyS4), NOT USB. + * We open the port directly with POSIX open() on all platforms (Android + * included) — NOT through Usbserial.java which is for USB-CDC/FTDI adapters. + * On Android, chmod 777 is required before open() to get rw access to the + * UART device node (same approach used by the original Freebeat app's + * DCUARTDriver class). + * ---------------------------------------------------------------------- */ +int FreebeatUSB::rawWrite(const char *bytes, int size) { + qDebug() << "Freebeat TX:" << QByteArray(bytes, size).toHex(); +#ifdef WIN32 + DWORD cBytes; + if (!WriteFile(devicePort, bytes, size, &cBytes, NULL)) + return -1; + return (int)cBytes; +#else + int rc = write(devicePort, bytes, size); + if (rc != -1) + tcdrain(devicePort); + return rc; +#endif +} + +// Read up to maxLen bytes with a timeout in ms; returns bytes read +int FreebeatUSB::rawRead(char *buf, int maxLen, int timeoutMs) { +#ifdef WIN32 + DWORD cBytes = 0; + COMMTIMEOUTS ct; + GetCommTimeouts(devicePort, &ct); + ct.ReadTotalTimeoutConstant = timeoutMs; + ct.ReadIntervalTimeout = 0; + SetCommTimeouts(devicePort, &ct); + ReadFile(devicePort, buf, maxLen, &cBytes, NULL); + return (int)cBytes; +#else + // Use select() for non-blocking read with timeout + fd_set fds; + FD_ZERO(&fds); + FD_SET(devicePort, &fds); + struct timeval tv; + tv.tv_sec = timeoutMs / 1000; + tv.tv_usec = (timeoutMs % 1000) * 1000; + int rc = select(devicePort + 1, &fds, NULL, NULL, &tv); + if (rc > 0) + return read(devicePort, buf, maxLen); + return 0; +#endif +} + +/* ---------------------------------------------------------------------- + * PORT OPEN / CLOSE + * ---------------------------------------------------------------------- */ +int FreebeatUSB::openPort() { +#ifdef WIN32 + COMMTIMEOUTS timeouts; + QString portSpec; + int portnum = deviceFilename.midRef(3).toString().toInt(); + if (portnum < 10) + portSpec = deviceFilename; + else + portSpec = "\\\\.\\" + deviceFilename; + + wchar_t deviceFilenameW[32]; + MultiByteToWideChar(CP_ACP, 0, portSpec.toLatin1(), -1, (LPWSTR)deviceFilenameW, sizeof(deviceFilenameW)); + + devicePort = CreateFile(deviceFilenameW, GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_DELETE | FILE_SHARE_WRITE | FILE_SHARE_READ, + NULL, OPEN_EXISTING, 0, NULL); + if (devicePort == INVALID_HANDLE_VALUE) + return -1; + + if (!GetCommState(devicePort, &deviceSettings)) + return -1; + + deviceSettings.BaudRate = CBR_9600; + deviceSettings.fParity = NOPARITY; + deviceSettings.ByteSize = 8; + deviceSettings.StopBits = ONESTOPBIT; + deviceSettings.fBinary = TRUE; + deviceSettings.fOutX = 0; + deviceSettings.fInX = 0; + deviceSettings.fRtsControl = RTS_CONTROL_ENABLE; + deviceSettings.fDtrControl = DTR_CONTROL_ENABLE; + deviceSettings.fOutxCtsFlow = FALSE; + + if (!SetCommState(devicePort, &deviceSettings)) { + CloseHandle(devicePort); + return -1; + } + + timeouts.ReadIntervalTimeout = 0; + timeouts.ReadTotalTimeoutConstant = 500; + timeouts.ReadTotalTimeoutMultiplier = 0; + timeouts.WriteTotalTimeoutConstant = 2000; + timeouts.WriteTotalTimeoutMultiplier = 0; + SetCommTimeouts(devicePort, &timeouts); + return 0; +#else + // POSIX path — used on Linux, macOS, and Android. + // On Android, /dev/ttyS4 is owned by root. Try to open first; if EACCES, + // escalate via su (same technique as DCUARTDriver in the original Freebeat APK). +#ifdef Q_OS_ANDROID + if (access(deviceFilename.toLatin1().constData(), R_OK | W_OK) != 0) { + qDebug() << "Freebeat: no rw access to" << deviceFilename << "- trying su chmod"; + QProcess proc; + proc.start("su", QStringList() << "-c" << ("chmod 666 " + deviceFilename)); + if (!proc.waitForFinished(3000)) + qDebug() << "Freebeat: su chmod timed out"; + else + qDebug() << "Freebeat: su chmod exit" << proc.exitCode(); + } +#endif + +#if defined(Q_OS_MACX) + int ldisc = TTYDISC; +#else + int ldisc = N_TTY; +#endif + if ((devicePort = open(deviceFilename.toLatin1(), O_RDWR | O_NOCTTY | O_NONBLOCK)) == -1) + return errno; + + tcflush(devicePort, TCIOFLUSH); + + if (ioctl(devicePort, TIOCSETD, &ldisc) == -1) + return errno; + + tcgetattr(devicePort, &deviceSettings); + cfmakeraw(&deviceSettings); + cfsetspeed(&deviceSettings, B9600); + + deviceSettings.c_iflag &= + ~(IGNBRK | BRKINT | ICRNL | INLCR | PARMRK | INPCK | ICANON | ISTRIP | IXON | IXOFF | IXANY); + deviceSettings.c_iflag |= IGNPAR; + deviceSettings.c_cflag &= (~CSIZE & ~CSTOPB); + deviceSettings.c_oflag = 0; +#if defined(Q_OS_MACX) + deviceSettings.c_cflag &= (~CCTS_OFLOW & ~CRTS_IFLOW); + deviceSettings.c_cflag |= (CS8 | CLOCAL | CREAD | HUPCL); +#else + deviceSettings.c_cflag &= (~CRTSCTS); + deviceSettings.c_cflag |= (CS8 | CLOCAL | CREAD | HUPCL); +#endif + deviceSettings.c_lflag = 0; + deviceSettings.c_cc[VMIN] = 0; + deviceSettings.c_cc[VTIME] = 5; // 0.5 second timeout + + if (tcsetattr(devicePort, TCSANOW, &deviceSettings) == -1) + return errno; + + tcflush(devicePort, TCIOFLUSH); + return 0; +#endif +} + +int FreebeatUSB::closePort() { +#ifdef WIN32 + return (int)!CloseHandle(devicePort); +#else + tcflush(devicePort, TCIOFLUSH); + return close(devicePort); +#endif +} + +/* ---------------------------------------------------------------------- + * MAIN RUN LOOP + * ---------------------------------------------------------------------- */ +void FreebeatUSB::run() { + if (openPort()) { + qDebug() << "FreebeatUSB: failed to open port" << deviceFilename; + return; + } + + // Send initial machine query + { + QByteArray q = buildCmd5(FREEBEAT_CMD_START, FREEBEAT_ACT_QUERY, 0); + rawWrite(q.constData(), q.size()); + } + + QThread::msleep(500); + + while (true) { + pvars.lock(); + bool stop = doStop; + bool reset = doReset; + bool writeR = writeResistance; + int res = targetResistance; + writeResistance = false; + pvars.unlock(); + + if (stop) { + QByteArray cmd = buildCmd5(FREEBEAT_CMD_START, FREEBEAT_ACT_STOP, 0); + rawWrite(cmd.constData(), cmd.size()); + break; + } + + if (reset) { + QByteArray cmd = buildCmd5(FREEBEAT_CMD_START, FREEBEAT_ACT_RESET, 0); + rawWrite(cmd.constData(), cmd.size()); + pvars.lock(); + doReset = false; + pvars.unlock(); + } + + if (writeR) { + QByteArray cmd = buildCmd5(FREEBEAT_CMD_START, FREEBEAT_ACT_RESISTANCE, (uint8_t)res); + rawWrite(cmd.constData(), cmd.size()); + } + + // Request fresh telemetry every cycle. + { + QByteArray cmd = buildCmd5(FREEBEAT_CMD_START, FREEBEAT_ACT_QUERY, 0); + rawWrite(cmd.constData(), cmd.size()); + } + + // Send LED heartbeat every cycle to keep bike awake + { + QByteArray led = buildLedQuery(); + rawWrite(led.constData(), led.size()); + } + + // Read response — data packets are 13 bytes, LED packets 20 bytes + char buf[64]; + int n = rawRead(buf, sizeof(buf), 200); + if (n > 0) { + QByteArray pkt(buf, n); + parsePacket(pkt); + } + + QThread::msleep(200); + } + + closePort(); +} diff --git a/src/devices/freebeatbike/FreebeatUSB.h b/src/devices/freebeatbike/FreebeatUSB.h new file mode 100644 index 0000000000..ad5dd35bc3 --- /dev/null +++ b/src/devices/freebeatbike/FreebeatUSB.h @@ -0,0 +1,132 @@ +/* + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef FREEBEATUSB_H +#define FREEBEATUSB_H + +#include +#include +#include +#include + +#ifdef WIN32 +#include +#include +#else +#include +#include +#include +#ifndef N_TTY +#define N_TTY 0 +#endif +#endif + +#include +#include +#include +#include +#include +#include + +// Freebeat internal UART: 9600 8N1 on /dev/ttyS4 +#define FREEBEAT_BAUD 9600 + +// Protocol byte constants +#define FREEBEAT_SYNC_DATA 0x55 +#define FREEBEAT_SYNC_LED 0xE5 +#define FREEBEAT_TYPE_A 0x15 +#define FREEBEAT_TYPE_B 0x03 +#define FREEBEAT_TYPE_C 0x25 +#define FREEBEAT_VERIFY_BYTE 0x3F +#define FREEBEAT_END_BYTE 0xA0 + +// Command start codes +#define FREEBEAT_CMD_START 0x25 +#define FREEBEAT_CMD_LED 0xB5 + +// Command actions +#define FREEBEAT_ACT_RESISTANCE 0x03 +#define FREEBEAT_ACT_DAME_MIX 0x05 +#define FREEBEAT_ACT_DAME_MAX 0x06 +#define FREEBEAT_ACT_RESET 0x07 +#define FREEBEAT_ACT_QUERY 0x08 +#define FREEBEAT_ACT_STOP 0x09 +#define FREEBEAT_ACT_VERSION 0x2F +#define FREEBEAT_ACT_LED_CHECK 0x08 // startCode=0xB5 +#define FREEBEAT_ACT_LED_CHANGE 0x03 // startCode=0xB5 + +class FreebeatUSB : public QThread { + public: + FreebeatUSB(QObject *parent = nullptr, QString deviceFilename = QString(), int baudrate = FREEBEAT_BAUD); + ~FreebeatUSB(); + + int start(); + int stop(); + + // Commands + void setResistance(int resistance); // 1..100 + void sendQuery(); + void sendStop(); + void sendReset(); + + // Telemetry getters (thread-safe) + void getTelemetry(int &rpm, int &resistance, double &speed, double &watt, bool &valid); + + private: + void run() override; + + int openPort(); + int closePort(); + + // Build 5-byte command: [startCode, action, data, checksum, 0xA0] + QByteArray buildCmd5(uint8_t startCode, uint8_t action, uint8_t data); + // Build 4-byte LED query: [0xB5, 0x08, 0x08, 0xA0] + QByteArray buildLedQuery(); + + int rawWrite(const char *bytes, int size); + int rawRead(char *buf, int maxLen, int timeoutMs); + + bool parsePacket(const QByteArray &pkt); + + QMutex pvars; + + // Outbound + volatile int targetResistance; + volatile bool writeResistance; + volatile bool doStop; + volatile bool doReset; + volatile bool doQuery; + + // Inbound telemetry + volatile int devRpm; + volatile int devResistance; + volatile double devSpeed; + volatile double devWatt; + volatile bool devValid; + + QString deviceFilename; + int baudrate; + +#ifdef WIN32 + HANDLE devicePort; + DCB deviceSettings; +#else + int devicePort; + struct termios deviceSettings; +#endif +}; + +#endif // FREEBEATUSB_H diff --git a/src/devices/freebeatbike/PROTOCOL.md b/src/devices/freebeatbike/PROTOCOL.md new file mode 100644 index 0000000000..c7cefecb79 --- /dev/null +++ b/src/devices/freebeatbike/PROTOCOL.md @@ -0,0 +1,115 @@ +# Freebeat Fit Bike — Communication Protocol + +Reverse-engineered from APK `freebeat_original.apk` (decompiled with apktool). +Key smali files: `UsbParseData.smali`, `UsbParseData$ParseAction.smali`, `SerialPortSDK.smali`, `DCUARTDriver.smali`. + +## Physical Layer + +| Parameter | Value | +|-----------|-------| +| Interface | Internal UART (`/dev/ttyS4` on the bike's Android tablet) | +| Baud rate | 9600 bps | +| Frame | 8N1 | +| Library | `libserial_port.so` (JNI wrapper of `android-serialport-api`) | + +**Not USB serial** — the bike's Android tablet communicates with the hardware controller +over a native UART. QZ opens this port directly with `open()` + `termios`, preceded by +`chmod 777 /dev/ttyS4` to obtain rw access (same approach as `DCUARTDriver` in the original app). + +--- + +## Command Packets (Host → Bike) + +### 5-byte command + +``` +[ startCode | action | data | checksum | 0xA0 ] +checksum = (action + data) & 0xFF +``` + +### 4-byte LED heartbeat + +``` +[ 0xB5 | 0x08 | 0x08 | 0xA0 ] +``` +Must be sent periodically to keep the bike alive. + +### Command table + +| Name | startCode | action | data | Notes | +|-------------------|-----------|--------|--------|-------------------------------| +| SET_RESISTANCE | 0x25 | 0x03 | 1–100 | Set resistance level | +| DAME_MIX | 0x25 | 0x05 | 0x00 | | +| DAME_MAX | 0x25 | 0x06 | 0x00 | | +| MACHINE_RESET | 0x25 | 0x07 | 0x00 | | +| MACHINE_QUERY | 0x25 | 0x08 | 0x00 | Request status packet | +| MACHINE_STOP | 0x25 | 0x09 | 0x00 | | +| CHECK_SERIAL_VER | 0x25 | 0x2F | 0x00 | | +| CHECK_LED_LIGHT | 0xB5 | 0x08 | 0x08 | LED heartbeat (4-byte form) | +| LED_LIGHT_CHANGE | 0xB5 | 0x03 | value | | + +--- + +## Telemetry Packets (Bike → Host) + +### Data packet — 13 bytes + +``` +byte[0] = 0x55 sync byte +byte[1] = type 0x15 | 0x03 | 0x25 +byte[2] = 0x00 +byte[3] = 0x00 +byte[4] = speed_lo speed (km/h × 10) little-endian +byte[5] = speed_hi +byte[6] = 0x00 +byte[7] = resistance 1–100 +byte[8] = rpm_lo RPM little-endian +byte[9] = rpm_hi +byte[10] = 0x00 +byte[11] = checksum sum(bytes[1..10]) & 0xFF +byte[12] = 0x3F verify byte +``` + +**Parsing:** +``` +speed (km/h) = ((byte[5] << 8) | byte[4]) * 0.1 +resistance = byte[7] (1–100) +rpm = (byte[9] << 8) | byte[8] +``` + +### LED status packet — 20 bytes + +``` +byte[0] = 0xE5 sync byte for LED packets +... (remaining bytes not decoded; ignore for telemetry) +``` + +--- + +## Power Calculation + +The original app uses two coefficients depending on wheel circumference: + +``` +watt = rpm * coefficient * resistance / 100.0 +``` + +| Variant | Coefficient | +|--------------|-------------| +| Standard | 4.56 | +| Alternative | 7.39 | + +QZ uses the standard coefficient (4.56). + +--- + +## Initialization Sequence + +1. Open `/dev/ttyS4` at 9600 baud 8N1 +2. Send `MACHINE_QUERY` (`[0x25, 0x08, 0x00, 0x08, 0xA0]`) +3. Wait 500 ms +4. Enter polling loop (200 ms interval): + - Send `SET_RESISTANCE` if pending + - Send `MACHINE_QUERY` + - Send LED heartbeat (`[0xB5, 0x08, 0x08, 0xA0]`) + - Read and parse response diff --git a/src/devices/freebeatbike/freebeatbike.cpp b/src/devices/freebeatbike/freebeatbike.cpp new file mode 100644 index 0000000000..80f58ea039 --- /dev/null +++ b/src/devices/freebeatbike/freebeatbike.cpp @@ -0,0 +1,246 @@ +/* + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "freebeatbike.h" +#include "keepawakehelper.h" +#include "virtualdevices/virtualbike.h" +#include +#include +#include +#include +#include +#include +#include + +#include "qzsettings.h" + +#ifdef Q_OS_IOS +#include "ios/lockscreen.h" +#endif + +using namespace std::chrono_literals; + +freebeatbike::freebeatbike(bool noWriteResistance, bool noHeartService, int8_t bikeResistanceOffset, + double bikeResistanceGain) { + QSettings settings; + m_watt.setType(metric::METRIC_WATT, deviceType()); + Speed.setType(metric::METRIC_SPEED); + refresh = new QTimer(this); + this->noWriteResistance = noWriteResistance; + this->noHeartService = noHeartService; + this->bikeResistanceGain = bikeResistanceGain; + this->bikeResistanceOffset = bikeResistanceOffset; + initDone = false; + connect(refresh, &QTimer::timeout, this, &freebeatbike::update); + refresh->start(200ms); + + // The setting enables Freebeat support; the bike hardware is always on this UART. + QString freebeatSerialPort = QStringLiteral("/dev/ttyS4"); + + myFreebeat = new FreebeatUSB(this, freebeatSerialPort, FREEBEAT_BAUD); + myFreebeat->start(); + + initRequest = true; + + if (!firstStateChanged && !this->hasVirtualDevice() +#ifdef Q_OS_IOS +#ifndef IO_UNDER_QT + && !h +#endif +#endif + ) { + bool virtual_device_enabled = + settings.value(QZSettings::virtual_device_enabled, QZSettings::default_virtual_device_enabled).toBool(); +#ifdef Q_OS_IOS +#ifndef IO_UNDER_QT + bool cadence = + 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) { + qDebug() << "ios_peloton_workaround activated!"; + h = new lockscreen(); + h->virtualbike_ios(); + } else +#endif +#endif + if (virtual_device_enabled) { + emit debug(QStringLiteral("creating virtual bike interface...")); + auto virtualBike = + new virtualbike(this, noWriteResistance, noHeartService, bikeResistanceOffset, bikeResistanceGain); + connect(virtualBike, &virtualbike::changeInclination, this, &freebeatbike::changeInclination); + this->setVirtualDevice(virtualBike, VIRTUAL_DEVICE_MODE::PRIMARY); + } + } + firstStateChanged = 1; +} + +resistance_t freebeatbike::resistanceFromPowerRequest(uint16_t power) { + qDebug() << QStringLiteral("resistanceFromPowerRequest") << power; + + // Freebeat resistance is 1–100; power ≈ rpm * 4.56 * resistance/100 + // Without live RPM we do a simple linear mapping from power to resistance + // assuming a cadence near 80 RPM: resistance ≈ power / (80 * 4.56 / 100) + // Clamp to 1–100. + double res = (double)power / (80.0 * 4.56 / 100.0); + if (res < 1) res = 1; + if (res > max_resistance) res = max_resistance; + return (resistance_t)qRound(res); +} + +void freebeatbike::forceResistance(resistance_t requestResistance) { + if (!noWriteResistance && myFreebeat) { + myFreebeat->setResistance((int)requestResistance); + qDebug() << "freebeatbike: forceResistance" << requestResistance; + } +} + +void freebeatbike::innerWriteResistance() { + if (requestResistance != -1) { + if (requestResistance > max_resistance) + requestResistance = max_resistance; + else if (requestResistance < min_resistance) + requestResistance = min_resistance; + + if (requestResistance != currentResistance().value()) { + emit debug(QStringLiteral("writing resistance ") + QString::number(requestResistance)); + if (((virtualBike && !virtualBike->ftmsDeviceConnected()) || !virtualBike) && + (requestPower == 0 || requestPower == -1)) { + forceResistance(requestResistance); + } + } + requestResistance = -1; + } + + if (requestPower > 0) { + resistance_t res = resistanceFromPowerRequest(requestPower); + forceResistance(res); + qDebug() << "freebeatbike: setting power =" << requestPower << "-> resistance" << res; + requestPower = 0; + } +} + +void freebeatbike::update() { + if (initRequest) { + initRequest = false; + btinit(); + emit connectedAndDiscovered(); + return; + } + + QSettings settings; + QString heartRateBeltName = + settings.value(QZSettings::heart_rate_belt_name, QZSettings::default_heart_rate_belt_name).toString(); + bool disable_hr_frommachinery = + settings.value(QZSettings::heart_ignore_builtin, QZSettings::default_heart_ignore_builtin).toBool(); + + int rpm = 0, res = 0; + double speed = 0.0, watt = 0.0; + bool valid = false; + + myFreebeat->getTelemetry(rpm, res, speed, watt, valid); + + if (valid) { + Speed = speed; + Cadence = rpm; + m_watt = watt; + Resistance = res; + m_pelotonResistance = res; + + if (Cadence.value() > 0) { + CrankRevs++; + LastCrankEventTime += (uint16_t)(1024.0 / (((double)(Cadence.value())) / 60.0)); + } + + emit debug(QStringLiteral("Current Speed: ") + QString::number(Speed.value())); + emit debug(QStringLiteral("Current Cadence: ") + QString::number(Cadence.value())); + emit debug(QStringLiteral("Current Watt: ") + QString::number(watts())); + emit debug(QStringLiteral("Current Resistance: ") + QString::number(Resistance.value())); + } + + if (watts()) + KCal += ((((0.048 * ((double)watts()) + 1.19) * + settings.value(QZSettings::weight, QZSettings::default_weight).toFloat() * 3.5) / + 200.0) / + (60000.0 / ((double)lastRefreshCharacteristicChanged.msecsTo(QDateTime::currentDateTime())))); + +#ifdef Q_OS_ANDROID + if (settings.value(QZSettings::ant_heart, QZSettings::default_ant_heart).toBool()) + Heart = (uint8_t)KeepAwakeHelper::heart(); + else +#endif + { + if (disable_hr_frommachinery && heartRateBeltName.startsWith(QStringLiteral("Disabled"))) + update_hr_from_external(); + } + +#ifdef Q_OS_IOS +#ifndef IO_UNDER_QT + bool cadence = + 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) { + h->virtualbike_setCadence(currentCrankRevolutions(), lastCrankEventTime()); + h->virtualbike_setHeartRate((uint8_t)metrics_override_heartrate()); + } +#endif +#endif + + lastRefreshCharacteristicChanged = QDateTime::currentDateTime(); + + update_metrics(false, watts()); + + if (sec1Update++ == (1000 / refresh->interval())) { + sec1Update = 0; + } + + innerWriteResistance(); + + if (requestStart != -1) { + emit debug(QStringLiteral("starting...")); + requestStart = -1; + emit bikeStarted(); + } + if (requestStop != -1) { + emit debug(QStringLiteral("stopping...")); + requestStop = -1; + } +} + +resistance_t freebeatbike::pelotonToBikeResistance(int pelotonResistance) { + // Freebeat resistance 1–100 maps linearly from Peloton 0–100 + int res = pelotonResistance; + if (res < 1) res = 1; + if (res > (int)max_resistance) res = (int)max_resistance; + return (resistance_t)res; +} + +void freebeatbike::btinit() { + initDone = true; +} + +void freebeatbike::deviceDiscovered(const QBluetoothDeviceInfo &device) { + emit debug(QStringLiteral("Found new device: ") + device.name() + " (" + device.address().toString() + ')'); +} + +bool freebeatbike::connected() { + return true; +} + +uint16_t freebeatbike::watts() { + return (uint16_t)m_watt.value(); +} diff --git a/src/devices/freebeatbike/freebeatbike.h b/src/devices/freebeatbike/freebeatbike.h new file mode 100644 index 0000000000..eacb946b0f --- /dev/null +++ b/src/devices/freebeatbike/freebeatbike.h @@ -0,0 +1,97 @@ +/* + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef FREEBEATBIKE_H +#define FREEBEATBIKE_H + +#include +#include +#include +#include + +#ifndef Q_OS_ANDROID +#include +#else +#include +#endif + +#include +#include +#include +#include + +#include +#include + +#include "FreebeatUSB.h" +#include "devices/bike.h" +#include "virtualdevices/virtualbike.h" + +#ifdef Q_OS_IOS +#include "ios/lockscreen.h" +#endif + +class freebeatbike : public bike { + Q_OBJECT + public: + freebeatbike(bool noWriteResistance, bool noHeartService, int8_t bikeResistanceOffset, + double bikeResistanceGain); + resistance_t pelotonToBikeResistance(int pelotonResistance) override; + resistance_t resistanceFromPowerRequest(uint16_t power) override; + resistance_t maxResistance() override { return max_resistance; } + bool connected() override; + + private: + resistance_t max_resistance = 100; + resistance_t min_resistance = 1; + void btinit(); + uint16_t watts() override; + void forceResistance(resistance_t requestResistance); + void innerWriteResistance(); + + QTimer *refresh; + virtualbike *virtualBike = nullptr; + int8_t bikeResistanceOffset = 4; + double bikeResistanceGain = 1.0; + + uint8_t sec1Update = 0; + QDateTime lastRefreshCharacteristicChanged = QDateTime::currentDateTime(); + uint8_t firstStateChanged = 0; + + bool initDone = false; + bool initRequest = false; + + bool noWriteResistance = false; + bool noHeartService = false; + + FreebeatUSB *myFreebeat = nullptr; + +#ifdef Q_OS_IOS + lockscreen *h = 0; +#endif + + signals: + void disconnected(); + void debug(QString string); + + public slots: + void deviceDiscovered(const QBluetoothDeviceInfo &device); + + private slots: + void update(); +}; + +#endif // FREEBEATBIKE_H diff --git a/src/homeform.h b/src/homeform.h index 08635f8955..1019fdd571 100644 --- a/src/homeform.h +++ b/src/homeform.h @@ -399,6 +399,8 @@ class homeform : public QObject { QString proformtdf4ip = settings.value(QZSettings::proformtdf4ip, QZSettings::default_proformtdf4ip).toString(); QString proformtdf1ip = settings.value(QZSettings::proformtdf1ip, QZSettings::default_proformtdf1ip).toString(); QString proformtreadmillip = settings.value(QZSettings::proformtreadmillip, QZSettings::default_proformtreadmillip).toString(); + QString freebeatSerialPort = + settings.value(QZSettings::freebeat_serialport, QZSettings::default_freebeat_serialport).toString(); QString nordictrack_2950_ip = settings.value(QZSettings::nordictrack_2950_ip, QZSettings::default_nordictrack_2950_ip).toString(); @@ -418,7 +420,8 @@ class homeform : public QObject { return settings.value(QZSettings::bluetooth_lastdevice_name, QZSettings::default_bluetooth_lastdevice_name).toString().isEmpty() && nordictrack_2950_ip.isEmpty() && tdf_10_ip.isEmpty() && !fake_bike && !fakedevice_elliptical && !fakedevice_rower && !waterrower_usb && !fakedevice_treadmill && !antbike && !android_antbike && proform_elliptical_ip.isEmpty() && - proformtdf4ip.isEmpty() && proformtdf1ip.isEmpty() && proformtreadmillip.isEmpty(); + proformtdf4ip.isEmpty() && proformtdf1ip.isEmpty() && proformtreadmillip.isEmpty() && + freebeatSerialPort.isEmpty(); } diff --git a/src/qdomyos-zwift.pri b/src/qdomyos-zwift.pri index ab9562e63e..0f0c2f12fe 100644 --- a/src/qdomyos-zwift.pri +++ b/src/qdomyos-zwift.pri @@ -163,6 +163,7 @@ devices/ziprotreadmill/ziprotreadmill.cpp \ zwift_play/zwiftclickremote.cpp \ devices/computrainerbike/Computrainer.cpp \ devices/kettlerusbbike/KettlerUSB.cpp \ +devices/freebeatbike/FreebeatUSB.cpp \ PathController.cpp \ characteristics/characteristicnotifier2a53.cpp \ characteristics/characteristicnotifier2a5b.cpp \ @@ -173,6 +174,7 @@ characteristics/characteristicwriteprocessor.cpp \ characteristics/characteristicwriteprocessore005.cpp \ devices/computrainerbike/computrainerbike.cpp \ devices/kettlerusbbike/kettlerusbbike.cpp \ +devices/freebeatbike/freebeatbike.cpp \ devices/fakeelliptical/fakeelliptical.cpp \ devices/faketreadmill/faketreadmill.cpp \ devices/lifefitnesstreadmill/lifefitnesstreadmill.cpp \ @@ -476,6 +478,7 @@ devices/ypooelliptical/ypooelliptical.h \ devices/ziprotreadmill/ziprotreadmill.h \ devices/computrainerbike/Computrainer.h \ devices/kettlerusbbike/KettlerUSB.h \ +devices/freebeatbike/FreebeatUSB.h \ PathController.h \ characteristics/characteristicnotifier2a53.h \ characteristics/characteristicnotifier2a5b.h \ @@ -485,6 +488,7 @@ characteristics/characteristicnotifier2ad9.h \ characteristics/characteristicwriteprocessore005.h \ devices/computrainerbike/computrainerbike.h \ devices/kettlerusbbike/kettlerusbbike.h \ +devices/freebeatbike/freebeatbike.h \ definitions.h \ devices/fakeelliptical/fakeelliptical.h \ devices/faketreadmill/faketreadmill.h \ diff --git a/src/qzsettings.cpp b/src/qzsettings.cpp index 7d517ef786..11317278ad 100644 --- a/src/qzsettings.cpp +++ b/src/qzsettings.cpp @@ -647,6 +647,8 @@ const QString QZSettings::default_computrainer_serialport = QStringLiteral(""); const QString QZSettings::kettler_usb_serialport = QStringLiteral("kettler_usb_serialport"); const QString QZSettings::default_kettler_usb_serialport = QStringLiteral(""); const QString QZSettings::kettler_usb_baudrate = QStringLiteral("kettler_usb_baudrate"); +const QString QZSettings::freebeat_serialport = QStringLiteral("freebeat_serialport"); +const QString QZSettings::default_freebeat_serialport = QStringLiteral(""); const QString QZSettings::strava_virtual_activity = QStringLiteral("strava_virtual_activity"); const QString QZSettings::powr_sensor_running_cadence_half_on_strava = QStringLiteral("powr_sensor_running_cadence_half_on_strava"); @@ -1277,7 +1279,7 @@ const QString QZSettings::default_shortcut_start_stop = QStringLiteral(""); const QString QZSettings::shortcut_stop = QStringLiteral("shortcut_stop"); const QString QZSettings::default_shortcut_stop = QStringLiteral(""); -const uint32_t allSettingsCount = 1000; +const uint32_t allSettingsCount = 1001; QVariant allSettings[allSettingsCount][2] = { {QZSettings::cryptoKeySettingsProfiles, QZSettings::default_cryptoKeySettingsProfiles}, @@ -2302,6 +2304,7 @@ QVariant allSettings[allSettingsCount][2] = { {QZSettings::zwiftplay_gear_paddle_right, QZSettings::default_zwiftplay_gear_paddle_right}, {QZSettings::zwiftplay_gear_lb, QZSettings::default_zwiftplay_gear_lb}, {QZSettings::zwiftplay_gear_rb, QZSettings::default_zwiftplay_gear_rb}, + {QZSettings::freebeat_serialport, QZSettings::default_freebeat_serialport}, }; void QZSettings::qDebugAllSettings(bool showDefaults) { diff --git a/src/qzsettings.h b/src/qzsettings.h index 799714e8fe..ffe0d28d78 100644 --- a/src/qzsettings.h +++ b/src/qzsettings.h @@ -1817,6 +1817,9 @@ class QZSettings { static const QString kettler_usb_baudrate; static constexpr int default_kettler_usb_baudrate = 9600; + static const QString freebeat_serialport; + static const QString default_freebeat_serialport; + static const QString strava_virtual_activity; static constexpr bool default_strava_virtual_activity = true; diff --git a/src/settings-catalog.json b/src/settings-catalog.json index 2f18a1a8fd..550ffddb65 100644 --- a/src/settings-catalog.json +++ b/src/settings-catalog.json @@ -2,7 +2,7 @@ "$schema": "https://qdomyos-zwift.local/settings-catalog.schema.json", "schemaVersion": 1, "format": "qdomyos-zwift-settings-catalog", - "settingCount": 963, + "settingCount": 964, "pages": [ { "key": "page_custom_gear_table", @@ -46,7 +46,7 @@ }, { "key": "page_tts_text_to_speech_settings", - "name": "TTS (Text to Speech) Settings 🔊", + "name": "TTS (Text to Speech) Settings \ud83d\udd0a", "description": null, "parent": "General", "type": "page", @@ -56,7 +56,7 @@ }, { "key": "page_keyboard_shortcuts", - "name": "Keyboard Shortcuts ⌨️", + "name": "Keyboard Shortcuts \u2328\ufe0f", "description": null, "parent": "Tiles", "type": "page", @@ -461,7 +461,7 @@ { "key": "bike_resistance_offset", "name": "Zwift Resistance Offset", - "description": "This setting sets your “flat road” in Zwift. All communicated resistance changes will be based on this setting. The value entered is personal preference and will be dependent on your level of fitness. The suggested value for Echelon bikes is between 18 and 20. Default is 4.", + "description": "This setting sets your \u201cflat road\u201d in Zwift. All communicated resistance changes will be based on this setting. The value entered is personal preference and will be dependent on your level of fitness. The suggested value for Echelon bikes is between 18 and 20. Default is 4.", "parent": "Bike Options", "type": "integer", "qmlType": "int", @@ -474,7 +474,7 @@ { "key": "bike_resistance_gain_f", "name": "Zwift Resistance Gain", - "description": "(for bikes and treadmills when using “treadmill as a bike” setting). This setting scales the resistance from your bike or the speed from your treadmill before sending it to Zwift. Default is 1.", + "description": "(for bikes and treadmills when using \u201ctreadmill as a bike\u201d setting). This setting scales the resistance from your bike or the speed from your treadmill before sending it to Zwift. Default is 1.", "parent": "Bike Options", "type": "number", "qmlType": "real", @@ -500,7 +500,7 @@ { "key": "zwift_erg_filter", "name": "Zwift ERG Watt Up Filter", - "description": "In ERG Mode or during a Power Zone workout on Peloton, the app sends a “target output” request. If the output requested doesn’t match your current output (calculated using cadence and resistance level), your target resistance will change to help you get closer to the target output. If the filter is set to higher values, you will get less adjustment of the target resistance and you will have to increase your cadence to match the target output. The Up and Down Watt Filter settings are the upper and lower margin before the adjustment of resistance is communicated. Example: if the up and down filters are set to 10 and the target output is 100 watts, a change of your resistance will only be communicated if your bike produces less than 90 watts or more than 110 watts. Default is 10.", + "description": "In ERG Mode or during a Power Zone workout on Peloton, the app sends a \u201ctarget output\u201d request. If the output requested doesn\u2019t match your current output (calculated using cadence and resistance level), your target resistance will change to help you get closer to the target output. If the filter is set to higher values, you will get less adjustment of the target resistance and you will have to increase your cadence to match the target output. The Up and Down Watt Filter settings are the upper and lower margin before the adjustment of resistance is communicated. Example: if the up and down filters are set to 10 and the target output is 100 watts, a change of your resistance will only be communicated if your bike produces less than 90 watts or more than 110 watts. Default is 10.", "parent": "Bike Options", "type": "number", "qmlType": "real", @@ -526,7 +526,7 @@ { "key": "zwift_negative_inclination_x2", "name": "Double Negative Inclination", - "description": "Turn this on if you have a bike with inclination capabilities to fix Zwift’s bug that sends half-negative downhill inclination", + "description": "Turn this on if you have a bike with inclination capabilities to fix Zwift\u2019s bug that sends half-negative downhill inclination", "parent": "Advanced Settings", "type": "boolean", "qmlType": "bool", @@ -708,7 +708,7 @@ { "key": "treadmill_force_speed", "name": "Treadmill Speed Forcing", - "description": "Turn this on to have QZ control the speed of your treadmill during, for example, Peloton classes based on the coach’s speed callouts. Your speed will be in the low, upper or average range based on your Peloton Options > Difficulty setting. Default is off.", + "description": "Turn this on to have QZ control the speed of your treadmill during, for example, Peloton classes based on the coach\u2019s speed callouts. Your speed will be in the low, upper or average range based on your Peloton Options > Difficulty setting. Default is off.", "parent": "Treadmill Options", "type": "boolean", "qmlType": "bool", @@ -734,7 +734,7 @@ { "key": "continuous_moving", "name": "Continuous Moving", - "description": "Turn this on for: - Peloton Bootcamp classes or other workouts that are on and off the bike or treadmill. QZ will continue to track your workout even when you step away from your equipment. - Capturing non-equipment-based workouts, such as yoga or strength training. NOTE: All such workouts are labeled as “Rides” in Strava, but you can edit the label in Strava.", + "description": "Turn this on for: - Peloton Bootcamp classes or other workouts that are on and off the bike or treadmill. QZ will continue to track your workout even when you step away from your equipment. - Capturing non-equipment-based workouts, such as yoga or strength training. NOTE: All such workouts are labeled as \u201cRides\u201d in Strava, but you can edit the label in Strava.", "parent": "General Options", "type": "boolean", "qmlType": "bool", @@ -975,7 +975,7 @@ { "key": "pzp_username", "name": "PZP Username", - "description": "As of 4/1/2022, this feature is broken due to a Power Zone Pack (PZP) website change. Leave (or change back to) the default of “username” (without quotation marks, all lowercase and all one word) until further notice.", + "description": "As of 4/1/2022, this feature is broken due to a Power Zone Pack (PZP) website change. Leave (or change back to) the default of \u201cusername\u201d (without quotation marks, all lowercase and all one word) until further notice.", "parent": "Peloton Options", "type": "string", "qmlType": "string", @@ -1217,7 +1217,7 @@ { "key": "tile_resistance_order", "name": "order index", - "description": "Displays your bike’s resistance. The +/- buttons can be used to change resistance, if your bike is compatible.", + "description": "Displays your bike\u2019s resistance. The +/- buttons can be used to change resistance, if your bike is compatible.", "parent": "tile_resistance_enabled", "type": "integer", "qmlType": "int", @@ -1536,7 +1536,7 @@ { "key": "tile_peloton_offset_order", "name": "order index", - "description": "Allows you to sync resistance and cadence target changes with the Peloton coach’s callouts. If the targets are changing in QZ after the coach’s callouts, use the ‘+’ button to add seconds (essentially speeding QZ up). Use the ‘-’ button to slow QZ down. Use this tile in conjunction with the Remaining Time/Row tile (see below).", + "description": "Allows you to sync resistance and cadence target changes with the Peloton coach\u2019s callouts. If the targets are changing in QZ after the coach\u2019s callouts, use the \u2018+\u2019 button to add seconds (essentially speeding QZ up). Use the \u2018-\u2019 button to slow QZ down. Use this tile in conjunction with the Remaining Time/Row tile (see below).", "parent": "tile_peloton_offset_enabled", "type": "integer", "qmlType": "int", @@ -1649,7 +1649,7 @@ { "key": "tile_target_resistance_order", "name": "order index", - "description": "Displays target resistance in your bike’s resistance scale. For example, during a Peloton class or Zwift session, you want the resistance displayed in this tile to match the Resistance Tile.", + "description": "Displays target resistance in your bike\u2019s resistance scale. For example, during a Peloton class or Zwift session, you want the resistance displayed in this tile to match the Resistance Tile.", "parent": "tile_target_resistance_enabled", "type": "integer", "qmlType": "int", @@ -1910,7 +1910,7 @@ { "key": "tile_watt_kg_order", "name": "order index", - "description": "Calculates your output (watts) divided by your weight. This is the primary metric used by Zwift and similar apps to calculate your virtual speed. NOTE: This is a much better metric to use than Output/Watts when comparing your effort to other users. This is why Peloton’s leaderboard, which uses only Output, is flawed.", + "description": "Calculates your output (watts) divided by your weight. This is the primary metric used by Zwift and similar apps to calculate your virtual speed. NOTE: This is a much better metric to use than Output/Watts when comparing your effort to other users. This is why Peloton\u2019s leaderboard, which uses only Output, is flawed.", "parent": "tile_watt_kg_enabled", "type": "integer", "qmlType": "int", @@ -1997,7 +1997,7 @@ { "key": "tile_nextrowstrainprogram_order", "name": "order index", - "description": "Displays the next Peloton interval with duration and FTP Zone (in Power Zone classes) or Peloton Resistance (non–Power Zone classes).", + "description": "Displays the next Peloton interval with duration and FTP Zone (in Power Zone classes) or Peloton Resistance (non\u2013Power Zone classes).", "parent": "tile_nextrowstrainprogram_enabled", "type": "integer", "qmlType": "int", @@ -2113,7 +2113,7 @@ { "key": "tile_pid_hr_order", "name": "order index", - "description": "Use this tile to display the target heart rate zone in which you’ve chosen to work out in Settings > Training Program Options.", + "description": "Use this tile to display the target heart rate zone in which you\u2019ve chosen to work out in Settings > Training Program Options.", "parent": "tile_pid_hr_enabled", "type": "integer", "qmlType": "int", @@ -2233,7 +2233,7 @@ { "key": "peloton_offset", "name": "Conversion Offset", - "description": "Increases the resistance that QZ displays in the Peloton Resistance tile. If QZ’s calculated conversion from your bike’s resistance scale to Peloton’s seems too low, the number you enter here will be added to the calculated resistance without increasing your effort or actual resistance. (Example: If QZ displays Peloton resistance of 30 and you enter 5, QZ will display 35.)", + "description": "Increases the resistance that QZ displays in the Peloton Resistance tile. If QZ\u2019s calculated conversion from your bike\u2019s resistance scale to Peloton\u2019s seems too low, the number you enter here will be added to the calculated resistance without increasing your effort or actual resistance. (Example: If QZ displays Peloton resistance of 30 and you enter 5, QZ will display 35.)", "parent": "Peloton Options", "type": "number", "qmlType": "real", @@ -2246,7 +2246,7 @@ { "key": "treadmill_pid_heart_zone", "name": "PID on Heart Zone", - "description": "QZ controls your treadmill or bike to keep you within a chosen Heart Rate Zone. Turn on, set a target heart rate (HR) zone in which to train and click OK. For example, enter 2 to train in HR zone 2 and the treadmill will auto adjust the speed (or resistance on a bike) to maintain your heart rate in zone 2. QZ gradually increases or decreases your speed (or bike resistance) in small increments every 40 seconds to reach and maintain your target HR zone. During a workout, you can display and use the ‘+’ and ‘-’ button on the PID HR Zone tile to change the target HR zone.", + "description": "QZ controls your treadmill or bike to keep you within a chosen Heart Rate Zone. Turn on, set a target heart rate (HR) zone in which to train and click OK. For example, enter 2 to train in HR zone 2 and the treadmill will auto adjust the speed (or resistance on a bike) to maintain your heart rate in zone 2. QZ gradually increases or decreases your speed (or bike resistance) in small increments every 40 seconds to reach and maintain your target HR zone. During a workout, you can display and use the \u2018+\u2019 and \u2018-\u2019 button on the PID HR Zone tile to change the target HR zone.", "parent": "Training Program Options", "type": "string", "qmlType": "string", @@ -2269,7 +2269,7 @@ { "key": "pacef_1mile", "name": "1 mile pace (total time)", - "description": "Enter your 1 mile time goal, click OK. This setting will be used when you’re following a training program with the speed control. These settings should also match the Zwift app settings. More info: https://github.com/cagnulein/qdomyos-zwift/issues/609.", + "description": "Enter your 1 mile time goal, click OK. This setting will be used when you\u2019re following a training program with the speed control. These settings should also match the Zwift app settings. More info: https://github.com/cagnulein/qdomyos-zwift/issues/609.", "parent": "Training Program Options", "type": "number", "qmlType": "real", @@ -3232,7 +3232,7 @@ { "key": "filter_device", "name": "Manual Device", - "description": "Allows you to force QZ to connect to your equipment (see “Bluetooth Troubleshooting” below). Default is “Disabled.”", + "description": "Allows you to force QZ to connect to your equipment (see \u201cBluetooth Troubleshooting\u201d below). Default is \u201cDisabled.\u201d", "parent": "Advanced Settings", "type": "string", "qmlType": "string", @@ -3248,7 +3248,7 @@ { "key": "strava_suffix", "name": "Suffix activity", - "description": "Default is “QZ.” Please leave this set to default so that other Strava users will see the QZ; a tiny bit of advertising that helps promote the app and support its development. If you choose to remove it, please consider contributing to the developer’s Patreon or Buy Me a Coffee accounts or just subscribe to the Swag bag in the left side bar to allow me to continue developing and supporting the app.", + "description": "Default is \u201cQZ.\u201d Please leave this set to default so that other Strava users will see the QZ; a tiny bit of advertising that helps promote the app and support its development. If you choose to remove it, please consider contributing to the developer\u2019s Patreon or Buy Me a Coffee accounts or just subscribe to the Swag bag in the left side bar to allow me to continue developing and supporting the app.", "parent": "Advanced Settings", "type": "string", "qmlType": "string", @@ -3371,7 +3371,7 @@ { "key": "power_sensor_as_bike", "name": "Power Sensor as a Bike", - "description": "If your bike doesn’t have Bluetooth, this setting allows you to use a power meter pedal sensor so your bike will work with QZ. Default is off.", + "description": "If your bike doesn\u2019t have Bluetooth, this setting allows you to use a power meter pedal sensor so your bike will work with QZ. Default is off.", "parent": "Power Sensor Options", "type": "boolean", "qmlType": "bool", @@ -3384,7 +3384,7 @@ { "key": "power_sensor_as_treadmill", "name": "Power Sensor as a Treadmill", - "description": "If your treadmill doesn’t have Bluetooth, this setting allows you to use a Stryde sensor (or similar) so your treadmill will work with QZ. Default is off.", + "description": "If your treadmill doesn\u2019t have Bluetooth, this setting allows you to use a Stryde sensor (or similar) so your treadmill will work with QZ. Default is off.", "parent": "Power Sensor Options", "type": "boolean", "qmlType": "bool", @@ -3472,7 +3472,7 @@ "key": "fitmetria_fanfit_enable", "name": "Enable", "description": null, - "parent": "Fitmetria Fitfan™ Options", + "parent": "Fitmetria Fitfan\u2122 Options", "type": "boolean", "qmlType": "bool", "control": "switch", @@ -3485,7 +3485,7 @@ "key": "fitmetria_fanfit_mode", "name": "Mode", "description": null, - "parent": "Fitmetria Fitfan™ Options", + "parent": "Fitmetria Fitfan\u2122 Options", "type": "string", "qmlType": "string", "control": "select", @@ -3505,7 +3505,7 @@ "key": "fitmetria_fanfit_min", "name": "Min. value (0-100)", "description": null, - "parent": "Fitmetria Fitfan™ Options", + "parent": "Fitmetria Fitfan\u2122 Options", "type": "number", "qmlType": "real", "control": "text", @@ -3518,7 +3518,7 @@ "key": "fitmetria_fanfit_max", "name": "Max value (0-100)", "description": null, - "parent": "Fitmetria Fitfan™ Options", + "parent": "Fitmetria Fitfan\u2122 Options", "type": "number", "qmlType": "real", "control": "text", @@ -3556,7 +3556,7 @@ { "key": "bluetooth_30m_hangs", "name": "Bluetooth hangs after 30 m", - "description": "Same as “Relaxed Bluetooth for mad devices”. Leave off unless the Support staff asks you to turn it on. Default is off.", + "description": "Same as \u201cRelaxed Bluetooth for mad devices\u201d. Leave off unless the Support staff asks you to turn it on. Default is off.", "parent": "Experimental Features", "type": "boolean", "qmlType": "bool", @@ -3725,7 +3725,7 @@ { "key": "applewatch_fakedevice", "name": "Fake Device", - "description": "Simulates QZ being connected to a bike. When this is turned on QZ will calculate KCal based on your heart rate. Examples of when to use this setting: ○ To capture Peloton class data for classes without connected equipment (e.g., a strength or yoga workout).. ○ To arrange tiles on the QZ dashboard without connecting to your equipment. ○ To use the QZ Apple Watch app without connecting to your equipment.", + "description": "Simulates QZ being connected to a bike. When this is turned on QZ will calculate KCal based on your heart rate. Examples of when to use this setting: \u25cb To capture Peloton class data for classes without connected equipment (e.g., a strength or yoga workout).. \u25cb To arrange tiles on the QZ dashboard without connecting to your equipment. \u25cb To use the QZ Apple Watch app without connecting to your equipment.", "parent": "Experimental Features", "type": "boolean", "qmlType": "bool", @@ -4986,7 +4986,7 @@ "key": "maps_type", "name": "Maps Type", "description": null, - "parent": "Maps 🗺️", + "parent": "Maps \ud83d\uddfa\ufe0f", "type": "string", "qmlType": "string", "control": "select", @@ -6370,7 +6370,7 @@ "key": "gpx_loop", "name": "Loop Start-End-Start", "description": null, - "parent": "Maps 🗺️", + "parent": "Maps \ud83d\uddfa\ufe0f", "type": "boolean", "qmlType": "bool", "control": "switch", @@ -7707,7 +7707,7 @@ { "key": "ftms_rower", "name": "FTMS Rower", - "description": "Allows you to force QZ to connect to your FTMS Rower. If you are in doubt, leave this Disabled and send an email to the QZ support. Default is “Disabled.”", + "description": "Allows you to force QZ to connect to your FTMS Rower. If you are in doubt, leave this Disabled and send an email to the QZ support. Default is \u201cDisabled.\u201d", "parent": "Rower Options", "type": "string", "qmlType": "string", @@ -7829,7 +7829,7 @@ { "key": "watt_ignore_builtin", "name": "Disable wattage from machinery", - "description": "This prevents your fitness device from sending its wattage calculation to QZ and defaults to QZ’s more accurate calculation.", + "description": "This prevents your fitness device from sending its wattage calculation to QZ and defaults to QZ\u2019s more accurate calculation.", "parent": "Advanced Settings", "type": "boolean", "qmlType": "bool", @@ -12138,7 +12138,7 @@ }, { "key": "domyos_treadmill_ts100", - "name": "TS100 (Fixed 15° Inclination)", + "name": "TS100 (Fixed 15\u00b0 Inclination)", "description": null, "parent": "Domyos Treadmill Options", "type": "boolean", @@ -12328,7 +12328,7 @@ { "key": "cadence_sensor_as_treadmill", "name": "Cadence Sensor as a Treadmill", - "description": "If your equipment doesn’t have Bluetooth, these settings allow you to use a cadence sensor so it will work with QZ as a bike or treadmill. Default is off.", + "description": "If your equipment doesn\u2019t have Bluetooth, these settings allow you to use a cadence sensor so it will work with QZ as a bike or treadmill. Default is off.", "parent": "Cadence Sensor Options", "type": "boolean", "qmlType": "bool", @@ -12522,7 +12522,7 @@ { "key": "power_sensor_speed_inclination_coeff_b", "name": "Power Sensor Speed/Incline Coefficient B", - "description": "Custom coefficients for power sensor inclination calculation using formula: vwatts = (A + B × speed) × inclination. For Stryd sensors use: A = -0.96, B = 1.33 Examples with these values: • 8 km/h, 10% incline: (-0.96 + 1.33×8) × 10 = 97W added • 11 km/h, 10% incline: (-0.96 + 1.33×11) × 10 = 137W added If both A and B are 0, QZ will use the default formula: 9.8 × weight × (inclination/100). Default: A = -0.96, B = 1.33", + "description": "Custom coefficients for power sensor inclination calculation using formula: vwatts = (A + B \u00d7 speed) \u00d7 inclination. For Stryd sensors use: A = -0.96, B = 1.33 Examples with these values: \u2022 8 km/h, 10% incline: (-0.96 + 1.33\u00d78) \u00d7 10 = 97W added \u2022 11 km/h, 10% incline: (-0.96 + 1.33\u00d711) \u00d7 10 = 137W added If both A and B are 0, QZ will use the default formula: 9.8 \u00d7 weight \u00d7 (inclination/100). Default: A = -0.96, B = 1.33", "parent": "Power Sensor Options", "type": "number", "qmlType": "double", @@ -13695,6 +13695,19 @@ "defaultExpression": "false", "options": null }, + { + "key": "freebeat_serialport", + "name": "Serial Port", + "description": null, + "parent": "Freebeat Bike Options", + "type": "string", + "qmlType": "string", + "control": "text", + "visible": true, + "defaultValue": "", + "defaultExpression": "\"\"", + "options": null + }, { "key": "waterrower_usb", "name": "WaterRower USB", @@ -13709,4 +13722,4 @@ "options": null } ] -} +} \ No newline at end of file diff --git a/src/settings.qml b/src/settings.qml index 0ebdcdeeca..3b0d23997c 100644 --- a/src/settings.qml +++ b/src/settings.qml @@ -1735,8 +1735,8 @@ import AndroidStatusBar 1.0 property int mywhoosh_link_right_power: 0 property int mywhoosh_link_camera_value: 1 property int mywhoosh_link_emote_value: 1 - property bool waterrower_usb: false + property string freebeat_serialport: "" } @@ -5608,6 +5608,41 @@ import AndroidStatusBar 1.0 } + AccordionElement { + id: freebeatBikeAccordion + title: qsTr("Freebeat Bike Options") + indicatRectColor: Material.color(Material.Grey) + textColor: Material.color(Material.Yellow) + color: Material.backgroundColor + accordionContent: ColumnLayout { + spacing: 0 + RowLayout { + spacing: 10 + Label { + id: labelFreebeatSerialPort + text: qsTr("Serial Port:") + Layout.fillWidth: true + } + TextField { + id: freebeatSerialPortTextField + text: settings.freebeat_serialport + horizontalAlignment: Text.AlignRight + Layout.fillHeight: false + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + onAccepted: settings.freebeat_serialport = text + onActiveFocusChanged: if(this.focus) this.cursorPosition = this.text.length + } + Button { + id: okFreebeatSerialPortButton + text: qsTr("OK") + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + onClicked: { settings.freebeat_serialport = freebeatSerialPortTextField.text; window.settings_restart_to_apply = true; toast.show(qsTr("Setting saved!")); } + } + } + } + } + + AccordionElement { id: m3iBikeAccordion title: qsTr("M3i Bike Options")