Skip to content

Commit 3c77f3c

Browse files
authored
Add Waterrower USB connection Support (Issue #3059) (#3068)
* library added * Update build.gradle * Update build.gradle * Update build.gradle * adding most of the code * Update WaterRowerBridge.java * Update WaterRowerBridge.java * fix logging * Update device_filter.xml * Refactor WaterRower USB connection initialization Replaces auto-discovery with explicit device path search and connection for WaterRower USB devices. Updates Android and C++ bridge code to first locate the device, then connect using its path, improving reliability and error handling. * Retry WaterRower USB device detection periodically Android's UsbManager.getDeviceList() can return no devices at app startup if enumeration hasn't finished yet, so the single detection attempt at thread start silently failed forever (see debug log from issue #3059, comment 4858695955: single "device not found" line, no retries). Poll every 5s while not connected, broaden the vendor/product match, and also check the USB_DEVICE_ATTACHED intent as a fallback. * Fix WaterRower USB connection using usb-serial-for-android instead of jSerialComm jSerialComm can't open Android's raw USB device paths (/dev/bus/usb/X/Y) since CDC-ACM devices like the WaterRower S4/S5 USB monitor aren't exposed as kernel tty nodes on unrooted Android, causing "Unable to create a serial port object from the invalid port descriptor" errors reported in issue #3059. WaterRowerBridge now opens the device via usb-serial-for-android's CdcAcmSerialDriver (with permission handling) and hands the already-open UsbSerialPort to SerialChannel, which bridges it to the netty-based WaterRower transport via InputStream/OutputStream wrappers instead of jSerialComm. * Fix WaterRower stroke rate always reporting 0 lastStrokeRate was never assigned anywhere in WaterRowerBridge.java, only initialized to 0. Subscribe to AverageStrokeRateSubscription (already provided by waterrower-core) to actually populate it, so Cadence is reported to QZ instead of staying stuck at 0 while distance/watts/pace were updating fine. * Fix WaterRower USB workout timer never advancing waterrowerusb::update() never called update_metrics(), the base bluetoothdevice function that advances the session's elapsed time, moving time and other derived stats. Debug logs showed elapsed_s stuck at 0 for an entire ~10 minute session while distance/calories kept climbing in the background, which is almost certainly why the workout looked "dead" in the app even though data was technically being received. Also add the missing virtual bike/rower creation and connectedAndDiscovered() emission in update(), matching the pattern used by other non-BLE serial rowers (e.g. csaferower), since the WaterRower USB device previously never created a virtual device or triggered the accessory/heart-rate-belt wiring done on first connection. * Fix WaterRower USB device lookup * Fix WaterRower USB distance and watts * Fix WaterRower USB stroke count and ANT distance * Normalize ANT distance for WaterRower USB * Feed ANT speed channel distance from QZ * Skip wizard for WaterRower USB setting * Fix ANT FE rower broadcast data * Smooth WaterRower USB speed * Fix WaterRower USB startup lifecycle * Fix indentation and reorder property declarations * Remove keepalive for iOS lockscreen Removed keepalive setting for lockscreen on iOS. * Update main.yml
1 parent bbee913 commit 3c77f3c

116 files changed

Lines changed: 8943 additions & 40 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/android/build.gradle

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ def qtAndroidJavaSrcDir = qtAndroidJavaRootDir ? qtAndroidJavaRootDir + '/src' :
3333
def qtAndroidJavaResDir = qtAndroidJavaRootDir ? qtAndroidJavaRootDir + '/res' : 'qt-android-missing/src/android/java/res'
3434

3535
dependencies {
36+
implementation 'io.netty:netty-all:4.1.100.Final'
37+
implementation 'com.google.guava:guava:32.1.3-android'
38+
implementation 'com.fazecast:jSerialComm:2.10.4'
39+
implementation 'org.slf4j:slf4j-api:2.0.9'
40+
implementation 'org.slf4j:slf4j-simple:2.0.9'
3641
if (qtAndroidJarDir) {
3742
implementation fileTree(dir: qtAndroidJarDir, include: ['*.jar'])
3843
}
@@ -161,6 +166,21 @@ android {
161166
// Qt 5.15's QtLoader.java finds libraries via nativeLibraryDir on the
162167
// filesystem, not from inside the APK, so we must force extraction.
163168
packaging {
169+
resources {
170+
excludes += [
171+
'META-INF/io.netty.versions.properties',
172+
'META-INF/INDEX.LIST',
173+
'META-INF/DEPENDENCIES',
174+
'META-INF/LICENSE',
175+
'META-INF/LICENSE.txt',
176+
'META-INF/license.txt',
177+
'META-INF/NOTICE',
178+
'META-INF/NOTICE.txt',
179+
'META-INF/notice.txt',
180+
'META-INF/ASL2.0',
181+
'META-INF/*.kotlin_module'
182+
]
183+
}
164184
jniLibs {
165185
useLegacyPackaging = true
166186
}

src/android/res/xml/device_filter.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
<usb-device vendor-id="17A4" product-id="0002" />
3030
<usb-device vendor-id="17A4" product-id="0001" />
3131

32+
<!-- WaterRower devices - Microchip Technology Inc. -->
33+
<usb-device vendor-id="1240" product-id="10" /> <!-- 0x04D8 / 0x000A: MCP2200 USB-to-UART -->
34+
<usb-device vendor-id="1240" product-id="223" /> <!-- 0x04D8 / 0x00DF: WaterRower Custom Application -->
35+
<usb-device vendor-id="1240" /> <!-- 0x04D8 / ......: All Microchip Technology devices -->
36+
3237
<!-- CDC driver -->
3338
<usb-device vendor-id="9025" /> <!-- 0x2341 / ......: Arduino -->
3439
<usb-device vendor-id="5824" product-id="1155" /> <!-- 0x16C0 / 0x0483: Teensyduino -->

src/android/src/Ant.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,12 @@ public boolean isBikeConnected() {
161161

162162
public void updateBikeTransmitterExtendedMetrics(long distanceMeters, int heartRate,
163163
double elapsedTimeSeconds, int resistance,
164-
double inclination) {
164+
double inclination, int equipmentType, int strokeCount) {
165165
if(mChannelService == null)
166166
return;
167167
QLog.v(TAG, "updateBikeTransmitterExtendedMetrics");
168168
mChannelService.updateBikeTransmitterExtendedMetrics(distanceMeters, heartRate,
169169
elapsedTimeSeconds, resistance,
170-
inclination);
170+
inclination, equipmentType, strokeCount);
171171
}
172172
}

src/android/src/BikeTransmitterController.java

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,14 @@ public class BikeTransmitterController {
5555
private static final byte DATA_PAGE_GENERAL_FE = 0x10;
5656
private static final byte DATA_PAGE_BIKE_DATA = 0x19;
5757
private static final byte DATA_PAGE_TRAINER_DATA = 0x1A;
58+
private static final byte DATA_PAGE_ROWER_DATA = 0x16;
5859
private static final byte DATA_PAGE_GENERAL_SETTINGS = 0x11;
60+
private static final int EQUIPMENT_TYPE_ROWER = 0x16;
61+
private static final int EQUIPMENT_TYPE_TRAINER = 0x19;
62+
private static final int FE_STATE_READY = 0x02;
63+
private static final int FE_STATE_IN_USE = 0x03;
64+
private static final int PAGE16_CAP_DISTANCE_ENABLED = 0x04;
65+
private static final int PAGE22_CAP_STROKE_COUNT_ENABLED = 0x01;
5966

6067
private static Random randGen = new Random();
6168

@@ -64,6 +71,8 @@ public class BikeTransmitterController {
6471
int currentPower = 0; // Current power in watts
6572
double currentSpeedKph = 0.0; // Current speed in km/h
6673
long totalDistance = 0; // Total distance in meters
74+
int equipmentType = EQUIPMENT_TYPE_TRAINER; // ANT+ FE equipment type
75+
int strokeCount = 0; // Accumulated rower stroke count
6776
int currentHeartRate = 0; // Heart rate in BPM
6877
double elapsedTimeSeconds = 0.0; // Elapsed time in seconds
6978
int currentResistance = 0; // Current resistance level (0-100)
@@ -210,6 +219,14 @@ public void setDistance(long distance) {
210219
this.totalDistance = Math.max(0, distance);
211220
}
212221

222+
public void setEquipmentType(int equipmentType) {
223+
this.equipmentType = Math.max(0, Math.min(255, equipmentType));
224+
}
225+
226+
public void setStrokeCount(int strokeCount) {
227+
this.strokeCount = Math.max(0, strokeCount);
228+
}
229+
213230
public void setHeartRate(int heartRate) {
214231
this.currentHeartRate = Math.max(0, Math.min(255, heartRate));
215232
}
@@ -255,9 +272,9 @@ public String getTransmissionInfo() {
255272
}
256273

257274
return String.format("Transmission: ACTIVE - Cadence: %drpm, Power: %dW, " +
258-
"Speed: %.1fkm/h, Resistance: %d, Inclination: %.1f%%",
275+
"Speed: %.1fkm/h, Resistance: %d, Inclination: %.1f%%, Equipment: 0x%02X",
259276
currentCadence, currentPower, currentSpeedKph,
260-
currentResistance, currentInclination);
277+
currentResistance, currentInclination, equipmentType);
261278
}
262279

263280
/**
@@ -311,20 +328,13 @@ public void run() {
311328
cnt += 1;
312329

313330
// Cycle through different data pages like PowerChannelController
314-
if (cnt % 5 == 0) {
315-
// General FE Data Page (0x10)
331+
if (cnt % 5 == 0) {
316332
debugString = buildGeneralFEDataPage(payload);
317-
} else if (cnt % 5 == 1) {
318-
// Bike Data Page (0x19)
319-
debugString = buildBikeDataPage(payload);
320-
} else if (cnt % 5 == 2) {
321-
// Trainer Data Page (0x1A)
322-
debugString = buildBikeDataPage(payload);
333+
} else if (cnt % 5 == 1 || cnt % 5 == 2) {
334+
debugString = buildEquipmentSpecificDataPage(payload);
323335
} else if (cnt % 5 == 3) {
324-
// General Settings Page (0x11)
325336
debugString = buildGeneralSettingsPage(payload);
326337
} else {
327-
// Default General FE Data Page (0x10)
328338
debugString = buildGeneralFEDataPage(payload);
329339
}
330340

@@ -367,21 +377,15 @@ public void run() {
367377
cnt += 1;
368378
String debugString = "";
369379

370-
// Cycle through different data pages like PowerChannelController
371380
if (cnt % 16 == 1) {
372-
// General FE Data Page (0x10)
373381
debugString = buildGeneralFEDataPage(payload);
374382
} else if (cnt % 16 == 5) {
375-
// Bike Data Page (0x19)
376-
debugString = buildBikeDataPage(payload);
383+
debugString = buildEquipmentSpecificDataPage(payload);
377384
} else if (cnt % 16 == 9) {
378-
// Trainer Data Page (0x1A)
379-
debugString = buildBikeDataPage(payload);
385+
debugString = buildEquipmentSpecificDataPage(payload);
380386
} else if (cnt % 16 == 13) {
381-
// General Settings Page (0x11)
382387
debugString = buildGeneralSettingsPage(payload);
383388
} else {
384-
// Default General FE Data Page (0x10)
385389
debugString = buildGeneralFEDataPage(payload);
386390
}
387391

@@ -439,7 +443,7 @@ private String buildGeneralFEDataPage(byte[] payload) {
439443
payload[0] = 0x10; // Data Page Number = 0x10 (Page 16)
440444

441445
// Byte 1: Equipment Type Bit Field (Refer to Table 8-8)
442-
payload[1] = 0x19; // Equipment type: Bike (stationary bike = 0x19)
446+
payload[1] = (byte) equipmentType;
443447

444448
// Byte 2: Elapsed Time (0.25 seconds resolution, rollover at 64s)
445449
int elapsedTime025s = (int) (elapsedTimeSeconds * 4) & 0xFF;
@@ -458,13 +462,13 @@ private String buildGeneralFEDataPage(byte[] payload) {
458462
// Byte 6: Heart Rate (0xFF = invalid)
459463
payload[6] = (byte) (currentHeartRate == 0 ? 0xFF : currentHeartRate);
460464

461-
// Byte 7: Capabilities Bit Field (4 bits 0:3) + FE State Bit Field (4 bits 4:7)
462-
payload[7] = 0x00; // Set to 0x00 for now (refer to Tables 8-9 and 8-10)
465+
// Byte 7: Page 16 capabilities (bits 0:3) + FE State (bits 4:7)
466+
payload[7] = (byte) (PAGE16_CAP_DISTANCE_ENABLED | feStateNibble());
463467

464468
// Create debug string
465469
return String.format(Locale.US,
466470
"General FE Data Page (0x10): " +
467-
"Page=0x%02X, Equipment=0x%02X(Bike), " +
471+
"Page=0x%02X, Equipment=0x%02X, " +
468472
"ElapsedTime=0x%02X(%.1fs), Distance=0x%02X(%dm), " +
469473
"Speed=0x%02X%02X(%.1fkm/h), HeartRate=0x%02X(%s), " +
470474
"Capabilities=0x%02X",
@@ -482,6 +486,13 @@ private String buildGeneralFEDataPage(byte[] payload) {
482486
* @param payload byte array to populate
483487
* @return debug string with hex and parsed values
484488
*/
489+
private String buildEquipmentSpecificDataPage(byte[] payload) {
490+
if (equipmentType == EQUIPMENT_TYPE_ROWER) {
491+
return buildRowerDataPage(payload);
492+
}
493+
return buildBikeDataPage(payload);
494+
}
495+
485496
private String buildBikeDataPage(byte[] payload) {
486497
payload[0] = 0x19; // Data Page Number = 0x19 (Page 25)
487498

@@ -511,7 +522,7 @@ private String buildBikeDataPage(byte[] payload) {
511522
}
512523

513524
// Byte 7: Flags Bit Field (bits 0-3) + FE State Bit Field (bits 4-7)
514-
payload[7] = 0x00; // Set to 0x00 for now
525+
payload[7] = (byte) feStateNibble();
515526

516527
// Create debug string
517528
String cadenceStr = currentCadence == 0 ? "Invalid" : currentCadence + "rpm";
@@ -528,6 +539,41 @@ private String buildBikeDataPage(byte[] payload) {
528539
(payload[6] & 0x0F), payload[5] & 0xFF, powerStr,
529540
payload[7] & 0xFF);
530541
}
542+
543+
/**
544+
* Build Specific Rower Data Page (0x16) - Page 22.
545+
*/
546+
private String buildRowerDataPage(byte[] payload) {
547+
payload[0] = DATA_PAGE_ROWER_DATA;
548+
payload[1] = (byte) 0xFF;
549+
payload[2] = (byte) 0xFF;
550+
payload[3] = (byte) (strokeCount & 0xFF);
551+
payload[4] = (byte) (currentCadence == 0 ? 0xFF : currentCadence);
552+
553+
int rowerPower = currentPower;
554+
if (rowerPower > 65534) {
555+
payload[5] = (byte) 0xFF;
556+
payload[6] = (byte) 0xFF;
557+
} else {
558+
payload[5] = (byte) (rowerPower & 0xFF);
559+
payload[6] = (byte) ((rowerPower >> 8) & 0xFF);
560+
}
561+
562+
payload[7] = (byte) (PAGE22_CAP_STROKE_COUNT_ENABLED | feStateNibble());
563+
564+
String cadenceStr = currentCadence == 0 ? "Invalid" : currentCadence + "spm";
565+
String powerStr = rowerPower > 65534 ? "Invalid" : rowerPower + "W";
566+
567+
return String.format(Locale.US,
568+
"Rower Data Page (0x16): " +
569+
"Page=0x%02X, StrokeCount=0x%02X(%d), " +
570+
"Cadence=0x%02X(%s), Power=0x%02X%02X(%s), Capabilities=0x%02X",
571+
payload[0] & 0xFF,
572+
payload[3] & 0xFF, strokeCount,
573+
payload[4] & 0xFF, cadenceStr,
574+
payload[6] & 0xFF, payload[5] & 0xFF, powerStr,
575+
payload[7] & 0xFF);
576+
}
531577

532578
/**
533579
* Build General Settings Page (0x11) - Page 17
@@ -562,7 +608,7 @@ private String buildGeneralSettingsPage(byte[] payload) {
562608
payload[6] = (byte) (resistanceLevel05 & 0xFF);
563609

564610
// Byte 7: Capabilities Bit Field (bits 0-3) + FE State Bit Field (bits 4-7)
565-
payload[7] = 0x00; // Set to 0x00 for now
611+
payload[7] = (byte) feStateNibble();
566612

567613
// Create debug string
568614
return String.format(Locale.US,
@@ -576,6 +622,13 @@ private String buildGeneralSettingsPage(byte[] payload) {
576622
payload[6] & 0xFF, currentResistance,
577623
payload[7] & 0xFF);
578624
}
625+
626+
private int feStateNibble() {
627+
int state = (elapsedTimeSeconds > 0.0 || currentSpeedKph > 0.0 || currentPower > 0 || currentCadence > 0)
628+
? FE_STATE_IN_USE
629+
: FE_STATE_READY;
630+
return (state & 0x0F) << 4;
631+
}
579632

580633
/**
581634
* Handle incoming control commands
@@ -648,4 +701,4 @@ private void handleTrackResistanceCommand(byte[] data) {
648701
}
649702
}
650703
}
651-
}
704+
}

src/android/src/ChannelService.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,15 @@ boolean isBikeTransmitterActive() {
251251
*/
252252
void updateBikeTransmitterExtendedMetrics(long distanceMeters, int heartRate,
253253
double elapsedTimeSeconds, int resistance,
254-
double inclination) {
254+
double inclination, int equipmentType, int strokeCount) {
255255
if (!Ant.treadmill && bikeTransmitterController != null) {
256256
bikeTransmitterController.setDistance(distanceMeters);
257257
bikeTransmitterController.setHeartRate(heartRate);
258258
bikeTransmitterController.setElapsedTime(elapsedTimeSeconds);
259259
bikeTransmitterController.setResistance(resistance);
260260
bikeTransmitterController.setInclination(inclination);
261+
bikeTransmitterController.setEquipmentType(equipmentType);
262+
bikeTransmitterController.setStrokeCount(strokeCount);
261263
}
262264
}
263265

0 commit comments

Comments
 (0)