Skip to content

Commit 822e898

Browse files
cagnuleinclaude
andcommitted
Freebeat: fix serial open (direct POSIX, not Usbserial.java) + add PROTOCOL.md
The Freebeat bike uses an internal UART (/dev/ttyS4), not a USB-CDC/FTDI adapter, so Usbserial.java was wrong. Now FreebeatUSB opens the port with POSIX open() + termios on all platforms (Android included), preceded by chmod 777 on Android (same technique as DCUARTDriver in the original APK). Removed QAndroidJniObject / rxBuffer since they are no longer needed. Also add PROTOCOL.md documenting the full binary protocol reverse-engineered from the APK (packet layout, command table, checksum, power formula). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c393d0e commit 822e898

3 files changed

Lines changed: 138 additions & 65 deletions

File tree

src/devices/freebeatbike/FreebeatUSB.cpp

Lines changed: 23 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@
1616

1717
#include "FreebeatUSB.h"
1818

19-
#ifdef Q_OS_ANDROID
20-
#include <QtAndroid>
21-
#endif
22-
2319
/* ----------------------------------------------------------------------
2420
* CONSTRUCTOR/DESTRUCTOR
2521
* ---------------------------------------------------------------------- */
@@ -36,7 +32,9 @@ FreebeatUSB::FreebeatUSB(QObject *parent, QString deviceFilename, int baudrate)
3632
devSpeed = 0.0;
3733
devWatt = 0.0;
3834
devValid = false;
39-
#ifndef WIN32
35+
#ifdef WIN32
36+
devicePort = INVALID_HANDLE_VALUE;
37+
#else
4038
devicePort = -1;
4139
#endif
4240
}
@@ -172,19 +170,16 @@ bool FreebeatUSB::parsePacket(const QByteArray &pkt) {
172170

173171
/* ----------------------------------------------------------------------
174172
* RAW I/O
173+
* Note: Freebeat communicates via internal UART (/dev/ttyS4), NOT USB.
174+
* We open the port directly with POSIX open() on all platforms (Android
175+
* included) — NOT through Usbserial.java which is for USB-CDC/FTDI adapters.
176+
* On Android, chmod 777 is required before open() to get rw access to the
177+
* UART device node (same approach used by the original Freebeat app's
178+
* DCUARTDriver class).
175179
* ---------------------------------------------------------------------- */
176180
int FreebeatUSB::rawWrite(const char *bytes, int size) {
177181
qDebug() << "Freebeat TX:" << QByteArray(bytes, size).toHex();
178-
#ifdef Q_OS_ANDROID
179-
QAndroidJniEnvironment env;
180-
jbyteArray d = env->NewByteArray(size);
181-
jbyte *b = env->GetByteArrayElements(d, 0);
182-
for (int i = 0; i < size; i++)
183-
b[i] = bytes[i];
184-
env->SetByteArrayRegion(d, 0, size, b);
185-
QAndroidJniObject::callStaticMethod<void>("org/cagnulen/qdomyoszwift/Usbserial", "write", "([B)V", d);
186-
return size;
187-
#elif defined(WIN32)
182+
#ifdef WIN32
188183
DWORD cBytes;
189184
if (!WriteFile(devicePort, bytes, size, &cBytes, NULL))
190185
return -1;
@@ -199,36 +194,7 @@ int FreebeatUSB::rawWrite(const char *bytes, int size) {
199194

200195
// Read up to maxLen bytes with a timeout in ms; returns bytes read
201196
int FreebeatUSB::rawRead(char *buf, int maxLen, int timeoutMs) {
202-
#ifdef Q_OS_ANDROID
203-
int total = 0;
204-
int elapsed = 0;
205-
const int step = 10;
206-
while (elapsed < timeoutMs && total < maxLen) {
207-
// Drain Android USB serial into rxBuffer
208-
QAndroidJniObject dd = QAndroidJniObject::callStaticObjectMethod(
209-
"org/cagnulen/qdomyoszwift/Usbserial", "read", "()[B");
210-
jint len = QAndroidJniObject::callStaticMethod<jint>(
211-
"org/cagnulen/qdomyoszwift/Usbserial", "readLen", "()I");
212-
if (len > 0) {
213-
QAndroidJniEnvironment env;
214-
jbyteArray d = dd.object<jbyteArray>();
215-
jbyte *b = env->GetByteArrayElements(d, 0);
216-
rxBuffer.append(reinterpret_cast<const char *>(b), len);
217-
env->ReleaseByteArrayElements(d, b, JNI_ABORT);
218-
}
219-
int avail = qMin(maxLen - total, rxBuffer.size());
220-
if (avail > 0) {
221-
memcpy(buf + total, rxBuffer.constData(), avail);
222-
rxBuffer.remove(0, avail);
223-
total += avail;
224-
}
225-
if (total >= maxLen)
226-
break;
227-
QThread::msleep(step);
228-
elapsed += step;
229-
}
230-
return total;
231-
#elif defined(WIN32)
197+
#ifdef WIN32
232198
DWORD cBytes = 0;
233199
COMMTIMEOUTS ct;
234200
GetCommTimeouts(devicePort, &ct);
@@ -256,15 +222,7 @@ int FreebeatUSB::rawRead(char *buf, int maxLen, int timeoutMs) {
256222
* PORT OPEN / CLOSE
257223
* ---------------------------------------------------------------------- */
258224
int FreebeatUSB::openPort() {
259-
#ifdef Q_OS_ANDROID
260-
QAndroidJniObject portName = QAndroidJniObject::fromString(QStringLiteral("auto"));
261-
QAndroidJniObject::callStaticMethod<void>("org/cagnulen/qdomyoszwift/Usbserial", "open",
262-
"(Landroid/content/Context;ILjava/lang/String;)V",
263-
QtAndroid::androidContext().object(),
264-
baudrate,
265-
portName.object<jstring>());
266-
return 0;
267-
#elif defined(WIN32)
225+
#ifdef WIN32
268226
COMMTIMEOUTS timeouts;
269227
QString portSpec;
270228
int portnum = deviceFilename.midRef(3).toString().toInt();
@@ -309,6 +267,17 @@ int FreebeatUSB::openPort() {
309267
SetCommTimeouts(devicePort, &timeouts);
310268
return 0;
311269
#else
270+
// POSIX path — used on Linux, macOS, and Android.
271+
// On Android the UART (/dev/ttyS4) is owned by root; chmod 777 grants access.
272+
#ifdef Q_OS_ANDROID
273+
{
274+
QString cmd = "chmod 777 " + deviceFilename;
275+
int rv = system(cmd.toLatin1().constData());
276+
if (rv != 0)
277+
qDebug() << "Freebeat: chmod failed for" << deviceFilename;
278+
}
279+
#endif
280+
312281
#if defined(Q_OS_MACX)
313282
int ldisc = TTYDISC;
314283
#else
@@ -353,8 +322,6 @@ int FreebeatUSB::openPort() {
353322
int FreebeatUSB::closePort() {
354323
#ifdef WIN32
355324
return (int)!CloseHandle(devicePort);
356-
#elif defined(Q_OS_ANDROID)
357-
return 0;
358325
#else
359326
tcflush(devicePort, TCIOFLUSH);
360327
return close(devicePort);

src/devices/freebeatbike/FreebeatUSB.h

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,6 @@
3434
#endif
3535
#endif
3636

37-
#ifdef Q_OS_ANDROID
38-
#include "keepawakehelper.h"
39-
#include <QAndroidJniObject>
40-
#endif
41-
4237
#include <errno.h>
4338
#include <fcntl.h>
4439
#include <stdint.h>
@@ -132,10 +127,6 @@ class FreebeatUSB : public QThread {
132127
int devicePort;
133128
struct termios deviceSettings;
134129
#endif
135-
136-
#ifdef Q_OS_ANDROID
137-
QByteArray rxBuffer;
138-
#endif
139130
};
140131

141132
#endif // FREEBEATUSB_H
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Freebeat Fit Bike — Communication Protocol
2+
3+
Reverse-engineered from APK `freebeat_original.apk` (decompiled with apktool).
4+
Key smali files: `UsbParseData.smali`, `UsbParseData$ParseAction.smali`, `SerialPortSDK.smali`, `DCUARTDriver.smali`.
5+
6+
## Physical Layer
7+
8+
| Parameter | Value |
9+
|-----------|-------|
10+
| Interface | Internal UART (`/dev/ttyS4` on the bike's Android tablet) |
11+
| Baud rate | 9600 bps |
12+
| Frame | 8N1 |
13+
| Library | `libserial_port.so` (JNI wrapper of `android-serialport-api`) |
14+
15+
**Not USB serial** — the bike's Android tablet communicates with the hardware controller
16+
over a native UART. QZ opens this port directly with `open()` + `termios`, preceded by
17+
`chmod 777 /dev/ttyS4` to obtain rw access (same approach as `DCUARTDriver` in the original app).
18+
19+
---
20+
21+
## Command Packets (Host → Bike)
22+
23+
### 5-byte command
24+
25+
```
26+
[ startCode | action | data | checksum | 0xA0 ]
27+
checksum = (action + data) & 0xFF
28+
```
29+
30+
### 4-byte LED heartbeat
31+
32+
```
33+
[ 0xB5 | 0x08 | 0x08 | 0xA0 ]
34+
```
35+
Must be sent periodically to keep the bike alive.
36+
37+
### Command table
38+
39+
| Name | startCode | action | data | Notes |
40+
|-------------------|-----------|--------|--------|-------------------------------|
41+
| SET_RESISTANCE | 0x25 | 0x03 | 1–100 | Set resistance level |
42+
| DAME_MIX | 0x25 | 0x05 | 0x00 | |
43+
| DAME_MAX | 0x25 | 0x06 | 0x00 | |
44+
| MACHINE_RESET | 0x25 | 0x07 | 0x00 | |
45+
| MACHINE_QUERY | 0x25 | 0x08 | 0x00 | Request status packet |
46+
| MACHINE_STOP | 0x25 | 0x09 | 0x00 | |
47+
| CHECK_SERIAL_VER | 0x25 | 0x2F | 0x00 | |
48+
| CHECK_LED_LIGHT | 0xB5 | 0x08 | 0x08 | LED heartbeat (4-byte form) |
49+
| LED_LIGHT_CHANGE | 0xB5 | 0x03 | value | |
50+
51+
---
52+
53+
## Telemetry Packets (Bike → Host)
54+
55+
### Data packet — 13 bytes
56+
57+
```
58+
byte[0] = 0x55 sync byte
59+
byte[1] = type 0x15 | 0x03 | 0x25
60+
byte[2] = 0x00
61+
byte[3] = 0x00
62+
byte[4] = speed_lo speed (km/h × 10) little-endian
63+
byte[5] = speed_hi
64+
byte[6] = 0x00
65+
byte[7] = resistance 1–100
66+
byte[8] = rpm_lo RPM little-endian
67+
byte[9] = rpm_hi
68+
byte[10] = 0x00
69+
byte[11] = checksum sum(bytes[1..10]) & 0xFF
70+
byte[12] = 0x3F verify byte
71+
```
72+
73+
**Parsing:**
74+
```
75+
speed (km/h) = ((byte[5] << 8) | byte[4]) * 0.1
76+
resistance = byte[7] (1–100)
77+
rpm = (byte[9] << 8) | byte[8]
78+
```
79+
80+
### LED status packet — 20 bytes
81+
82+
```
83+
byte[0] = 0xE5 sync byte for LED packets
84+
... (remaining bytes not decoded; ignore for telemetry)
85+
```
86+
87+
---
88+
89+
## Power Calculation
90+
91+
The original app uses two coefficients depending on wheel circumference:
92+
93+
```
94+
watt = rpm * coefficient * resistance / 100.0
95+
```
96+
97+
| Variant | Coefficient |
98+
|--------------|-------------|
99+
| Standard | 4.56 |
100+
| Alternative | 7.39 |
101+
102+
QZ uses the standard coefficient (4.56).
103+
104+
---
105+
106+
## Initialization Sequence
107+
108+
1. Open `/dev/ttyS4` at 9600 baud 8N1
109+
2. Send `MACHINE_QUERY` (`[0x25, 0x08, 0x00, 0x08, 0xA0]`)
110+
3. Wait 500 ms
111+
4. Enter polling loop (200 ms interval):
112+
- Send `SET_RESISTANCE` if pending
113+
- Send `MACHINE_QUERY`
114+
- Send LED heartbeat (`[0xB5, 0x08, 0x08, 0xA0]`)
115+
- Read and parse response

0 commit comments

Comments
 (0)