diff --git a/src/android/build.gradle b/src/android/build.gradle index 2f5c9fb160..40ad1b1314 100644 --- a/src/android/build.gradle +++ b/src/android/build.gradle @@ -33,6 +33,11 @@ def qtAndroidJavaSrcDir = qtAndroidJavaRootDir ? qtAndroidJavaRootDir + '/src' : def qtAndroidJavaResDir = qtAndroidJavaRootDir ? qtAndroidJavaRootDir + '/res' : 'qt-android-missing/src/android/java/res' dependencies { + implementation 'io.netty:netty-all:4.1.100.Final' + implementation 'com.google.guava:guava:32.1.3-android' + implementation 'com.fazecast:jSerialComm:2.10.4' + implementation 'org.slf4j:slf4j-api:2.0.9' + implementation 'org.slf4j:slf4j-simple:2.0.9' if (qtAndroidJarDir) { implementation fileTree(dir: qtAndroidJarDir, include: ['*.jar']) } @@ -161,6 +166,21 @@ android { // Qt 5.15's QtLoader.java finds libraries via nativeLibraryDir on the // filesystem, not from inside the APK, so we must force extraction. packaging { + resources { + excludes += [ + 'META-INF/io.netty.versions.properties', + 'META-INF/INDEX.LIST', + 'META-INF/DEPENDENCIES', + 'META-INF/LICENSE', + 'META-INF/LICENSE.txt', + 'META-INF/license.txt', + 'META-INF/NOTICE', + 'META-INF/NOTICE.txt', + 'META-INF/notice.txt', + 'META-INF/ASL2.0', + 'META-INF/*.kotlin_module' + ] + } jniLibs { useLegacyPackaging = true } diff --git a/src/android/res/xml/device_filter.xml b/src/android/res/xml/device_filter.xml index 64c8b6de22..38bcca5089 100644 --- a/src/android/res/xml/device_filter.xml +++ b/src/android/res/xml/device_filter.xml @@ -29,6 +29,11 @@ + + + + + diff --git a/src/android/src/Ant.java b/src/android/src/Ant.java index 4e30b28b06..5c1204718c 100644 --- a/src/android/src/Ant.java +++ b/src/android/src/Ant.java @@ -161,12 +161,12 @@ public boolean isBikeConnected() { public void updateBikeTransmitterExtendedMetrics(long distanceMeters, int heartRate, double elapsedTimeSeconds, int resistance, - double inclination) { + double inclination, int equipmentType, int strokeCount) { if(mChannelService == null) return; QLog.v(TAG, "updateBikeTransmitterExtendedMetrics"); mChannelService.updateBikeTransmitterExtendedMetrics(distanceMeters, heartRate, elapsedTimeSeconds, resistance, - inclination); + inclination, equipmentType, strokeCount); } } diff --git a/src/android/src/BikeTransmitterController.java b/src/android/src/BikeTransmitterController.java index e8604676a3..01f0d17c15 100644 --- a/src/android/src/BikeTransmitterController.java +++ b/src/android/src/BikeTransmitterController.java @@ -55,7 +55,14 @@ public class BikeTransmitterController { private static final byte DATA_PAGE_GENERAL_FE = 0x10; private static final byte DATA_PAGE_BIKE_DATA = 0x19; private static final byte DATA_PAGE_TRAINER_DATA = 0x1A; + private static final byte DATA_PAGE_ROWER_DATA = 0x16; private static final byte DATA_PAGE_GENERAL_SETTINGS = 0x11; + private static final int EQUIPMENT_TYPE_ROWER = 0x16; + private static final int EQUIPMENT_TYPE_TRAINER = 0x19; + private static final int FE_STATE_READY = 0x02; + private static final int FE_STATE_IN_USE = 0x03; + private static final int PAGE16_CAP_DISTANCE_ENABLED = 0x04; + private static final int PAGE22_CAP_STROKE_COUNT_ENABLED = 0x01; private static Random randGen = new Random(); @@ -64,6 +71,8 @@ public class BikeTransmitterController { int currentPower = 0; // Current power in watts double currentSpeedKph = 0.0; // Current speed in km/h long totalDistance = 0; // Total distance in meters + int equipmentType = EQUIPMENT_TYPE_TRAINER; // ANT+ FE equipment type + int strokeCount = 0; // Accumulated rower stroke count int currentHeartRate = 0; // Heart rate in BPM double elapsedTimeSeconds = 0.0; // Elapsed time in seconds int currentResistance = 0; // Current resistance level (0-100) @@ -210,6 +219,14 @@ public void setDistance(long distance) { this.totalDistance = Math.max(0, distance); } + public void setEquipmentType(int equipmentType) { + this.equipmentType = Math.max(0, Math.min(255, equipmentType)); + } + + public void setStrokeCount(int strokeCount) { + this.strokeCount = Math.max(0, strokeCount); + } + public void setHeartRate(int heartRate) { this.currentHeartRate = Math.max(0, Math.min(255, heartRate)); } @@ -255,9 +272,9 @@ public String getTransmissionInfo() { } return String.format("Transmission: ACTIVE - Cadence: %drpm, Power: %dW, " + - "Speed: %.1fkm/h, Resistance: %d, Inclination: %.1f%%", + "Speed: %.1fkm/h, Resistance: %d, Inclination: %.1f%%, Equipment: 0x%02X", currentCadence, currentPower, currentSpeedKph, - currentResistance, currentInclination); + currentResistance, currentInclination, equipmentType); } /** @@ -311,20 +328,13 @@ public void run() { cnt += 1; // Cycle through different data pages like PowerChannelController - if (cnt % 5 == 0) { - // General FE Data Page (0x10) + if (cnt % 5 == 0) { debugString = buildGeneralFEDataPage(payload); - } else if (cnt % 5 == 1) { - // Bike Data Page (0x19) - debugString = buildBikeDataPage(payload); - } else if (cnt % 5 == 2) { - // Trainer Data Page (0x1A) - debugString = buildBikeDataPage(payload); + } else if (cnt % 5 == 1 || cnt % 5 == 2) { + debugString = buildEquipmentSpecificDataPage(payload); } else if (cnt % 5 == 3) { - // General Settings Page (0x11) debugString = buildGeneralSettingsPage(payload); } else { - // Default General FE Data Page (0x10) debugString = buildGeneralFEDataPage(payload); } @@ -367,21 +377,15 @@ public void run() { cnt += 1; String debugString = ""; - // Cycle through different data pages like PowerChannelController if (cnt % 16 == 1) { - // General FE Data Page (0x10) debugString = buildGeneralFEDataPage(payload); } else if (cnt % 16 == 5) { - // Bike Data Page (0x19) - debugString = buildBikeDataPage(payload); + debugString = buildEquipmentSpecificDataPage(payload); } else if (cnt % 16 == 9) { - // Trainer Data Page (0x1A) - debugString = buildBikeDataPage(payload); + debugString = buildEquipmentSpecificDataPage(payload); } else if (cnt % 16 == 13) { - // General Settings Page (0x11) debugString = buildGeneralSettingsPage(payload); } else { - // Default General FE Data Page (0x10) debugString = buildGeneralFEDataPage(payload); } @@ -439,7 +443,7 @@ private String buildGeneralFEDataPage(byte[] payload) { payload[0] = 0x10; // Data Page Number = 0x10 (Page 16) // Byte 1: Equipment Type Bit Field (Refer to Table 8-8) - payload[1] = 0x19; // Equipment type: Bike (stationary bike = 0x19) + payload[1] = (byte) equipmentType; // Byte 2: Elapsed Time (0.25 seconds resolution, rollover at 64s) int elapsedTime025s = (int) (elapsedTimeSeconds * 4) & 0xFF; @@ -458,13 +462,13 @@ private String buildGeneralFEDataPage(byte[] payload) { // Byte 6: Heart Rate (0xFF = invalid) payload[6] = (byte) (currentHeartRate == 0 ? 0xFF : currentHeartRate); - // Byte 7: Capabilities Bit Field (4 bits 0:3) + FE State Bit Field (4 bits 4:7) - payload[7] = 0x00; // Set to 0x00 for now (refer to Tables 8-9 and 8-10) + // Byte 7: Page 16 capabilities (bits 0:3) + FE State (bits 4:7) + payload[7] = (byte) (PAGE16_CAP_DISTANCE_ENABLED | feStateNibble()); // Create debug string return String.format(Locale.US, "General FE Data Page (0x10): " + - "Page=0x%02X, Equipment=0x%02X(Bike), " + + "Page=0x%02X, Equipment=0x%02X, " + "ElapsedTime=0x%02X(%.1fs), Distance=0x%02X(%dm), " + "Speed=0x%02X%02X(%.1fkm/h), HeartRate=0x%02X(%s), " + "Capabilities=0x%02X", @@ -482,6 +486,13 @@ private String buildGeneralFEDataPage(byte[] payload) { * @param payload byte array to populate * @return debug string with hex and parsed values */ + private String buildEquipmentSpecificDataPage(byte[] payload) { + if (equipmentType == EQUIPMENT_TYPE_ROWER) { + return buildRowerDataPage(payload); + } + return buildBikeDataPage(payload); + } + private String buildBikeDataPage(byte[] payload) { payload[0] = 0x19; // Data Page Number = 0x19 (Page 25) @@ -511,7 +522,7 @@ private String buildBikeDataPage(byte[] payload) { } // Byte 7: Flags Bit Field (bits 0-3) + FE State Bit Field (bits 4-7) - payload[7] = 0x00; // Set to 0x00 for now + payload[7] = (byte) feStateNibble(); // Create debug string String cadenceStr = currentCadence == 0 ? "Invalid" : currentCadence + "rpm"; @@ -528,6 +539,41 @@ private String buildBikeDataPage(byte[] payload) { (payload[6] & 0x0F), payload[5] & 0xFF, powerStr, payload[7] & 0xFF); } + + /** + * Build Specific Rower Data Page (0x16) - Page 22. + */ + private String buildRowerDataPage(byte[] payload) { + payload[0] = DATA_PAGE_ROWER_DATA; + payload[1] = (byte) 0xFF; + payload[2] = (byte) 0xFF; + payload[3] = (byte) (strokeCount & 0xFF); + payload[4] = (byte) (currentCadence == 0 ? 0xFF : currentCadence); + + int rowerPower = currentPower; + if (rowerPower > 65534) { + payload[5] = (byte) 0xFF; + payload[6] = (byte) 0xFF; + } else { + payload[5] = (byte) (rowerPower & 0xFF); + payload[6] = (byte) ((rowerPower >> 8) & 0xFF); + } + + payload[7] = (byte) (PAGE22_CAP_STROKE_COUNT_ENABLED | feStateNibble()); + + String cadenceStr = currentCadence == 0 ? "Invalid" : currentCadence + "spm"; + String powerStr = rowerPower > 65534 ? "Invalid" : rowerPower + "W"; + + return String.format(Locale.US, + "Rower Data Page (0x16): " + + "Page=0x%02X, StrokeCount=0x%02X(%d), " + + "Cadence=0x%02X(%s), Power=0x%02X%02X(%s), Capabilities=0x%02X", + payload[0] & 0xFF, + payload[3] & 0xFF, strokeCount, + payload[4] & 0xFF, cadenceStr, + payload[6] & 0xFF, payload[5] & 0xFF, powerStr, + payload[7] & 0xFF); + } /** * Build General Settings Page (0x11) - Page 17 @@ -562,7 +608,7 @@ private String buildGeneralSettingsPage(byte[] payload) { payload[6] = (byte) (resistanceLevel05 & 0xFF); // Byte 7: Capabilities Bit Field (bits 0-3) + FE State Bit Field (bits 4-7) - payload[7] = 0x00; // Set to 0x00 for now + payload[7] = (byte) feStateNibble(); // Create debug string return String.format(Locale.US, @@ -576,6 +622,13 @@ private String buildGeneralSettingsPage(byte[] payload) { payload[6] & 0xFF, currentResistance, payload[7] & 0xFF); } + + private int feStateNibble() { + int state = (elapsedTimeSeconds > 0.0 || currentSpeedKph > 0.0 || currentPower > 0 || currentCadence > 0) + ? FE_STATE_IN_USE + : FE_STATE_READY; + return (state & 0x0F) << 4; + } /** * Handle incoming control commands @@ -648,4 +701,4 @@ private void handleTrackResistanceCommand(byte[] data) { } } } -} \ No newline at end of file +} diff --git a/src/android/src/ChannelService.java b/src/android/src/ChannelService.java index 6edf0352c0..cd12ed8285 100644 --- a/src/android/src/ChannelService.java +++ b/src/android/src/ChannelService.java @@ -251,13 +251,15 @@ boolean isBikeTransmitterActive() { */ void updateBikeTransmitterExtendedMetrics(long distanceMeters, int heartRate, double elapsedTimeSeconds, int resistance, - double inclination) { + double inclination, int equipmentType, int strokeCount) { if (!Ant.treadmill && bikeTransmitterController != null) { bikeTransmitterController.setDistance(distanceMeters); bikeTransmitterController.setHeartRate(heartRate); bikeTransmitterController.setElapsedTime(elapsedTimeSeconds); bikeTransmitterController.setResistance(resistance); bikeTransmitterController.setInclination(inclination); + bikeTransmitterController.setEquipmentType(equipmentType); + bikeTransmitterController.setStrokeCount(strokeCount); } } diff --git a/src/android/src/WaterRowerBridge.java b/src/android/src/WaterRowerBridge.java new file mode 100644 index 0000000000..4f81da6694 --- /dev/null +++ b/src/android/src/WaterRowerBridge.java @@ -0,0 +1,381 @@ +package org.cagnulen.qdomyoszwift; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbManager; +import android.os.Build; +import androidx.core.content.ContextCompat; + +import com.hoho.android.usbserial.driver.CdcAcmSerialDriver; +import com.hoho.android.usbserial.driver.UsbSerialPort; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.concurrent.CountDownLatch; + +import de.tbressler.waterrower.WaterRower; +import de.tbressler.waterrower.IWaterRowerConnectionListener; +import de.tbressler.waterrower.discovery.WaterRowerAutoDiscovery; +import de.tbressler.waterrower.io.transport.SerialChannel; +import de.tbressler.waterrower.io.transport.SerialDeviceAddress; +import de.tbressler.waterrower.model.ErrorCode; +import de.tbressler.waterrower.model.ModelInformation; +import de.tbressler.waterrower.model.StrokeType; +import de.tbressler.waterrower.subscriptions.values.*; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +public class WaterRowerBridge { + private static final String TAG = "WaterRowerBridge"; + private static WaterRower waterRower; + private static boolean isConnected = false; + private static double lastStrokeRate = 0; + private static double lastDistance = 0; + private static double lastPace = 0; + private static double lastWatts = 0; + private static double lastCalories = 0; + private static double lastStrokeCount = 0; + private static long lastDataUpdate = 0; + + private static boolean isWaterRowerDevice(UsbDevice device) { + if (device == null || device.getVendorId() != 1240) { + return false; + } + + int productId = device.getProductId(); + if (productId == 10 || productId == 223) { + return true; + } + + String productName = device.getProductName(); + return productName != null && productName.toUpperCase().contains("WR-S"); + } + + private static String describeDevice(UsbDevice device) { + if (device == null) { + return "null"; + } + + return device.getDeviceName() + " vendor=" + device.getVendorId() + + " product=" + device.getProductId() + + " name=" + device.getProductName(); + } + + private static final IWaterRowerConnectionListener connectionListener = new IWaterRowerConnectionListener() { + @Override + public void onConnected(ModelInformation modelInformation) { + QLog.d(TAG, "WaterRower connected: " + modelInformation.getMonitorType()); + isConnected = true; + + // Subscribe to rowing metrics + // Subscribe to stroke events + waterRower.subscribe(new StrokeSubscription() { + @Override + protected void onStroke(StrokeType strokeType) { + lastDataUpdate = System.currentTimeMillis(); + QLog.d(TAG, "Stroke: " + strokeType); + } + }); + + // Subscribe to stroke rate + waterRower.subscribe(new AverageStrokeRateSubscription() { + @Override + protected void onStrokeRateUpdated(double strokeRate) { + lastStrokeRate = strokeRate; + lastDataUpdate = System.currentTimeMillis(); + QLog.d(TAG, "Stroke rate: " + strokeRate); + } + }); + + // Subscribe to stroke count + waterRower.subscribe(new StrokeCountSubscription() { + @Override + protected void onStrokeCountUpdated(int strokes) { + lastStrokeCount = strokes; + lastDataUpdate = System.currentTimeMillis(); + QLog.d(TAG, "Stroke count: " + strokes); + } + }); + + // Subscribe to distance + waterRower.subscribe(new DistanceSubscription() { + @Override + protected void onDistanceUpdated(double distance) { + lastDistance = distance; + lastDataUpdate = System.currentTimeMillis(); + QLog.d(TAG, "Distance: " + distance); + } + }); + + // Subscribe to total velocity (pace) + waterRower.subscribe(new TotalVelocitySubscription() { + @Override + protected void onVelocityUpdated(double velocity) { + if (velocity > 0) { + lastPace = 500.0 / velocity; // Convert to seconds per 500m + } + lastDataUpdate = System.currentTimeMillis(); + QLog.d(TAG, "Velocity: " + velocity + ", Pace: " + lastPace); + } + }); + + // Subscribe to watts + waterRower.subscribe(new WattsSubscription() { + @Override + protected void onWattsUpdated(int watts) { + lastWatts = watts; + lastDataUpdate = System.currentTimeMillis(); + QLog.d(TAG, "Watts: " + watts); + } + }); + + // Subscribe to calories + waterRower.subscribe(new TotalCaloriesSubscription() { + @Override + protected void onCaloriesUpdated(int calories) { + lastCalories = calories; + lastDataUpdate = System.currentTimeMillis(); + QLog.d(TAG, "Calories: " + calories); + } + }); + } + + @Override + public void onDisconnected() { + QLog.d(TAG, "WaterRower disconnected"); + isConnected = false; + } + + @Override + public void onError(ErrorCode errorCode) { + QLog.e(TAG, "WaterRower error: " + errorCode); + isConnected = false; + } + }; + + public static String getDevicePath(Context context) { + QLog.d(TAG, "getDevicePath: searching for WaterRower device"); + UsbManager manager = (UsbManager) context.getSystemService(Context.USB_SERVICE); + HashMap deviceList = manager.getDeviceList(); + QLog.d(TAG, "getDevicePath: UsbManager device count " + deviceList.size()); + Iterator deviceIterator = deviceList.values().iterator(); + while(deviceIterator.hasNext()){ + UsbDevice device = deviceIterator.next(); + QLog.d(TAG, "getDevicePath: found device " + describeDevice(device) + + " permission=" + manager.hasPermission(device)); + if (isWaterRowerDevice(device)) { + QLog.d(TAG, "getDevicePath: found WaterRower device from UsbManager at " + device.getDeviceName()); + return device.getDeviceName(); + } + } + + if (context instanceof Activity) { + Intent intent = ((Activity) context).getIntent(); + if (intent != null && UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(intent.getAction())) { + UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + QLog.d(TAG, "getDevicePath: USB attach intent device " + describeDevice(device) + + " permission=" + (device != null && manager.hasPermission(device))); + if (isWaterRowerDevice(device)) { + QLog.d(TAG, "getDevicePath: found WaterRower device from USB attach intent at " + device.getDeviceName()); + return device.getDeviceName(); + } + } else { + QLog.d(TAG, "getDevicePath: no USB attach intent available"); + } + } + + QLog.d(TAG, "getDevicePath: WaterRower device not found"); + return ""; + } + + /* Finds the attached WaterRower UsbDevice, or null if none is currently attached. */ + private static UsbDevice findWaterRowerDevice(Context context, UsbManager manager) { + HashMap deviceList = manager.getDeviceList(); + Iterator deviceIterator = deviceList.values().iterator(); + while (deviceIterator.hasNext()) { + UsbDevice device = deviceIterator.next(); + if (isWaterRowerDevice(device)) { + return device; + } + } + + if (context instanceof Activity) { + Intent intent = ((Activity) context).getIntent(); + if (intent != null && UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(intent.getAction())) { + UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + if (isWaterRowerDevice(device)) { + return device; + } + } + } + + return null; + } + + /* Requests runtime USB permission for the device and blocks (up to 5s) until the user + responds or the permission is already granted (e.g. via the USB_DEVICE_ATTACHED + intent-filter chooser). Returns true if permission is granted. */ + private static boolean requestUsbPermissionIfNeeded(Context context, UsbManager manager, UsbDevice device) { + if (manager.hasPermission(device)) { + return true; + } + + QLog.d(TAG, "requestUsbPermissionIfNeeded: requesting USB permission for " + describeDevice(device)); + + final CountDownLatch latch = new CountDownLatch(1); + final boolean[] granted = {false}; + String action = "org.cagnulen.qdomyoszwift.WATERROWER_USB_PERMISSION"; + BroadcastReceiver usbReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent intent) { + granted[0] = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false); + latch.countDown(); + } + }; + + int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0; + PendingIntent permissionIntent = PendingIntent.getBroadcast(context, 0, new Intent(action), flags); + IntentFilter filter = new IntentFilter(action); + ContextCompat.registerReceiver(context, usbReceiver, filter, ContextCompat.RECEIVER_EXPORTED); + + try { + manager.requestPermission(device, permissionIntent); + latch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + context.unregisterReceiver(usbReceiver); + } + + QLog.d(TAG, "requestUsbPermissionIfNeeded: permission granted=" + granted[0]); + return granted[0]; + } + + public static String connect(Context context, String devicePath) { + QLog.d(TAG, "Connecting to WaterRower at " + devicePath); + + if (waterRower != null) { + shutdown(); + } + + try { + UsbManager manager = (UsbManager) context.getSystemService(Context.USB_SERVICE); + UsbDevice device = findWaterRowerDevice(context, manager); + if (device == null) { + QLog.e(TAG, "connect: WaterRower USB device not found"); + return "CONNECTION_FAILED: WaterRower USB device not found"; + } + + if (!requestUsbPermissionIfNeeded(context, manager, device)) { + QLog.e(TAG, "connect: USB permission denied for " + describeDevice(device)); + return "CONNECTION_FAILED: USB permission denied"; + } + + UsbDeviceConnection connection = manager.openDevice(device); + if (connection == null) { + QLog.e(TAG, "connect: could not open USB device connection"); + return "CONNECTION_FAILED: Could not open USB device connection"; + } + + // The WaterRower S4/S5 USB monitor exposes a standard USB CDC-ACM interface, but is + // not in usb-serial-for-android's default probe table (it's identified by generic + // Microchip CDC-ACM VID/PID), so the driver is created explicitly here rather than + // via UsbSerialProber. + UsbSerialPort port = new CdcAcmSerialDriver(device).getPorts().get(0); + port.open(connection); + port.setParameters(19200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE); + + // Hand the already-open port to the SerialChannel that WaterRower.connect() below + // will instantiate; jSerialComm can not open raw Android USB device paths directly. + SerialChannel.setUsbSerialPort(port); + + waterRower = new WaterRower(); + waterRower.addConnectionListener(connectionListener); + + SerialDeviceAddress address = new SerialDeviceAddress(devicePath); + waterRower.connect(address); + + QLog.d(TAG, "Connection attempt returned SUCCESS"); + return "SUCCESS"; + + } catch (Exception e) { + SerialChannel.setUsbSerialPort(null); + QLog.e(TAG, "Failed to connect to WaterRower", e); + return "CONNECTION_FAILED: " + e.getMessage(); + } + } + + public static void shutdown() { + QLog.d(TAG, "Shutting down WaterRower connection"); + + if (waterRower != null) { + try { + waterRower.disconnect(); + } catch (IOException e) { + QLog.e(TAG, "Error disconnecting WaterRower", e); + } + waterRower = null; + } + + isConnected = false; + lastStrokeRate = 0; + lastDistance = 0; + lastPace = 0; + lastWatts = 0; + lastCalories = 0; + lastStrokeCount = 0; + lastDataUpdate = 0; + } + + public static boolean isConnected() { + return isConnected; + } + + public static String getStrokeData() { + if (!isConnected || waterRower == null) { + return "NO_DATA"; + } + + // Check if data is recent (within last 5 seconds) + long currentTime = System.currentTimeMillis(); + if (currentTime - lastDataUpdate > 5000) { + return "NO_DATA"; + } + + // Return data in CSV format: strokeRate,distance,pace,watts,calories,strokeCount + return String.format("%.1f,%.1f,%.2f,%.1f,%.1f,%.1f", + lastStrokeRate, lastDistance, lastPace, lastWatts, lastCalories, lastStrokeCount); + } + + public static double getStrokeRate() { + return lastStrokeRate; + } + + public static double getDistance() { + return lastDistance; + } + + public static double getPace() { + return lastPace; + } + + public static double getWatts() { + return lastWatts; + } + + public static double getCalories() { + return lastCalories; + } + + public static double getStrokeCount() { + return lastStrokeCount; + } +} diff --git a/src/android/src/de/tbressler/waterrower/IWaterRowerConnectionListener.java b/src/android/src/de/tbressler/waterrower/IWaterRowerConnectionListener.java new file mode 100644 index 0000000000..c88102c768 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/IWaterRowerConnectionListener.java @@ -0,0 +1,33 @@ +package de.tbressler.waterrower; + +import de.tbressler.waterrower.model.ErrorCode; +import de.tbressler.waterrower.model.ModelInformation; + +/** + * Listener interface for the connection to the WaterRower monitor. + * + * @author Tobias Bressler + * @version 1.0 + */ +public interface IWaterRowerConnectionListener { + + /** + * Will be called, if a supported WaterRower monitor was connected. + * + * @param modelInformation Model information (e.g. monitor type and firmware version). + */ + void onConnected(ModelInformation modelInformation); + + /** + * Will be called, if the WaterRower monitor was disconnected. + */ + void onDisconnected(); + + /** + * Will be called, if an error occurred while communicating with WaterRower monitor. + * + * @param errorCode The error code. + */ + void onError(ErrorCode errorCode); + +} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/WaterRower.java b/src/android/src/de/tbressler/waterrower/WaterRower.java new file mode 100644 index 0000000000..f62ceb45c0 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/WaterRower.java @@ -0,0 +1,493 @@ +package de.tbressler.waterrower; + +import de.tbressler.waterrower.io.ConnectionListener; +import de.tbressler.waterrower.io.IConnectionListener; +import de.tbressler.waterrower.io.WaterRowerConnector; +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.in.ErrorMessage; +import de.tbressler.waterrower.io.msg.in.HardwareTypeMessage; +import de.tbressler.waterrower.io.msg.in.ModelInformationMessage; +import de.tbressler.waterrower.io.msg.out.*; +import de.tbressler.waterrower.io.transport.SerialDeviceAddress; +import de.tbressler.waterrower.log.Log; +import de.tbressler.waterrower.model.ErrorCode; +import de.tbressler.waterrower.model.ModelInformation; +import de.tbressler.waterrower.subscriptions.ISubscription; +import de.tbressler.waterrower.subscriptions.ISubscriptionPollingService; +import de.tbressler.waterrower.watchdog.DeviceVerificationWatchdog; +import de.tbressler.waterrower.watchdog.ITimeoutListener; +import de.tbressler.waterrower.watchdog.PingWatchdog; +import de.tbressler.waterrower.workout.Workout; +import de.tbressler.waterrower.workout.WorkoutInterval; +import de.tbressler.waterrower.workout.WorkoutUnit; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static de.tbressler.waterrower.io.msg.out.ConfigureWorkoutMessage.MessageType.*; +import static de.tbressler.waterrower.model.ErrorCode.*; +import static de.tbressler.waterrower.utils.Compatibility.isSupportedWaterRower; +import static de.tbressler.waterrower.watchdog.TimeoutReason.DEVICE_NOT_CONFIRMED_TIMEOUT; +import static java.util.Objects.requireNonNull; + +/** + * The entry point of the WaterRower library. + * + * This class connects with the WaterRower and exchanges the information between PC and + * WaterRower monitor. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class WaterRower { + + /* Handles the connection to the WaterRower. */ + private final WaterRowerConnector connector; + + /* Polls and handles subscriptions. */ + private final ISubscriptionPollingService subscriptionPollingService; + + /* Watchdog that checks if a ping is received periodically. */ + private final PingWatchdog pingWatchdog; + + /* Watchdog that checks if the device sends it's model information in order to verify + * compatibility with the library. */ + private final DeviceVerificationWatchdog deviceVerificationWatchdog; + + /* All listeners. */ + private final List listeners = new CopyOnWriteArrayList<>(); + + + /* The listener for the WaterRower connector. */ + private final IConnectionListener connectionListener = new ConnectionListener() { + + @Override + public void onConnected() { + handleOnConnect(); + } + + @Override + public void onMessageReceived(AbstractMessage msg) { + handleOnMessageReceived(msg); + } + + @Override + public void onDisconnected() { + handleOnDisconnected(); + } + + @Override + public void onError() { + fireOnError(COMMUNICATION_FAILED); + } + + }; + + + /* Listener for watchdog timeouts. */ + private final ITimeoutListener timeoutListener = reason -> { + try { + disconnect(); + } catch (IOException e) { + Log.error("Couldn't disconnect, due to errors!", e); + } finally { + fireOnError((reason == DEVICE_NOT_CONFIRMED_TIMEOUT) ? DEVICE_NOT_SUPPORTED : TIMEOUT); + } + }; + + + /** + * The entry point of the WaterRower library. + * + * This class connects with the WaterRower and exchanges the information between PC and + * WaterRower monitor. + */ + public WaterRower() { + this(new WaterRowerInitializer(Duration.ofSeconds(5), 5)); + } + + /** + * The entry point of the WaterRower library. + * + * This class connects with the WaterRower and exchanges the information between PC and + * WaterRower monitor. + * + * @param initializer The WaterRower initializer (for configuration), must not be null. + */ + public WaterRower(WaterRowerInitializer initializer) { + this(initializer.getWaterRowerConnector(), + initializer.getPingWatchdog(), + initializer.getDeviceVerificationWatchdog(), + initializer.getSubscriptionPollingService()); + } + + /** + * The entry point of the WaterRower library. + * + * This class connects with the WaterRower and exchanges the information between PC and + * WaterRower monitor. + * + * @param connector The connector to the WaterRower, must not be null. + * @param pingWatchdog The watchdog that checks if a ping is received periodically, must not + * be null. + * @param deviceVerificationWatchdog The watchdog that checks if the device sends it's model + * information in order to verify compatibility with the + * library. Must not be null. + * @param subscriptionPollingService The subscription polling service, which polls and + * handles the subscriptions. Must not be null. + */ + WaterRower(WaterRowerConnector connector, + PingWatchdog pingWatchdog, + DeviceVerificationWatchdog deviceVerificationWatchdog, + ISubscriptionPollingService subscriptionPollingService) { + + this.connector = requireNonNull(connector); + this.connector.addConnectionListener(connectionListener); + + this.pingWatchdog = pingWatchdog; + this.pingWatchdog.setTimeoutListener(timeoutListener); + + this.deviceVerificationWatchdog = deviceVerificationWatchdog; + this.deviceVerificationWatchdog.setTimeoutListener(timeoutListener); + + this.subscriptionPollingService = requireNonNull(subscriptionPollingService); + } + + + /** + * Connect to the rowing computer. + * + * @param address The serial port, must not be null. + * + * @throws IOException If connect fails. + */ + public void connect(SerialDeviceAddress address) throws IOException { + Log.debug("Connecting..."); + + if (connector.isConnected()) + throw new IOException("Already connected! Can not connect again."); + + connector.connect(requireNonNull(address)); + } + + + /* Handles the task after a successful connect. */ + private void handleOnConnect() { + try { + + Log.debug("Serial device connected. Sending 'start communication' message."); + + deviceVerificationWatchdog.start(); + + connector.send(new StartCommunicationMessage()); + + } catch (IOException e) { + Log.error("Couldn't send 'start communication' message!", e); + fireOnError(COMMUNICATION_FAILED); + } + } + + + /* Handles received messages. */ + private void handleOnMessageReceived(AbstractMessage msg) { + try { + + pingWatchdog.pingReceived(); + + handleLowLevelMessages(msg); + + } catch (IOException e) { + Log.error("A communication error occurred!", e); + fireOnError(COMMUNICATION_FAILED); + } + } + + /* Handles all the low level messages like ping, hardware type, ... */ + private void handleLowLevelMessages(AbstractMessage msg) throws IOException { + + if (msg instanceof HardwareTypeMessage) { + + if (((HardwareTypeMessage) msg).isWaterRower()) { + + Log.debug("Connected with WaterRower. Sending poll for model information."); + + connector.send(new RequestModelInformationMessage()); + + } else { + + Log.warn("The connected device is not a WaterRower!"); + + fireOnError(DEVICE_NOT_SUPPORTED); + } + + } else if (msg instanceof ModelInformationMessage) { + + ModelInformation modelInformation = ((ModelInformationMessage) msg).getModelInformation(); + + Log.debug("Received model information from connected WaterRower:\n" + + " Model: " + modelInformation); + + if (isSupportedWaterRower(modelInformation)) { + + Log.debug("Monitor type and firmware are supported by this library. Successfully connected with WaterRower."); + + // Set device model confirmed and stop watchdog: + deviceVerificationWatchdog.setDeviceConfirmed(true); + deviceVerificationWatchdog.stop(); + + // Start ping watchdog. + pingWatchdog.start(); + + // Start subscription polling service. + subscriptionPollingService.start(); + + fireOnConnected(modelInformation); + + } else { + + Log.warn("The monitor type and/or firmware of the connected WaterRower are not supported by this library!"); + + deviceVerificationWatchdog.setDeviceConfirmed(false); + deviceVerificationWatchdog.stop(); + + fireOnError(DEVICE_NOT_SUPPORTED); + } + + } else if (msg instanceof ErrorMessage) { + + Log.debug("Error message received from WaterRower monitor."); + + fireOnError(ERROR_MESSAGE_RECEIVED); + } + } + + + /** + * Returns true if connected to a supported WaterRower monitor. + * + * @return True if connected to a WaterRower monitor. + */ + public boolean isConnected() { + if (!connector.isConnected()) + return false; + return deviceVerificationWatchdog.isDeviceConfirmed(); + } + + + /** + * Disconnects from the rowing computer. + * + * @throws IOException If disconnect fails. + */ + public void disconnect() throws IOException { + + Log.debug("Disconnecting..."); + + stopInternalServices(); + + // Be polite and send a goodbye. + sendExitCommunicationMessage(); + + // Disconnect. + connector.disconnect(); + } + + /* Send ExitCommunicationMessage. */ + private void sendExitCommunicationMessage() { + try { + if (isConnected()) + connector.send(new ExitCommunicationMessage()); + } catch (IOException e) { + // If the message can not be send, ignore the error. Maybe the connection is already lost and + // a "goodbye" can not be send anymore. + Log.warn("Couldn't send exit communication message! " + e.getMessage()); + } + } + + + /* Handle disconnect. */ + private void handleOnDisconnected() { + + Log.debug("Serial device disconnected."); + + stopInternalServices(); + + try { + // Make sure the WaterRower gets disconnected. + disconnect(); + } catch (IOException e) { + Log.warn("Couldn't disconnect! " + e.getMessage()); + } + + fireOnDisconnected(); + } + + + /* Stop internal services. */ + private void stopInternalServices() { + subscriptionPollingService.stop(); + deviceVerificationWatchdog.stop(); + pingWatchdog.stop(); + } + + + /** + * Request the rowing computer to perform a reset; this will be identical to the user performing this with the + * power button. + * + * @throws IOException If the reset couldn't be send. + */ + public void performReset() throws IOException { + checkIfConnected(); + connector.send(new ResetMessage()); + } + + + /** + * Sends a workout configuration to the WaterRower. A configuration can be a single or an interval workout. + * + * @param workout The workout configuration for single or interval workouts, must not be null. + * + * @throws IOException If the workout couldn't be send. + */ + public void startWorkout(Workout workout) throws IOException { + requireNonNull(workout); + checkIfConnected(); + + if (workout.isSingleWorkout()) { + sendSingleWorkout(workout); + } else { + sendIntervalWorkout(workout); + } + } + + /* Sends a single workout to the WaterRower. */ + private void sendSingleWorkout(Workout workout) throws IOException { + + List workoutIntervals = workout.getWorkoutIntervals(); + if (workoutIntervals.size() != 1) + throw new IllegalStateException("A single workout must have one workout interval!"); + + WorkoutInterval interval = workoutIntervals.get(0); + + int distance = interval.getValue(); + WorkoutUnit unit = interval.getUnit(); + + Log.info("Sending single workout with " + distance + " " + unit + "..."); + + ConfigureWorkoutMessage msg = new ConfigureWorkoutMessage(SINGLE_WORKOUT, distance, unit); + connector.send(msg); + + Log.info("Sending of workout finished."); + } + + /* Sends an interval workout to the WaterRower. */ + private void sendIntervalWorkout(Workout workout) throws IOException { + + List workoutIntervals = workout.getWorkoutIntervals(); + + int numberOfIntervals = workoutIntervals.size(); + + if (numberOfIntervals < 2) + throw new IllegalStateException("An interval workout must have at least two workout interval!"); + + Log.info("Sending interval workout with "+ numberOfIntervals +" intervals..."); + + WorkoutInterval interval; + int distance; + int restInterval; + WorkoutUnit unit; + + final List messages = new ArrayList<>(); + for(int i = 0; i < numberOfIntervals; i++) { + + interval = workoutIntervals.get(i); + + distance = interval.getValue(); + restInterval = (i == 0) ? -1 : interval.getRestInterval(); + unit = interval.getUnit(); + + Log.debug("Sending interval: " + interval); + messages.add(new ConfigureWorkoutMessage((i == 0) ? START_INTERVAL_WORKOUT : ADD_INTERVAL_WORKOUT, distance, unit, restInterval)); + + // If this is the last interval, send the end interval message. + if (i == (numberOfIntervals - 1)) { + + Log.debug("Sending the end message for interval workout."); + messages.add(new ConfigureWorkoutMessage(END_INTERVAL_WORKOUT, 0xFFFF, unit, 0xFFFF)); + } + + } + + // Send the messages. + connector.send(messages); + + Log.info("Sending of interval workout finished."); + } + + + /* Throws IOException if not connected to a WaterRower. */ + private void checkIfConnected() throws IOException { + if (!isConnected()) + throw new IOException("Not connected to a WaterRower!"); + } + + + /** + * Subscribe to events. This will start the polling for the given data. + * + * @param subscription The subscription and callback, must not be null. + */ + public void subscribe(ISubscription subscription) { + subscriptionPollingService.subscribe(requireNonNull(subscription)); + } + + /** + * Unsubscribe from events. This will stop the polling for the given data. + * + * @param subscription The subscription, must not be null. + */ + public void unsubscribe(ISubscription subscription) { + subscriptionPollingService.unsubscribe(requireNonNull(subscription)); + } + + + /** + * Adds the listener. + * + * @param listener The listener, must not be null. + */ + public void addConnectionListener(IWaterRowerConnectionListener listener) { + listeners.add(requireNonNull(listener)); + } + + /* Notifies listeners when an error occurred. */ + private void fireOnError(ErrorCode errorCode) { + for(IWaterRowerConnectionListener listener : listeners) + listener.onError(errorCode); + } + + /* Notifies listeners when connected. */ + private void fireOnConnected(ModelInformation modelInformation) { + for(IWaterRowerConnectionListener listener : listeners) + listener.onConnected(modelInformation); + } + + /* Notifies listeners when disconnected. */ + private void fireOnDisconnected() { + listeners.forEach(IWaterRowerConnectionListener::onDisconnected); + } + + /** + * Removes the listener. + * + * @param listener The listener that should be removed, must not be null. + */ + public void removeConnectionListener(IWaterRowerConnectionListener listener) { + listeners.remove(requireNonNull(listener)); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/WaterRowerInitializer.java b/src/android/src/de/tbressler/waterrower/WaterRowerInitializer.java new file mode 100644 index 0000000000..c844e609f8 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/WaterRowerInitializer.java @@ -0,0 +1,119 @@ +package de.tbressler.waterrower; + +import de.tbressler.waterrower.io.ChannelInitializer; +import de.tbressler.waterrower.io.CommunicationService; +import de.tbressler.waterrower.io.WaterRowerConnector; +import de.tbressler.waterrower.subscriptions.ISubscriptionPollingService; +import de.tbressler.waterrower.subscriptions.SubscriptionPollingService; +import de.tbressler.waterrower.watchdog.DeviceVerificationWatchdog; +import de.tbressler.waterrower.watchdog.PingWatchdog; +import io.netty.bootstrap.Bootstrap; + +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +import static java.util.Objects.requireNonNull; + +/** + * Initializes the dependencies of the WaterRower class based on the given parameters. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class WaterRowerInitializer { + + /* Handles the connection to the WaterRower. */ + private final WaterRowerConnector connector; + + /* Polls and handles subscriptions. */ + private final ISubscriptionPollingService subscriptionPolling; + + /* Watchdog that checks if a ping is received periodically. */ + private final PingWatchdog pingWatchdog; + + /* Watchdog that checks if the device sends it's model information in order to verify + * compatibility with the library. */ + private final DeviceVerificationWatchdog deviceVerificationWatchdog; + + + /** + * Initializes the dependencies of the WaterRower class based on the given parameters. + * + * @param timeoutInterval The timeout interval for messages, if a message was not received from the WaterRower + * during this interval a timeout error will get fired, must not be null. + * Recommended = 5 second. + * @param threadPoolSize The number of threads to keep in the pool, which should be used by the WaterRower + * service even if they are idle. + * Recommended = 5. + */ + public WaterRowerInitializer(Duration timeoutInterval, int threadPoolSize) { + this(Duration.ofMillis(200), timeoutInterval, threadPoolSize); + } + + /** + * Initializes the dependencies of the WaterRower class based on the given parameters. + * + * @param messageInterval The interval between the polling messages. + * Recommended = 200 ms. + * @param timeoutInterval The timeout interval for messages, if a message was not received from the WaterRower + * during this interval a timeout error will get fired, must not be null. + * Recommended = 5 second. + * @param threadPoolSize The number of threads to keep in the pool, which should be used by the WaterRower + * service even if they are idle. + * Recommended = 5. + */ + public WaterRowerInitializer(Duration messageInterval, Duration timeoutInterval, int threadPoolSize) { + requireNonNull(timeoutInterval); + if (threadPoolSize < 1) + throw new IllegalArgumentException("The number of threads must be at least 1!"); + + Bootstrap bootstrap = new Bootstrap(); + CommunicationService communicationService = new CommunicationService(bootstrap, new ChannelInitializer()); + ScheduledExecutorService executorService = Executors.newScheduledThreadPool(threadPoolSize); + + connector = new WaterRowerConnector(communicationService); + subscriptionPolling = new SubscriptionPollingService(connector, executorService, messageInterval); + pingWatchdog = new PingWatchdog(timeoutInterval, executorService); + deviceVerificationWatchdog = new DeviceVerificationWatchdog(timeoutInterval, executorService); + } + + + /** + * Returns the connector, which handles the connection to the WaterRower. + * + * @return The connector, never null. + */ + WaterRowerConnector getWaterRowerConnector() { + return connector; + } + + /** + * Returns the watchdog that checks if a ping was received periodically. + * + * @return The watchdog, never null. + */ + PingWatchdog getPingWatchdog() { + return pingWatchdog; + } + + /** + * Returns the watchdog that checks if the device sends it's model information in order to verify + * compatibility with the library. + * + * @return The watchdog, never null. + */ + DeviceVerificationWatchdog getDeviceVerificationWatchdog() { + return deviceVerificationWatchdog; + } + + /** + * Returns the subscription polling service, which polls and handles the subscriptions. + * + * @return The subscription polling service, never null. + */ + ISubscriptionPollingService getSubscriptionPollingService() { + return subscriptionPolling; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/discovery/WaterRowerAutoDiscovery.java b/src/android/src/de/tbressler/waterrower/discovery/WaterRowerAutoDiscovery.java new file mode 100644 index 0000000000..6c8c2f6e15 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/discovery/WaterRowerAutoDiscovery.java @@ -0,0 +1,207 @@ +package de.tbressler.waterrower.discovery; + +import de.tbressler.waterrower.IWaterRowerConnectionListener; +import de.tbressler.waterrower.WaterRower; +import de.tbressler.waterrower.io.transport.SerialDeviceAddress; +import de.tbressler.waterrower.log.Log; +import de.tbressler.waterrower.model.ErrorCode; +import de.tbressler.waterrower.model.ModelInformation; +import de.tbressler.waterrower.utils.AvailablePort; +import de.tbressler.waterrower.utils.SerialPortWrapper; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.Stack; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; + +import static java.time.Duration.ofSeconds; +import static java.util.Objects.requireNonNull; +import static java.util.concurrent.TimeUnit.SECONDS; +import static java.util.stream.Collectors.toList; + +/** + * Handles the auto-discovery of the WaterRower. + * + * This class automatically searches for the available serial ports and connects to + * the WaterRower Performance Monitor. When the connection is lost, the class automatically + * connects the WaterRower again as soon as the device is available again. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class WaterRowerAutoDiscovery { + + /* Try again interval, if no ports are available currently. */ + static final Duration TRY_AGAIN_INTERVAL = ofSeconds(5); + + + /* The WaterRower. */ + private final WaterRower waterRower; + + /* The executor service. */ + private final ScheduledExecutorService executorService; + + /* Wrapper for the serial port implementation. */ + private final SerialPortWrapper serialPortWrapper; + + + /* The current stack of available ports. */ + private final Stack availablePorts = new Stack<>(); + + /* True if active. */ + private final AtomicBoolean isActive = new AtomicBoolean(false); + + /* Lock, so that only one connection attempt can be done at the same time. */ + private final ReentrantLock lock = new ReentrantLock(true); + + /* The current serial port. */ + private String currentSerialPort = null; + + + /* Listener for WaterRower connections. */ + private final IWaterRowerConnectionListener connectionListener = new IWaterRowerConnectionListener() { + + @Override + public void onConnected(ModelInformation modelInformation) { + Log.debug("WaterRower successfully connected."); + } + + @Override + public void onDisconnected() { + Log.debug("WaterRower disconnected. Try to auto-connect again."); + executorService.submit(() -> tryNextConnectionAttempt()); + } + + @Override + public void onError(ErrorCode errorCode) {} + + }; + + + /** + * Handles the auto-discovery of the WaterRower. + * + * @param waterRower The WaterRower, must not be null. + */ + public WaterRowerAutoDiscovery(WaterRower waterRower) { + this(waterRower, Executors.newSingleThreadScheduledExecutor(), new SerialPortWrapper()); + } + + + /** + * Handles the auto-discovery of the WaterRower. + * + * @param waterRower The WaterRower, must not be null. + * @param executorService The executor service, must not be null. + */ + public WaterRowerAutoDiscovery(WaterRower waterRower, ScheduledExecutorService executorService) { + this(waterRower, executorService, new SerialPortWrapper()); + } + + + /** + * Handles the auto-discovery of the WaterRower. + * + * @param waterRower The WaterRower, must not be null. + * @param executorService The executor service, must not be null. + * @param serialPortWrapper The serial port wrapper, must not be null. + */ + WaterRowerAutoDiscovery(WaterRower waterRower, ScheduledExecutorService executorService, SerialPortWrapper serialPortWrapper) { + this.waterRower = requireNonNull(waterRower); + this.waterRower.addConnectionListener(connectionListener); + this.executorService = requireNonNull(executorService); + this.serialPortWrapper = requireNonNull(serialPortWrapper); + } + + + /** + * Starts the auto-discovery. + */ + public void start() { + Log.debug("Starting discovery."); + isActive.set(true); + executorService.submit(this::tryNextConnectionAttempt); + } + + + /* Try the next connection attempt. */ + private void tryNextConnectionAttempt() { + lock.lock(); + + try { + + if (!isActive.get()) + return; + + // If no ports are available anymore, update list of ports. + if (availablePorts.empty()) + updateAvailablePorts(); + + if (availablePorts.empty()) { + // Still no serial ports available! + Log.warn("Currently no serial ports available! Trying again in "+TRY_AGAIN_INTERVAL.getSeconds()+" second(s)..."); + executorService.schedule(this::tryNextConnectionAttempt, TRY_AGAIN_INTERVAL.getSeconds(), SECONDS); + return; + } + + SerialDeviceAddress address = availablePorts.pop(); + currentSerialPort = address.value(); + + Log.info("Auto-connecting serial port '"+address.value()+"'."); + + waterRower.connect(address); + + } catch (IOException e) { + Log.warn("Couldn't connect to serial port '"+currentSerialPort+"', due to error ("+e.getMessage()+")! Trying next port."); + executorService.schedule(this::tryNextConnectionAttempt, TRY_AGAIN_INTERVAL.getSeconds(), SECONDS); + } finally { + lock.unlock(); + } + } + + /* Updates the available serial ports on the stack. */ + private void updateAvailablePorts() { + + Log.debug("Updating list of available serial ports."); + + // Get all available serial ports. Additionally filter out every useless port in + // order to boost the performance of the auto-discovery. + List availablePorts = serialPortWrapper.getAvailablePorts().stream().filter((port) -> { + String portName = port.getSystemPortName(); + return (!portName.startsWith("/dev/cu.") && !portName.startsWith("cu.") + && !portName.contains("Bluetooth") && !portName.contains("BT") + && (port.getDescription().contains("WR-S") || port.getDescription().contains("Microchip Technology")) + && !port.isOpen()); + }).collect(toList()); + + // Add the new ports to the top of the available port stack (only these ports + // are used for auto-discovery). + this.availablePorts.addAll(availablePorts.stream() + .map((port) -> new SerialDeviceAddress(port.getSystemPortName())) + .collect(toList())); + } + + + /** + * Returns true if auto-discovery is active. + * + * @return True if auto-discovery is active. + */ + public boolean isActive() { + return isActive.get(); + } + + + /** + * Stops the auto-discovery. + */ + public void stop() { + Log.debug("Stopping discovery."); + isActive.set(false); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/ChannelInitializer.java b/src/android/src/de/tbressler/waterrower/io/ChannelInitializer.java new file mode 100644 index 0000000000..674ad64bc8 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/ChannelInitializer.java @@ -0,0 +1,109 @@ +package de.tbressler.waterrower.io; + +import de.tbressler.waterrower.io.codec.MessageFrameDecoder; +import de.tbressler.waterrower.io.codec.MessageFrameEncoder; +import de.tbressler.waterrower.io.codec.MessageParser; +import de.tbressler.waterrower.io.transport.SerialChannel; +import de.tbressler.waterrower.io.transport.SerialChannelConfig; +import de.tbressler.waterrower.log.Log; +import io.netty.channel.ChannelPipeline; +import io.netty.handler.codec.DelimiterBasedFrameDecoder; + +import static de.tbressler.waterrower.io.transport.SerialChannelConfig.Paritybit.NONE; +import static de.tbressler.waterrower.io.transport.SerialChannelConfig.Stopbits.STOPBITS_1; +import static io.netty.handler.codec.Delimiters.lineDelimiter; +import static java.util.Objects.requireNonNull; + +/** + * Initializes the serial channel and sets up the pipeline for encoding and decoding the messages. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ChannelInitializer extends io.netty.channel.ChannelInitializer { + + /* Maximum length of a single frame. */ + private static final int MAX_FRAME_LENGTH = 32; + + + /* The handler of the serial channel. */ + private SerialHandler serialHandler; + + /* The message parser. */ + private final MessageParser parser = new MessageParser(); + + + /** + * Initializes the serial channel and sets up the pipeline for encoding and decoding the messages. + */ + public ChannelInitializer() {} + + + /** + * Sets the serial handler. + * + * @param serialHandler The serial handler, must not be null. + */ + public void setSerialHandler(SerialHandler serialHandler) { + this.serialHandler = requireNonNull(serialHandler); + } + + + @Override + protected void initChannel(SerialChannel channel) { + Log.debug("Serial channel initialized. Configuring pipeline and channel..."); + + checkIfSerialHandlerIsSet(); + + configureChannel(channel); + configurePipeline(channel); + } + + /* Checks if the serial handler is not null. */ + private void checkIfSerialHandlerIsSet() { + if (serialHandler == null) { + IllegalStateException exception = new IllegalStateException("You forgot to set the serial handler before initializing the channel."); + Log.error("Serial channel couldn't be initialized!", exception); + throw exception; + } + } + + + /* Configures the channel. */ + private void configureChannel(SerialChannel channel) { + SerialChannelConfig config = channel.config(); + config.setBaudrate(19200); + config.setDatabits(8); + config.setStopbits(STOPBITS_1); + config.setParitybit(NONE); + + logSerialConfiguration(config); + } + + /* Logs the serial configuration. */ + private void logSerialConfiguration(SerialChannelConfig config) { + Log.debug("Serial channel configured to: " + + "\n Baudrate: " + config.getBaudrate() + + "\n Databits: " + config.getDatabits() + + "\n Stopbits: " + config.getStopbits().name() + + "\n Parity: " + config.getParitybit()); + } + + /* Configures the pipeline. */ + private void configurePipeline(SerialChannel channel) { + ChannelPipeline pipeline = channel.pipeline(); + + // Decode messages: + pipeline.addLast("framer", new DelimiterBasedFrameDecoder(MAX_FRAME_LENGTH, lineDelimiter())); + pipeline.addLast("decoder", new MessageFrameDecoder(parser)); + + // Encode messages: + pipeline.addLast("encoder", new MessageFrameEncoder(parser)); + + // Handle messages and exceptions: + pipeline.addLast("handler", serialHandler); + + Log.debug("Pipeline configured and handler added."); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/CommunicationService.java b/src/android/src/de/tbressler/waterrower/io/CommunicationService.java new file mode 100644 index 0000000000..906b0df0f0 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/CommunicationService.java @@ -0,0 +1,259 @@ +package de.tbressler.waterrower.io; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.transport.SerialChannel; +import de.tbressler.waterrower.io.transport.SerialDeviceAddress; +import de.tbressler.waterrower.log.Log; +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.oio.OioEventLoopGroup; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +import static java.util.Objects.requireNonNull; + +/** + * A communication service that manages the serial connection. + * It can receive and send serial messages. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class CommunicationService { + + /* The bootstrap. */ + private final Bootstrap bootstrap; + + /* The current channel or null. */ + private Channel currentChannel; + + /* A lock for synchronized access to open/close/read/write on channel. */ + private final ReentrantLock lock = new ReentrantLock(true); + + /* Listeners for serial connections. */ + private final List connectionListeners = new ArrayList<>(); + + + /* Handler for the communication channel. */ + private final SerialHandler serialHandler = new SerialHandler() { + + @Override + protected void onConnected() { + fireOnConnected(); + } + + @Override + protected void onMessageReceived(AbstractMessage message) { + fireOnMessageReceived(message); + } + + @Override + protected void onDisconnected() { + fireOnDisconnected(); + } + + @Override + protected void onError() { + closeWithoutExceptions(); + fireOnError(); + } + + }; + + + /** + * A communication service that manages the serial connection. + * It can receive and send serial messages. + * + * @param bootstrap The bootstrap, not null. + * @param channelInitializer The channel initializer, not null. + */ + public CommunicationService(Bootstrap bootstrap, ChannelInitializer channelInitializer) { + requireNonNull(bootstrap); + requireNonNull(channelInitializer); + + this.bootstrap = bootstrap; + this.bootstrap.group(new OioEventLoopGroup()); + this.bootstrap.channel(SerialChannel.class); + + channelInitializer.setSerialHandler(serialHandler); + + this.bootstrap.handler(channelInitializer); + } + + + /** + * Opens the connection to the given serial port. + * + * @param address The serial port, must not be null. + * @throws IOException if opening of the channel fails. + */ + public void open(SerialDeviceAddress address) throws IOException { + requireNonNull(address); + + lock.lock(); + + try { + + checkIfChannelIsClose(); + + Log.debug("Opening channel at serial port '" + address.value() + "'."); + + ChannelFuture future = bootstrap.connect(address).syncUninterruptibly(); + if (!future.isSuccess()) { + fireOnError(); + throw new IOException("Serial channel couldn't be opened!"); + } + + Log.debug("Serial channel was successfully opened."); + + currentChannel = future.channel(); + + } catch (Exception e) { + throw new IOException("Can not connect to '"+address.value()+"'!", e); + } finally { + lock.unlock(); + } + } + + /* Throws IOException if channel is already open. */ + private void checkIfChannelIsClose() throws IOException { + if (currentChannel != null) + throw new IOException("Serial channel is already open!"); + } + + + /** + * Returns true if the communication service is connected. + * + * @return True if connected otherwise false. + */ + public boolean isConnected() { + + lock.lock(); + + try { + + return (currentChannel != null); + + } finally { + lock.unlock(); + } + } + + + /** + * Sends the given message. + * + * @param msg The message to be send, must not be null. + */ + public void send(AbstractMessage msg) throws IOException { + requireNonNull(msg); + + lock.lock(); + + try { + + checkIfChannelIsOpen(); + + Log.debug("Sending message '" + msg.toString() + "'."); + + currentChannel.writeAndFlush(msg); + + } catch (Exception e) { + throw new IOException("Can not send message '"+msg+"'!", e); + } finally { + lock.unlock(); + } + } + + + /** + * Closes the current connection. + * + * @throws IOException if closing fails. + */ + public void close() throws IOException { + + try { + + checkIfChannelIsOpen(); + + Log.debug("Closing serial channel."); + + ChannelFuture future = currentChannel.close().syncUninterruptibly(); + if (!future.isSuccess()) + throw new IOException("Serial channel couldn't be closed!"); + + Log.debug("Serial channel was successfully closed."); + + } catch (Exception e) { + throw new IOException("Can not disconnect!", e); + } finally { + currentChannel = null; + } + } + + /* Throws IOException if channel is already closed. */ + private void checkIfChannelIsOpen() throws IOException { + if ((currentChannel == null) || (!currentChannel.isOpen())) + throw new IOException("Serial channel is not open!"); + } + + /* Close the channel and suppress exceptions. */ + private void closeWithoutExceptions() { + try { + Log.debug("Try to close channel."); + close(); + } catch (IOException e) { + Log.warn("Channel can not be closed! " + e.getMessage()); + } + } + + + /** + * Add a connection listener. + * + * @param listener The listener. + */ + public void addConnectionListener(IConnectionListener listener) { + requireNonNull(listener); + connectionListeners.add(listener); + } + + /* Notify all listeners about a successful connection. */ + private void fireOnConnected() { + connectionListeners.forEach(IConnectionListener::onConnected); + } + + /* Notify all listeners about an error. */ + private void fireOnError() { + connectionListeners.forEach(IConnectionListener::onError); + } + + /* Notify all listeners about a disconnect. */ + private void fireOnDisconnected() { + connectionListeners.forEach(IConnectionListener::onDisconnected); + } + + /* Notify all listeners about a received message. */ + private void fireOnMessageReceived(AbstractMessage msg) { + for (IConnectionListener listener : connectionListeners) + listener.onMessageReceived(msg); + } + + /** + * Remove a connection listener. + * + * @param listener The listener. + */ + public void removeConnectionListener(IConnectionListener listener) { + requireNonNull(listener); + connectionListeners.remove(listener); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/ConnectionListener.java b/src/android/src/de/tbressler/waterrower/io/ConnectionListener.java new file mode 100644 index 0000000000..4394d429c5 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/ConnectionListener.java @@ -0,0 +1,25 @@ +package de.tbressler.waterrower.io; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +/** + * Simple implementation of the interface IConnectionListener. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ConnectionListener implements IConnectionListener { + + @Override + public void onConnected() {} + + @Override + public void onMessageReceived(AbstractMessage msg) {} + + @Override + public void onDisconnected() {} + + @Override + public void onError() {} + +} diff --git a/src/android/src/de/tbressler/waterrower/io/IConnectionListener.java b/src/android/src/de/tbressler/waterrower/io/IConnectionListener.java new file mode 100644 index 0000000000..94493a09af --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/IConnectionListener.java @@ -0,0 +1,35 @@ +package de.tbressler.waterrower.io; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +/** + * Listener for serial connections. + * + * @author Tobias Bressler + * @version 1.0 + */ +public interface IConnectionListener { + + /** + * Called if connection was established. + */ + void onConnected(); + + /** + * Called if a message was received. + * + * @param msg The received message. + */ + void onMessageReceived(AbstractMessage msg); + + /** + * Called if connection was closed. + */ + void onDisconnected(); + + /** + * Called if a connection error occurred. + */ + void onError(); + +} diff --git a/src/android/src/de/tbressler/waterrower/io/SerialHandler.java b/src/android/src/de/tbressler/waterrower/io/SerialHandler.java new file mode 100644 index 0000000000..276861ca3c --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/SerialHandler.java @@ -0,0 +1,83 @@ +package de.tbressler.waterrower.io; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.log.Log; +import io.netty.channel.ChannelHandler.Sharable; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.util.ReferenceCountUtil; + +/** + * Handler for different events on the serial connection (e.g. connect, disconnect). + * + * @author Tobias Bressler + * @version 1.0 + */ +@Sharable +public abstract class SerialHandler extends ChannelInboundHandlerAdapter { + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + try { + + if (!(msg instanceof AbstractMessage)) { + Log.warn("Invalid message received! Message skipped."); + return; + } + + Log.debug("Message received: " + msg); + + // Notify that a message was received. + onMessageReceived((AbstractMessage) msg); + + } finally { + ReferenceCountUtil.release(msg); + } + } + + /** + * Is called if a message was received. + * + * @param message The message. + */ + abstract protected void onMessageReceived(AbstractMessage message); + + + @Override + public void channelActive(ChannelHandlerContext ctx) throws Exception { + super.channelActive(ctx); + onConnected(); + } + + /** + * Is called if connection was established. + */ + abstract protected void onConnected(); + + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + super.channelInactive(ctx); + onDisconnected(); + } + + /** + * Is called if connection was closed. + */ + protected abstract void onDisconnected(); + + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + Log.error("Unexpected exception caught in serial handler!", cause); + ctx.close(); + Log.debug("Connection to serial port closed."); + onError(); + } + + /** + * Is called if an connection error occurred. + */ + protected abstract void onError(); + +} diff --git a/src/android/src/de/tbressler/waterrower/io/WaterRowerConnector.java b/src/android/src/de/tbressler/waterrower/io/WaterRowerConnector.java new file mode 100644 index 0000000000..aa7091652e --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/WaterRowerConnector.java @@ -0,0 +1,173 @@ +package de.tbressler.waterrower.io; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.transport.SerialDeviceAddress; +import de.tbressler.waterrower.log.Log; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +import static java.util.Objects.requireNonNull; + +/** + * Handles the connection to the WaterRower. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class WaterRowerConnector { + + /* ms to wait after a message was send, in order to give the WaterRower time to + process the message. */ + private final static int MINIMUM_SEND_INTERVAL = 30; + + + /* The serial communication service. */ + private final CommunicationService communicationService; + + /* The lock to synchronize connect and disconnect. */ + private final ReentrantLock lock = new ReentrantLock(true); + + /* Listeners for the serial communication. */ + private final List listeners = new ArrayList<>(); + + + /** + * Handles the connection to the WaterRower. + * + * @param communicationService The communication service, must not be null. + */ + public WaterRowerConnector(CommunicationService communicationService) { + this.communicationService = requireNonNull(communicationService); + } + + + /** + * Connect to the rowing computer. + * + * @param address The serial port, must not be null. + * + * @throws IOException If connect fails. + */ + public void connect(SerialDeviceAddress address) throws IOException { + requireNonNull(address); + + lock.lock(); + + try { + + if (isConnected()) + throw new IOException("Service is already connected! Can not connect."); + + Log.debug("Opening serial channel at '" + address.value() + "' connection."); + communicationService.open(address); + + } finally { + lock.unlock(); + } + } + + + /* Returns true if connected. */ + public boolean isConnected() { + return communicationService.isConnected(); + } + + + /** + * Disconnects from the rowing computer. + * + * @throws IOException If disconnect fails. + */ + public void disconnect() throws IOException { + + lock.lock(); + + try { + + if (!isConnected()) + throw new IOException("Service is not connected! Can not disconnect."); + + Log.debug("Closing serial channel."); + communicationService.close(); + + } finally { + lock.unlock(); + } + } + + + /** + * Sends a single message. + * + * @param msg The message to be sent, must not be null. + */ + public void send(AbstractMessage msg) throws IOException { + requireNonNull(msg); + + lock.lock(); + + if (!isConnected()) + throw new IOException("Not connected! Can not send message to WaterRower."); + + try { + + communicationService.send(msg); + Thread.sleep(MINIMUM_SEND_INTERVAL); // Wait, this gives the rowing computer time to process. + + } catch (InterruptedException e) { + Log.error("Error while sending message!", e); + } finally { + lock.unlock(); + } + } + + + /** + * Sends multiple messages at once. + * + * @param messages The messages to be sent, must not be null. + */ + public void send(List messages) throws IOException { + requireNonNull(messages); + + lock.lock(); + + if (!isConnected()) + throw new IOException("Not connected! Can not send message to WaterRower."); + + try { + + for(AbstractMessage msg : messages) { + send(msg); + } + + } finally { + lock.unlock(); + } + } + + + /** + * Adds the connection listener. + * + * @param listener The listener, must not be null. + */ + public void addConnectionListener(IConnectionListener listener) { + listeners.add(requireNonNull(listener)); + communicationService.addConnectionListener(listener); + } + + /** + * Removes the connection listener. + * + * @param listener The listener, must not be null. + */ + public void removeConnectionListener(IConnectionListener listener) { + listeners.remove(requireNonNull(listener)); + communicationService.removeConnectionListener(listener); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/codec/MessageFrameDecoder.java b/src/android/src/de/tbressler/waterrower/io/codec/MessageFrameDecoder.java new file mode 100644 index 0000000000..8553cddb1e --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/codec/MessageFrameDecoder.java @@ -0,0 +1,66 @@ +package de.tbressler.waterrower.io.codec; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.log.Log; +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; + +import java.util.List; + +import static de.tbressler.waterrower.io.utils.ByteUtils.bufferToString; +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.util.Objects.requireNonNull; + +/** + * Decodes messages (byte > msg). + * + * @author Tobias Bressler + * @version 1.0 + */ +public class MessageFrameDecoder extends ByteToMessageDecoder { + + /* The message parser. */ + private final MessageParser parser; + + + /** + * Constructor. + * + * @param parser The message parser, must not be null. + */ + public MessageFrameDecoder(MessageParser parser) { + this.parser = requireNonNull(parser); + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { + + Log.debug("Decoder received new message buffer:\n" + + " Buffer: " + bufferToString(in)); + + int numberOfBytes = in.readableBytes(); + + // Check if bytes are available and this is not an empty frame. + if (numberOfBytes == 0) { + Log.warn("No bytes in message buffer! Skipping frame."); + return; + } + + byte [] byteArray = new byte[numberOfBytes]; + + in.readBytes(byteArray, 0, numberOfBytes); + + Log.debug("Message buffer decoded to: >" + new String(byteArray, US_ASCII) + "<"); + + // Decode the message. + AbstractMessage decodedMessage = parser.decode(byteArray); + if (decodedMessage == null) { + Log.warn("Couldn't decode bytes to message! Skipping it."); + return; + } + + out.add(decodedMessage); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/codec/MessageFrameEncoder.java b/src/android/src/de/tbressler/waterrower/io/codec/MessageFrameEncoder.java new file mode 100644 index 0000000000..7aec8ff045 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/codec/MessageFrameEncoder.java @@ -0,0 +1,60 @@ +package de.tbressler.waterrower.io.codec; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.log.Log; +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.MessageToByteEncoder; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.util.Objects.requireNonNull; + +/** + * Encodes messages (msg > byte). + * + * @author Tobias Bressler + * @version 1.0 + */ +public class MessageFrameEncoder extends MessageToByteEncoder { + + /* The message parser. */ + private final MessageParser parser; + + + /** + * Constructor. + * + * @param parser The message parser, must not be null. + */ + public MessageFrameEncoder(MessageParser parser) { + this.parser = requireNonNull(parser); + } + + @Override + protected void encode(ChannelHandlerContext ctx, Object msg, ByteBuf out) throws Exception { + if (!(msg instanceof AbstractMessage)) { + Exception e = new IllegalArgumentException("This type of message can not be send! " + + "Only messages of type >"+AbstractMessage.class.getSimpleName()+"< can be send."); + Log.error("Message couldn't be send to serial device!", e); + throw e; + } + + // Parse the message: + byte[] byteArray = parser.encode((AbstractMessage) msg); + if (byteArray == null) { + Log.warn("Message couldn't been encoded! Skipped message."); + return; + } + + // Write bytes to channel. + out.writeBytes(byteArray); + out.writeByte(0x0D); + out.writeByte(0x0A); + + Log.debug("Message buffer encoded and written:\n" + + " As String: >" + new String(byteArray, US_ASCII) + "<"); + + ctx.writeAndFlush(out); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/codec/MessageParser.java b/src/android/src/de/tbressler/waterrower/io/codec/MessageParser.java new file mode 100644 index 0000000000..465e42b55b --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/codec/MessageParser.java @@ -0,0 +1,129 @@ +package de.tbressler.waterrower.io.codec; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.IMessageInterpreter; +import de.tbressler.waterrower.io.msg.in.DecodeErrorMessage; +import de.tbressler.waterrower.io.msg.interpreter.*; +import de.tbressler.waterrower.log.Log; + +import java.util.ArrayList; +import java.util.List; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.util.Objects.requireNonNull; + +/** + * Decodes and encodes messages received from or sent to the WaterRower S4/S5 monitor. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class MessageParser { + + /* List of message interpreters. */ + private List interpreters = new ArrayList<>(); + + + /** + * Decodes and encodes messages received from or sent to the WaterRower S4/S5 monitor. + */ + public MessageParser() { + createAndAddMessageInterpreters(); + } + + /* Add all message interpreters to this parser. */ + private void createAndAddMessageInterpreters() { + interpreters.add(new InformationRequestMessageInterpreter()); + interpreters.add(new PulseCountMessageInterpreter()); + interpreters.add(new StrokeMessageInterpreter()); + interpreters.add(new PingMessageInterpreter()); + interpreters.add(new AcknowledgeMessageInterpreter()); + interpreters.add(new ErrorMessageInterpreter()); + interpreters.add(new HardwareTypeMessageInterpreter()); + interpreters.add(new ResetMessageInterpreter()); + interpreters.add(new ConfigureWorkoutMessageInterpreter()); + interpreters.add(new StartCommunicationMessageInterpreter()); + interpreters.add(new ExitCommunicationMessageInterpreter()); + } + + /* For testing purposes only! Returns all interpreters. */ + List getInterpreters() { + return interpreters; + } + + /** + * Decodes and encodes messages received from or sent to the WaterRower S4/S5 monitor. + * Mainly used for test purposes! + * + * @param interpreters The interpreters for the different messages, must not be null. + */ + MessageParser(List interpreters) { + this.interpreters = requireNonNull(interpreters); + } + + + /** + * Decodes the given byte array to a message object. Returns a DecodeErrorMessage if the message + * couldn't be decoded. + * + * @param bytes The byte array. + * @return The message object or a DecodeErrorMessage. + */ + public AbstractMessage decode(byte[] bytes) { + + Log.debug("Parsing message to object."); + + String msg = new String(bytes, US_ASCII); + + String msgIdentifier; + for (IMessageInterpreter interpreter : interpreters) { + + // Check message identifiers: + msgIdentifier = interpreter.getMessageIdentifier(); + if (msgIdentifier == null) + continue; + if (!msg.startsWith(msgIdentifier)) + continue; + + // Decode message to an object: + AbstractMessage decodedMsg = interpreter.decode(msg); + + if (decodedMsg != null) + return decodedMsg; + } + + return new DecodeErrorMessage(msg); + } + + + /** + * Encodes the given message to a byte array. Returns null if the message + * couldn't be encoded. + * + * @param msg The message. + * @return The byte array or null. + */ + @SuppressWarnings("unchecked") + public byte[] encode(AbstractMessage msg) { + + Log.debug("Parsing message '"+msg.toString()+"' to bytes."); + + for (IMessageInterpreter interpreter : interpreters) { + + // Check if message type matches: + if (!interpreter.isSupported(msg)) + continue; + + // Encode object to message: + String encodedMsg = interpreter.encode(msg); + + if (encodedMsg != null) + return encodedMsg.getBytes(US_ASCII); + } + + Log.warn("Message couldn't be encoded! Unknown message type '"+msg.getClass().getName()+"'."); + + return null; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/AbstractMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/AbstractMessage.java new file mode 100644 index 0000000000..fdf2d812fd --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/AbstractMessage.java @@ -0,0 +1,10 @@ +package de.tbressler.waterrower.io.msg; + +/** + * An abstract serial message. + * This is the super-class of all messages that can be exchanged with the WaterRower S4 monitor. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class AbstractMessage {} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/AbstractMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/AbstractMessageInterpreter.java new file mode 100644 index 0000000000..b5e82e1ea2 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/AbstractMessageInterpreter.java @@ -0,0 +1,9 @@ +package de.tbressler.waterrower.io.msg; + +/** + * Abstract message interpreter. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class AbstractMessageInterpreter implements IMessageInterpreter {} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/io/msg/IMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/IMessageInterpreter.java new file mode 100644 index 0000000000..da1cd9eab3 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/IMessageInterpreter.java @@ -0,0 +1,43 @@ +package de.tbressler.waterrower.io.msg; + + +/** + * Interface for message interpreters, which decode or encode incoming and outgoing messages. + * + * @author Tobias Bressler + * @version 1.0 + */ +public interface IMessageInterpreter { + + /** + * Returns the identifier of the message type which this interpreter can decode. Can be null if no incoming + * messages of this type are expected. + * + * @return The identifier char or null. + */ + String getMessageIdentifier(); + + /** + * Returns true if the message type is supported by this interpreter. + * + * @return The message. + */ + boolean isSupported(AbstractMessage msg); + + /** + * Decodes the given ASCII string to a message object. If the message can not be decoded the method returns null. + * + * @param msg The message as ASCII string. + * @return The message object or null. + */ + T decode(String msg); + + /** + * Encodes the given message object to a ASCII string. If the message can not be encoded the method returns null. + * + * @param msg The message object. + * @return The message as ASCII string. + */ + String encode(T msg); + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/InformationRequestMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/InformationRequestMessage.java new file mode 100644 index 0000000000..bbc4e3e133 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/InformationRequestMessage.java @@ -0,0 +1,7 @@ +package de.tbressler.waterrower.io.msg; + +/** + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class InformationRequestMessage extends AbstractMessage {} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/Memory.java b/src/android/src/de/tbressler/waterrower/io/msg/Memory.java new file mode 100644 index 0000000000..7098741db2 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/Memory.java @@ -0,0 +1,20 @@ +package de.tbressler.waterrower.io.msg; + +/** + * Defines how many bytes should be read from memory locations. + * + * @author Tobias Bressler + * @version 1.0 + */ +public enum Memory { + + /* 1 byte. */ + SINGLE_MEMORY, + + /* 16 bit. */ + DOUBLE_MEMORY, + + /* 24 bit. */ + TRIPLE_MEMORY + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/in/AcknowledgeMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/in/AcknowledgeMessage.java new file mode 100644 index 0000000000..12b34cf0fd --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/in/AcknowledgeMessage.java @@ -0,0 +1,25 @@ +package de.tbressler.waterrower.io.msg.in; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; + +/** + * Packet Accepted (S4/S5 -> PC). + * + * This packet will only be sent where no other reply to a PC would otherwise be given. If a + * packet response is required to the PC then that will take the place of the OK packet. + * + * [O][K] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class AcknowledgeMessage extends AbstractMessage { + + @Override + public String toString() { + return toStringHelper(this).toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/in/DataMemoryMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/in/DataMemoryMessage.java new file mode 100644 index 0000000000..672bc4f182 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/in/DataMemoryMessage.java @@ -0,0 +1,181 @@ +package de.tbressler.waterrower.io.msg.in; + +import de.tbressler.waterrower.io.msg.out.ReadMemoryMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; +import static de.tbressler.waterrower.io.msg.Memory.*; +import static de.tbressler.waterrower.utils.MessageUtils.intToAch; + +/** + * Value from single, double or triple memory locations (S4/S5 -> PC). + * + * The read packets will retrieve values from the rowing-computer memory, these locations are + * raw data which maybe a decimal, hexadecimal, binary or BCD format, each will be returned in + * ACH format in the packet. Correct conversion and usage will be needed for the PC application + * to use the values. + * + * Value from single memory location: + * + * Returns the single byte of data Y1 from location XXX for the users application. + * + * [I][DS] + XXX + Y1 + 0x0D0A + * + * Value from double memory locations: + * + * Returns two bytes of data starting from the second location first (Y2) then location XXX (Y1). + * This is for reading 16bit values which have (H)igh and (L)ow pair in one go. + * + * [I][DD] + XXX + Y2 + Y1 + 0x0D0A + * + * Value from triple memory locations: + * + * Returns three bytes of data starting from the third location first (Y3) then (Y2) to location + * XXX (Y1). This is for reading 24bit values like a clock, which has Hours, Minutes & Seconds. + * + * [I][DT] + XXX + Y3 + Y2 + Y1 + 0x0D0A + * + * XXX is in ACH format and has a maximum range of 0x000 to 0xFFF, however not all locations are + * available (see Memory Map), errors will be replied for out of spec memory reads. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class DataMemoryMessage extends ReadMemoryMessage { + + /* The single byte of data Y1 (0 .. 255) from memory location. */ + private final int value1; + + /* The single byte of data Y2 (0 .. 255) from memory location. */ + private final int value2; + + /* The single byte of data Y3 (0 .. 255) from memory location. */ + private final int value3; + + + /** + * This message returns the single byte Y1 of data from single memory location for the users + * application. + * + * @param location The memory location (0 .. 4095), please refer to memory map of the Water + * Rower monitor. + * @param value1 The single byte of data Y1 (0 .. 255) from memory location. + */ + public DataMemoryMessage(int location, int value1) { + super(SINGLE_MEMORY, location); + this.value3 = -1; + this.value2 = -1; + this.value1 = assertValueRange(value1); + } + + /** + * This message returns the bytes Y1 and Y2 of data from double memory location for the users + * application. + * + * @param location The memory location (0 .. 4095), please refer to memory map of the Water + * Rower monitor. + * @param value2 The single byte of data Y2 (0 .. 255) from memory location. + * @param value1 The single byte of data Y1 (0 .. 255) from memory location. + */ + public DataMemoryMessage(int location, int value2, int value1) { + super(DOUBLE_MEMORY, location); + this.value3 = -1; + this.value2 = assertValueRange(value2); + this.value1 = assertValueRange(value1); + } + + /** + * This message returns the bytes Y1, Y2 and Y3 of data from triple memory location for the + * users application. + * + * @param location The memory location (0 .. 4095), please refer to memory map of the Water + * Rower monitor. + * @param value3 The single byte of data Y3 (0 .. 255) from memory location. + * @param value2 The single byte of data Y2 (0 .. 255) from memory location. + * @param value1 The single byte of data Y1 (0 .. 255) from memory location. + */ + public DataMemoryMessage(int location, int value3, int value2, int value1) { + super(TRIPLE_MEMORY, location); + this.value3 = assertValueRange(value3); + this.value2 = assertValueRange(value2); + this.value1 = assertValueRange(value1); + } + + /* Throws IllegalArgumentException if value is out of range. */ + private int assertValueRange(int value) { + if ((value < 0) || (value > 255)) + throw new IllegalArgumentException("The value must be between 0 and 255!"); + return value; + } + + + /** + * Returns the single byte of data Y3 from the memory location. + * + * @return The single byte of data (0 .. 255) from memory location. The value is -1 for + * SINGLE_MEMORY or DOUBLE_MEMORY messages. + */ + public int getValue3() { + return value3; + } + + /** + * Returns the single byte of data Y3 from the memory location (as ACH string). + * + * @return The single byte of data (00 .. FF) from memory location. + */ + public String getValue3AsACH() { + return intToAch(value3, 2); + } + + + /** + * Returns the single byte of data Y2 from the memory location. + * + * @return The single byte of data (0 .. 255) from memory location. The value is -1 for + * SINGLE_MEMORY messages. + */ + public int getValue2() { + return value2; + } + + /** + * Returns the single byte of data Y2 from the memory location (as ACH string). + * + * @return The single byte of data (00 .. FF) from memory location. + */ + public String getValue2AsACH() { + return intToAch(value2, 2); + } + + + /** + * Returns the single byte of data Y1 from the memory location. + * + * @return The single byte of data (0 .. 255) from memory location. + */ + public int getValue1() { + return value1; + } + + /** + * Returns the single byte of data Y1 from the memory location (as ACH string). + * + * @return The single byte of data (00 .. FF) from memory location. + */ + public String getValue1AsACH() { + return intToAch(value1, 2); + } + + + @Override + public String toString() { + return toStringHelper(this) + .add("memory", getMemory()) + .add("location", getLocation()) + .add("value3", value3) + .add("value2", value2) + .add("value1", value1) + .toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/in/DecodeErrorMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/in/DecodeErrorMessage.java new file mode 100644 index 0000000000..dae569e3bb --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/in/DecodeErrorMessage.java @@ -0,0 +1,27 @@ +package de.tbressler.waterrower.io.msg.in; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; + +/** + * Decode error message. + */ +public class DecodeErrorMessage extends AbstractMessage { + + private final String message; + + public DecodeErrorMessage(String message) { + this.message = message; + } + + public String getMessage() { + return this.message; + } + + @Override + public String toString() { + return toStringHelper(this).toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/in/ErrorMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/in/ErrorMessage.java new file mode 100644 index 0000000000..a18e59f29a --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/in/ErrorMessage.java @@ -0,0 +1,25 @@ +package de.tbressler.waterrower.io.msg.in; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; + +/** + * Unknown packet / error (S4/S5 -> PC). + * + * The last received packet from the PC was of an unknown time and caused a general ERROR reply + * to be issued. + * + * [E][RROR] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ErrorMessage extends AbstractMessage { + + @Override + public String toString() { + return toStringHelper(this).toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/in/HardwareTypeMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/in/HardwareTypeMessage.java new file mode 100644 index 0000000000..0ca3180e92 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/in/HardwareTypeMessage.java @@ -0,0 +1,54 @@ +package de.tbressler.waterrower.io.msg.in; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; + +/** + * Hardware Type (S4/S5 -> PC). + * + * The WaterRower will reply with this packet when it receives a "USB" packet and will then + * proceed to send other packets accordingly until it switch’s off or the application issues an + * exit packet. + * + * [_][WR_] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class HardwareTypeMessage extends AbstractMessage { + + /* True if the connected device is a WaterRower. */ + private final boolean isWaterRower; + + + /** + * The WaterRower will reply with this packet when it receives a "USB" packet and will then + * proceed to send other packets accordingly until it switch’s off or the application issues an + * exit packet. + * + * @param isWaterRower True if the connected device is a WaterRower. + */ + public HardwareTypeMessage(boolean isWaterRower) { + this.isWaterRower = isWaterRower; + } + + + /** + * Returns true if the hardware type is a "WaterRower". + * + * @return True if the hardware is a "WaterRower". + */ + public boolean isWaterRower() { + return isWaterRower; + } + + + @Override + public String toString() { + return toStringHelper(this) + .add("isWaterRower", isWaterRower) + .toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/in/ModelInformationMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/in/ModelInformationMessage.java new file mode 100644 index 0000000000..434579ae3b --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/in/ModelInformationMessage.java @@ -0,0 +1,55 @@ +package de.tbressler.waterrower.io.msg.in; + +import de.tbressler.waterrower.io.msg.InformationRequestMessage; +import de.tbressler.waterrower.model.ModelInformation; + +import static com.google.common.base.MoreObjects.toStringHelper; +import static java.util.Objects.requireNonNull; + +/** + * Current Model Information (S4/S5 -> PC). + * + * Details of what unit is attached: + * - Model - Sent as 4 or 5 to indicate if it is a Series 4 or series 5 rowing computer. + * - Version high - 02 as an example for version 2.00 MSB of the firmware version. + * - Version low - 00 as an example for version 2.00 LSB of the firmware version. + * + * [I][V] + [Model] + [Version High] + [Version Low] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ModelInformationMessage extends InformationRequestMessage { + + /* The model type and firmware. */ + private final ModelInformation modelInformation; + + + /** + * Current model information. + * + * @param modelInformation The model type and firmware, must not be null. + */ + public ModelInformationMessage(ModelInformation modelInformation) { + this.modelInformation = requireNonNull(modelInformation); + } + + + /** + * Returns the model type and firmware. + * + * @return The model type and firmware, never null. + */ + public ModelInformation getModelInformation() { + return modelInformation; + } + + + @Override + public String toString() { + return toStringHelper(this) + .add("modelInformation", modelInformation) + .toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/in/PingMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/in/PingMessage.java new file mode 100644 index 0000000000..5a0c018670 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/in/PingMessage.java @@ -0,0 +1,25 @@ +package de.tbressler.waterrower.io.msg.in; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; + +/** + * Ping (S4/S5 -> PC). + * + * Sent once a second while NO rowing is occurring to indicate to the PC the rowing monitor is + * still operational but stopped. + * + * [P][ING] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class PingMessage extends AbstractMessage { + + @Override + public String toString() { + return toStringHelper(this).toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/in/PulseCountMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/in/PulseCountMessage.java new file mode 100644 index 0000000000..9755d9ae5a --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/in/PulseCountMessage.java @@ -0,0 +1,64 @@ +package de.tbressler.waterrower.io.msg.in; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; + +/** + * Pulse Count in the last 25mS (S4/S5 -> PC). + * + * This packet is auto transmitted by the rowing computer. + * + * "XX" is an ACH value representing the number of pulse’s counted during the last 25mS + * period; this value can range from 1 to 50 typically. (Zero values will not be transmitted). + * Please refer to "WaterRower Series 4 Rowing Algorithm.doc" for in depth details on how to use + * this data. At this time the constant values are: + * + * pins_per_xxcm 32 ; number of pin edges allowed to equal xxcm (dec) + * distance_xxcm 35 ; number of cm per flagged xxcm no. of pins (dec) + * + * This packet has the third highest priority of transmission on the USB. + * + * [P] + XX + 0x0D0A + * + * It seems the Pulse counts roughly (and linearly) corresponds to the distance. By experimenting you can + * find out your coef so that the distance on your S4 equals transforms pulses: `distanceOnS4 = pulseCount * coef`. + * On my Waterrower this number was 0.011. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class PulseCountMessage extends AbstractMessage { + + /* The number of pulse’s counted. */ + private final int pulsesCounted; + + + /** + * Message for Pulse Count in the last 25mS. + * + * @param pulsesCounted The number of pulse’s counted during the last 25mS period. + */ + public PulseCountMessage(int pulsesCounted) { + this.pulsesCounted = pulsesCounted; + } + + + /** + * Returns the number of pulse’s counted during the last 25mS period. + * + * @return The number of pulse’s counted. + */ + public int getPulsesCounted() { + return pulsesCounted; + } + + + @Override + public String toString() { + return toStringHelper(this) + .add("pulsesCounted", pulsesCounted) + .toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/in/StrokeMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/in/StrokeMessage.java new file mode 100644 index 0000000000..c03a57bea3 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/in/StrokeMessage.java @@ -0,0 +1,66 @@ +package de.tbressler.waterrower.io.msg.in; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.model.StrokeType; + +import static com.google.common.base.MoreObjects.toStringHelper; +import static java.util.Objects.requireNonNull; + +/** + * Stroke start/end (S4/S5 -> PC). + * + * This packet is auto transmitted by the rowing computer. + * + * Start of stroke: + * + * Start of stroke pull to show when the rowing computer determined acceleration occurring in + * the paddle. This packet has the highest priority of transmission on the USB. + * + * [S][S] + 0x0D0A + * + * End of stroke: + * + * End of stroke pull to show when the rowing computer determined deceleration occurring in + * the paddle. (Now entered the relax phase). This packet has the second highest priority of + * transmission on the USB. + * + * [S][E] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class StrokeMessage extends AbstractMessage { + + + /* The type of stroke. */ + private final StrokeType strokeType; + + + /** + * Stroke start/end message. + * + * @param stroke The type of stroke, must not be null. + */ + public StrokeMessage(StrokeType stroke) { + this.strokeType = requireNonNull(stroke); + } + + + /** + * Returns the type of stroke. + * + * @return The type of stroke, never null. + */ + public StrokeType getStrokeType() { + return strokeType; + } + + + @Override + public String toString() { + return toStringHelper(this) + .add("strokeType", strokeType) + .toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/AcknowledgeMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/AcknowledgeMessageInterpreter.java new file mode 100644 index 0000000000..f58b41513c --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/AcknowledgeMessageInterpreter.java @@ -0,0 +1,46 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.in.AcknowledgeMessage; + +/** + * Interpreter for: + * + * Packet Accepted (S4/S5 -> PC). + * + * This packet will only be sent where no other reply to a PC would otherwise be given. If a + * packet response is required to the PC then that will take the place of the OK packet. + * + * [O][K] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class AcknowledgeMessageInterpreter extends AbstractMessageInterpreter { + + /* Single instance of an acknowledge message. */ + private final static AcknowledgeMessage ACKNOWLEDGE_MESSAGE = new AcknowledgeMessage(); + + + @Override + public String getMessageIdentifier() { + return "OK"; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof AcknowledgeMessage); + } + + @Override + public AcknowledgeMessage decode(String msg) { + return ACKNOWLEDGE_MESSAGE; + } + + @Override + public String encode(AcknowledgeMessage msg) { + throw new IllegalStateException("This type of message can not be send to the WaterRower S4/S5 monitor."); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ConfigureWorkoutMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ConfigureWorkoutMessageInterpreter.java new file mode 100644 index 0000000000..26af177a5f --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ConfigureWorkoutMessageInterpreter.java @@ -0,0 +1,78 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.out.ConfigureWorkoutMessage; +import de.tbressler.waterrower.log.Log; + +import static de.tbressler.waterrower.utils.MessageUtils.intToAch; + +/** + * Interpreter for: ConfigureWorkoutMessage + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ConfigureWorkoutMessageInterpreter extends AbstractMessageInterpreter { + + @Override + public String getMessageIdentifier() { + return null; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof ConfigureWorkoutMessage); + } + + @Override + public ConfigureWorkoutMessage decode(String msg) { + throw new IllegalStateException("This type of message should not be send by WaterRower S4/S5 monitor to the PC."); + } + + @Override + public String encode(ConfigureWorkoutMessage msg) { + + switch(msg.getMessageType()) { + case SINGLE_WORKOUT: + return addUnitAndDistance(msg, "WS"); + case START_INTERVAL_WORKOUT: + return addUnitAndDistance(msg, "WI"); + case ADD_INTERVAL_WORKOUT: + case END_INTERVAL_WORKOUT: + return "WIN" + intToAch(msg.getRestInterval(), 4) + intToAch(msg.getDistance(), 4); + } + + Log.warn("Message couldn't be encoded!\n" + + " Message was: " + msg); + + return null; + } + + /* Add unit and distance to the given string. */ + private String addUnitAndDistance(ConfigureWorkoutMessage msg, String output) { + + switch (msg.getWorkoutUnit()) { + case METERS: + output += "I1"; + break; + case MILES: + output += "I2"; + break; + case KMS: + output += "I3"; + break; + case STROKES: + output += "I4"; + break; + case SECONDS: + output += "U"; + break; + } + + output += intToAch(msg.getDistance(), 4); + + return output; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ErrorMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ErrorMessageInterpreter.java new file mode 100644 index 0000000000..7e2df5a325 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ErrorMessageInterpreter.java @@ -0,0 +1,46 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.in.ErrorMessage; + +/** + * Interpreter for: + * + * Unknown packet / error (S4/S5 -> PC). + * + * The last received packet from the PC was of an unknown time and caused a general ERROR reply + * to be issued. + * + * [E][RROR] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ErrorMessageInterpreter extends AbstractMessageInterpreter { + + /* Single instance of an error message. */ + private final static ErrorMessage ERROR_MESSAGE = new ErrorMessage(); + + + @Override + public String getMessageIdentifier() { + return "ERROR"; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof ErrorMessage); + } + + @Override + public ErrorMessage decode(String msg) { + return ERROR_MESSAGE; + } + + @Override + public String encode(ErrorMessage msg) { + throw new IllegalStateException("This type of message can not be send to the WaterRower."); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ExitCommunicationMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ExitCommunicationMessageInterpreter.java new file mode 100644 index 0000000000..4e56e032f6 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ExitCommunicationMessageInterpreter.java @@ -0,0 +1,42 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.out.ExitCommunicationMessage; + +/** + * Interpreter for: + * + * Application is exiting (PC -> S4/S5). + * + * Any application wishing to normally terminate (close) is required to send this packet to stop + * the automatic packets being sent to the PC. + * + * [E][XIT] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ExitCommunicationMessageInterpreter extends AbstractMessageInterpreter { + + @Override + public String getMessageIdentifier() { + return null; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof ExitCommunicationMessage); + } + + @Override + public ExitCommunicationMessage decode(String msg) { + throw new IllegalStateException("This type of message should not be send by WaterRower S4/S5 monitor to the PC."); + } + + @Override + public String encode(ExitCommunicationMessage msg) { + return "EXIT"; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/HardwareTypeMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/HardwareTypeMessageInterpreter.java new file mode 100644 index 0000000000..ac0cc567b9 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/HardwareTypeMessageInterpreter.java @@ -0,0 +1,44 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.in.HardwareTypeMessage; + +/** + * Interpreter for: + * + * Hardware Type (S4/S5 -> PC). + * + * The WaterRower will reply with this packet when it receives a "USB" packet and will then + * proceed to send other packets accordingly until it switch’s off or the application issues an + * exit packet. + * + * [_][WR_] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class HardwareTypeMessageInterpreter extends AbstractMessageInterpreter { + + @Override + public String getMessageIdentifier() { + return "_"; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof HardwareTypeMessage); + } + + @Override + public HardwareTypeMessage decode(String msg) { + boolean isWaterRower = msg.startsWith("_WR_"); + return new HardwareTypeMessage(isWaterRower); + } + + @Override + public String encode(HardwareTypeMessage msg) { + throw new IllegalStateException("This type of message can not be send to the WaterRower S4/S5 monitor."); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/InformationRequestMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/InformationRequestMessageInterpreter.java new file mode 100644 index 0000000000..de09a39ae7 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/InformationRequestMessageInterpreter.java @@ -0,0 +1,199 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.InformationRequestMessage; +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.io.msg.in.ModelInformationMessage; +import de.tbressler.waterrower.io.msg.out.ReadMemoryMessage; +import de.tbressler.waterrower.io.msg.out.RequestModelInformationMessage; +import de.tbressler.waterrower.log.Log; +import de.tbressler.waterrower.model.ModelInformation; +import de.tbressler.waterrower.model.MonitorType; + +import static de.tbressler.waterrower.model.MonitorType.*; +import static de.tbressler.waterrower.utils.MessageUtils.achToInt; +import static de.tbressler.waterrower.utils.MessageUtils.intToAch; + +/** + * Interpreter for: + * + * Request Model Information (PC -> S4/S5). + * + * Request details from the rowing computer on what it is and firmware version. + * + * [I][V?] + 0x0D0A + * + * Current Model Information (S4/S5 -> PC). + * + * Details of what unit is attached: + * - Model - Sent as 4 or 5 to indicate if it is a Series 4 or series 5 rowing computer. + * - Version high - 02 as an example for version 2.00 MSB of the firmware version. + * - Version low - 00 as an example for version 2.00 LSB of the firmware version. + * + * [I][V] + [Model] + [Version High] + [Version Low] + 0x0D0A + * + * Value from single memory location: + * + * Returns the single byte of data Y1 from location XXX for the users application. + * + * [I][DS] + XXX + Y1 + 0x0D0A + * + * Value from double memory locations: + * + * Returns two bytes of data starting from the second location first (Y2) then location XXX (Y1). + * This is for reading 16bit values which have (H)igh and (L)ow pair in one go. + * + * [I][DD] + XXX + Y2 + Y1 + 0x0D0A + * + * Value from triple memory locations: + * + * Returns three bytes of data starting from the third location first (Y3) then (Y2) to location + * XXX (Y1). This is for reading 24bit values like a clock, which has Hours, Minutes & Seconds. + * + * [I][DT] + XXX + Y3 + Y2 + Y1 + 0x0D0A + * + * Read a single memory location: + * + * Requests the contents of a single location XXX, this will return a single byte in hex format. + * + * [I][RS] + XXX + 0x0D0A + * + * Read double memory locations: + * + * Requests the contents of two location starting from XXX, this will return two bytes in hex format. + * + * [I][RD] + XXX + 0x0D0A + * + * Read triple memory locations: + * + * Requests the contents of three locations starting from XXX, this will return three bytes in hex format. + * + * [I][RT] + XXX + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class InformationRequestMessageInterpreter extends AbstractMessageInterpreter { + + @Override + public String getMessageIdentifier() { + return "I"; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof InformationRequestMessage); + } + + @Override + public InformationRequestMessage decode(String msg) { + + if (msg.startsWith("IV")) { + return decodeModelInformationMessage(msg); + } else if (msg.startsWith("IDS")) { + return decodeSingleMemoryLocation(msg); + } else if (msg.startsWith("IDD")) { + return decodeDoubleMemoryLocation(msg); + } else if (msg.startsWith("IDT")) { + return decodeTripleMemoryLocation(msg); + } + + Log.warn("Message couldn't be decoded!\n" + + " Message was: >" + msg + "<"); + + return null; + } + + /* Parse current model information. */ + private ModelInformationMessage decodeModelInformationMessage(String msg) { + + MonitorType monitorType = parseMonitorType(msg); + String firmwareVersion = msg.substring(3, 5) + "." + msg.substring(5, 7); + + return new ModelInformationMessage(new ModelInformation(monitorType, firmwareVersion)); + } + + /* Parses and returns the monitor type from the given message. */ + private MonitorType parseMonitorType(String payload) { + switch (payload.charAt(2)) { + case '4': + return WATER_ROWER_S4; + case '5': + return WATER_ROWER_S5; + } + return UNKNOWN_MONITOR_TYPE; + } + + /* Parse value from single memory location. */ + private DataMemoryMessage decodeSingleMemoryLocation(String msg) { + + int location = achToInt(msg.substring(3, 6)); + int value1 = achToInt(msg.substring(6, 8)); + + return new DataMemoryMessage(location, value1); + } + + /* Parse values from double memory locations. */ + private DataMemoryMessage decodeDoubleMemoryLocation(String msg) { + + int location = achToInt(msg.substring(3, 6)); + int value2 = achToInt(msg.substring(6, 8)); + int value1 = achToInt(msg.substring(8, 10)); + + return new DataMemoryMessage(location, value2, value1); + } + + /* Parse values from triple memory locations. */ + private DataMemoryMessage decodeTripleMemoryLocation(String msg) { + + int location = achToInt(msg.substring(3, 6)); + int value3 = achToInt(msg.substring(6, 8)); + int value2 = achToInt(msg.substring(8, 10)); + int value1 = achToInt(msg.substring(10, 12)); + + return new DataMemoryMessage(location, value3, value2, value1); + } + + + @Override + public String encode(InformationRequestMessage msg) { + + if (msg instanceof RequestModelInformationMessage) { + return "IV?"; + } else if (msg instanceof ReadMemoryMessage) { + return encodeReadMemoryMessage((ReadMemoryMessage) msg); + } + + Log.warn("Message couldn't be encoded!\n" + + " Message was: " + msg); + + return null; + } + + /* Encodes messages of type ReadMemoryMessage. */ + private String encodeReadMemoryMessage(ReadMemoryMessage msg) { + String result = "IR"; + + switch (msg.getMemory()) { + case SINGLE_MEMORY: + result += "S"; + break; + case DOUBLE_MEMORY: + result += "D"; + break; + case TRIPLE_MEMORY: + result += "T"; + break; + default: + Log.warn("Message contains invalid values!\n" + + " Message was: "+msg.toString()); + return null; + } + + result += intToAch(msg.getLocation(), 3); + + return result; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/PingMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/PingMessageInterpreter.java new file mode 100644 index 0000000000..fe25f423e2 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/PingMessageInterpreter.java @@ -0,0 +1,46 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.in.PingMessage; + +/** + * Interpreter for: + * + * Ping (S4/S5 -> PC). + * + * Sent once a second while NO rowing is occurring to indicate to the PC the rowing monitor is + * still operational but stopped. + * + * [P][ING] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class PingMessageInterpreter extends AbstractMessageInterpreter { + + /* Single instance of an acknowledgment message. */ + private final static PingMessage PING_MESSAGE = new PingMessage(); + + + @Override + public String getMessageIdentifier() { + return "PING"; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof PingMessage); + } + + @Override + public PingMessage decode(String msg) { + return PING_MESSAGE; + } + + @Override + public String encode(PingMessage msg) { + throw new IllegalStateException("This type of message can not be send to the WaterRower S4/S5 monitor."); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/PulseCountMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/PulseCountMessageInterpreter.java new file mode 100644 index 0000000000..64da25e1f3 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/PulseCountMessageInterpreter.java @@ -0,0 +1,71 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.in.PulseCountMessage; +import de.tbressler.waterrower.log.Log; + +import static de.tbressler.waterrower.utils.MessageUtils.achToInt; + +/** + * Interpreter for: + * + * Pulse Count in the last 25mS (S4/S5 -> PC). + * + * This packet is auto transmitted by the rowing computer. + * + * "XX" is an ACH value representing the number of pulse’s counted during the last 25mS + * period; this value can range from 1 to 50 typically. (Zero values will not be transmitted). + * Please refer to "WaterRower Series 4 Rowing Algorithm.doc" for in depth details on how to use + * this data. At this time the constant values are: + * + * pins_per_xxcm 32 ; number of pin edges allowed to equal xxcm (dec) + * distance_xxcm 35 ; number of cm per flagged xxcm no. of pins (dec) + * + * This packet has the third highest priority of transmission on the USB. + * + * [P] + XX + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class PulseCountMessageInterpreter extends AbstractMessageInterpreter { + + @Override + public String getMessageIdentifier() { + return "P"; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof PulseCountMessage); + } + + @Override + public PulseCountMessage decode(String msg) { + + // Workaround: Discard ping messages, because + // they also start with a 'P'. + if (msg.startsWith("PING")) + return null; + + if (msg.length() < 3) + return null; + + try { + + String pulsesCount = msg.substring(1, 3); + return new PulseCountMessage(achToInt(pulsesCount)); + + } catch (NumberFormatException e) { + Log.error("Couldn't parse ACH value from message!", e); + return null; + } + } + + @Override + public String encode(PulseCountMessage msg) { + throw new IllegalStateException("This type of message can not be send to the WaterRower S4/S5 monitor."); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ResetMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ResetMessageInterpreter.java new file mode 100644 index 0000000000..e41498d631 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/ResetMessageInterpreter.java @@ -0,0 +1,43 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.out.ResetMessage; + +/** + * Interpreter for: + * + * Request the rowing computer to reset (PC -> S4/S5). + * + * Request the rowing computer to perform a reset; this will be identical to the user performing + * this with the power button. Used prior to configuring the rowing computer from a PC. + * Interactive mode will be disabled on a reset. + * + * [R][ESET] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ResetMessageInterpreter extends AbstractMessageInterpreter { + + @Override + public String getMessageIdentifier() { + return null; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof ResetMessage); + } + + @Override + public ResetMessage decode(String msg) { + throw new IllegalStateException("This type of message should not be send by WaterRower S4/S5 monitor to the PC."); + } + + @Override + public String encode(ResetMessage msg) { + return "RESET"; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/StartCommunicationMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/StartCommunicationMessageInterpreter.java new file mode 100644 index 0000000000..dcff75db3a --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/StartCommunicationMessageInterpreter.java @@ -0,0 +1,42 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.out.StartCommunicationMessage; + +/** + * Interpreter for: + * + * Application starting communication's (PC -> S4/S5). + * + * This is the very first packet sent by an application once the COM port is opened, this will + * tell the rowing computer to reply with its hardware type packet. + * + * [U][SB] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class StartCommunicationMessageInterpreter extends AbstractMessageInterpreter { + + @Override + public String getMessageIdentifier() { + return null; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof StartCommunicationMessage); + } + + @Override + public StartCommunicationMessage decode(String msg) { + throw new IllegalStateException("This type of message should not be send by WaterRower S4/S5 monitor to the PC."); + } + + @Override + public String encode(StartCommunicationMessage msg) { + return "USB"; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/interpreter/StrokeMessageInterpreter.java b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/StrokeMessageInterpreter.java new file mode 100644 index 0000000000..fbb43a0730 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/interpreter/StrokeMessageInterpreter.java @@ -0,0 +1,69 @@ +package de.tbressler.waterrower.io.msg.interpreter; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.AbstractMessageInterpreter; +import de.tbressler.waterrower.io.msg.in.StrokeMessage; + +import static de.tbressler.waterrower.model.StrokeType.END_OF_STROKE; +import static de.tbressler.waterrower.model.StrokeType.START_OF_STROKE; + +/** + * Interpreter for: + * + * Stroke start/end (S4/S5 -> PC). + * + * This packet is auto transmitted by the rowing computer. + * + * Start of strokeType: + * + * Start of strokeType pull to show when the rowing computer determined acceleration occurring in the + * paddle. This packet has the highest priority of transmission on the USB. + * + * [S][S] + 0x0D0A + * + * End of strokeType: + * + * End of strokeType pull to show when the rowing computer determined deceleration occurring in the + * paddle. (Now entered the relax phase). This packet has the second highest priority of + * transmission on the USB. + * + * [S][E] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class StrokeMessageInterpreter extends AbstractMessageInterpreter { + + /* Single instance of a start of stroke message. */ + private final static StrokeMessage START_OF_STROKE_MESSAGE = new StrokeMessage(START_OF_STROKE); + + /* Single instance of an end of stroke message. */ + private final static StrokeMessage END_OF_STROKE_MESSAGE = new StrokeMessage(END_OF_STROKE); + + + @Override + public String getMessageIdentifier() { + return "S"; + } + + @Override + public boolean isSupported(AbstractMessage msg) { + return (msg instanceof StrokeMessage); + } + + @Override + public StrokeMessage decode(String msg) { + if (msg.startsWith("SS")) { + return START_OF_STROKE_MESSAGE; + } else if (msg.startsWith("SE")) { + return END_OF_STROKE_MESSAGE; + } + return null; + } + + @Override + public String encode(StrokeMessage msg) { + throw new IllegalStateException("This type of message can not be send to the WaterRower S4/S5 monitor."); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/out/ConfigureWorkoutMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/out/ConfigureWorkoutMessage.java new file mode 100644 index 0000000000..f1b11a49ba --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/out/ConfigureWorkoutMessage.java @@ -0,0 +1,207 @@ +package de.tbressler.waterrower.io.msg.out; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.workout.WorkoutUnit; + +import static com.google.common.base.MoreObjects.toStringHelper; +import static de.tbressler.waterrower.io.msg.out.ConfigureWorkoutMessage.MessageType.END_INTERVAL_WORKOUT; +import static java.util.Objects.requireNonNull; + +/** + * This message will configure distance / duration workouts or interval workouts. + * + * Workouts are configured with at least 1 packet for single distance and duration workouts while interval workouts + * require multiple messages to define the total number of intervals. Should any message be incorrectly formatted + * then an ERROR will be issued, failing to complete an interval workout will result in it being scrapped at the + * next PING packet. + * + * This means the application will have a second to download and confirm the whole of a interval workout, the PING + * transmit timer will be set to 0 at the start of the interval workout programming. Because of the need to use the + * PING and rowing is NOT recommended during workout programming the application must warn the user to stop all rowing + * and wait for the PING messages before attempting to load a workout program. + * + * It is also recommended that the rowing computer is RESET prior to downloading any workout, a PING after a reset + * will indicate the rowing computer is ready again for data. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ConfigureWorkoutMessage extends AbstractMessage { + + /** + * Type of workout message. + */ + public enum MessageType { + + /* Configure a single workout. */ + SINGLE_WORKOUT, + + /* Start a interval distance/duration workout. */ + START_INTERVAL_WORKOUT, + + /* Add an interval to a workout. */ + ADD_INTERVAL_WORKOUT, + + /* End configuration of an interval workout. */ + END_INTERVAL_WORKOUT + + } + + /* The type of workout message. */ + private final MessageType messageType; + + /* The distance / duration of the workout or the workout interval. */ + private final int distance; + + /* The unit of the workout or the workout interval. */ + private final WorkoutUnit unit; + + /* The rest interval. */ + private final int restInterval; + + + /** + * This message will configure distance / duration workouts or interval workouts. + * + * @param messageType The type of message (single workout or start interval workout). Do not use this constructor + * for add/end interval workout messages. + * @param distance The distance (in meters/strokes) or duration (in seconds) of the workout. When unit = METERS, + * MILES or KMS: this value is in Meters, the display value for miles is a conversion and valid + * values are 0x0001 to 0xFA00. When unit = STROKES this value is the number of strokes and valid + * values are 0x0001 to 0x1388. When unit = SECONDS this value is in seconds. Valid values are 0x0001 + * to 0x4650. This value is limited to 5 Hours, which is 18,000 seconds. + * @param unit The unit of the workout, must not be null. + */ + public ConfigureWorkoutMessage(MessageType messageType, int distance, WorkoutUnit unit) { + this.messageType = requireNonNull(messageType); + this.distance = checkDistance(messageType, distance, unit); + this.unit = requireNonNull(unit); + this.restInterval = checkRestInterval(messageType, -1); + } + + /** + * This message will configure distance / duration workouts or interval workouts. + * + * @param messageType The type of message (add or end interval workout). Do not use this constructor + * for single workout or start interval workout messages. + * @param distance The distance (in meters/strokes) or duration (in seconds) of the workout. When unit = METERS, + * MILES or KMS: this value is in Meters, the display value for miles is a conversion and valid + * values are 0x0001 to 0xFA00. When unit = STROKES this value is the number of strokes and valid + * values are 0x0001 to 0x1388. When unit = SECONDS this value is in seconds. Valid values are 0x0001 + * to 0x4650. This value is limited to 5 Hours, which is 18,000 seconds. + * @param unit The unit of the workout, must not be null. + * @param restInterval The rest interval (in seconds), which must be set for add or end interval workout messages. + * Valid values are 0x0001 to 0x0E10 (and 0xFFFF for end interval workout). + */ + public ConfigureWorkoutMessage(MessageType messageType, int distance, WorkoutUnit unit, int restInterval) { + this.messageType = requireNonNull(messageType); + this.distance = checkDistance(messageType, distance, unit); + this.unit = requireNonNull(unit); + this.restInterval = checkRestInterval(messageType, restInterval); + } + + private int checkRestInterval(MessageType messageType, int restInterval) { + switch(messageType) { + case SINGLE_WORKOUT: + case START_INTERVAL_WORKOUT: + // Ignore rest interval, because SINGLE_WORKOUT or START_INTERVAL_WORKOUT does + // not have a rest interval. + break; + case ADD_INTERVAL_WORKOUT: + if ((restInterval < 0x0001) || (restInterval > 0x0E10)) + throw new IllegalArgumentException("The rest interval must be between 0x0001 and 0x0E10!"); + break; + case END_INTERVAL_WORKOUT: + if (restInterval != 0xFFFF) + throw new IllegalArgumentException("The rest interval for an END_INTERVAL_WORKOUT message must be 0xFFFF!"); + break; + } + return restInterval; + } + + /* Check if distance is in range. */ + private int checkDistance(MessageType messageType, int distance, WorkoutUnit unit) { + if ((messageType == END_INTERVAL_WORKOUT) && (distance == 0xFFFF)) + return distance; + switch(unit) { + case METERS: + case MILES: + case KMS: + // When unit = METERS, MILES or KMS: this value is in Meters, the display value for + // miles is a conversion and valid values are 0x0001 to 0xFA00. + if ((distance < 0x0001) || (distance > 0xFA00)) + throw new IllegalArgumentException("The distance of the workout must be between 0x0001 and 0xFA00!"); + break; + case STROKES: + // When unit = STROKES this value is the number of strokes and valid values are + // 0x0001 to 0x1388. + if ((distance < 0x0001) || (distance > 0x1388)) + throw new IllegalArgumentException("The distance of the workout must be between 0x0001 and 0x1388!"); + break; + case SECONDS: + // When unit = SECONDS this value is in seconds. Valid values are 0x0001 to 0x4650. This value is limited + // to 5 Hours, which is 18,000 seconds. + if ((distance < 0x0001) || (distance > 0x4650)) + throw new IllegalArgumentException("The duration of the workout must be between 0x0001 and 0x4650!"); + break; + } + return distance; + } + + + /** + * Returns the message type. + * + * @return The message type. + */ + public MessageType getMessageType() { + return messageType; + } + + + /** + * Returns the distance (in meters/strokes) or duration (in seconds) of the workout or the workout interval. When + * unit = METERS, MILES or KMS: this value is in Meters, the display value for miles is a conversion + * and valid values are 0x0001 to 0xFA00. When unit = STROKES this value is the number of strokes and valid values are + * 0x0001 to 0x1388. When unit = SECONDS this value is in seconds. Valid values are 0x0001 to 0x4650. This value is + * limited to 5 Hours, which is 18,000 seconds. + * + * @return The distance (in meters/strokes) or duration (in seconds). + */ + public int getDistance() { + return distance; + } + + + /** + * The unit of the workout or workout interval (e.g. meters, seconds). + * + * @return The unit of the workout. + */ + public WorkoutUnit getWorkoutUnit() { + return unit; + } + + + /** + * Returns the rest interval (in seconds). The rest interval is only used for add or end interval workout + * messages. Valid values are 0x0001 to 0x0E10 (and 0xFFFF for end interval workout). + * + * @return The rest interval (in seconds). + */ + public int getRestInterval() { + return restInterval; + } + + + @Override + public String toString() { + return toStringHelper(this) + .add("messageType", messageType) + .add("distance", distance) + .add("unit", unit) + .add("restInterval", restInterval) + .toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/out/ExitCommunicationMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/out/ExitCommunicationMessage.java new file mode 100644 index 0000000000..2ec85f6e7d --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/out/ExitCommunicationMessage.java @@ -0,0 +1,25 @@ +package de.tbressler.waterrower.io.msg.out; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; + +/** + * Application is exiting (PC -> S4/S5). + * + * Any application wishing to normally terminate (close) is required to send this packet to stop + * the automatic packets being sent to the PC. + * + * [E][XIT] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ExitCommunicationMessage extends AbstractMessage { + + @Override + public String toString() { + return toStringHelper(this).toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/out/ReadMemoryMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/out/ReadMemoryMessage.java new file mode 100644 index 0000000000..9e44937f0c --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/out/ReadMemoryMessage.java @@ -0,0 +1,92 @@ +package de.tbressler.waterrower.io.msg.out; + +import de.tbressler.waterrower.io.msg.InformationRequestMessage; +import de.tbressler.waterrower.io.msg.Memory; + +import static com.google.common.base.MoreObjects.toStringHelper; +import static java.util.Objects.requireNonNull; + +/** + * Read memory locations (PC -> S4/S5). + * + * Read a single memory location: + * + * Requests the contents of a single location XXX, this will return a single byte in hex format. + * + * [I][RS] + XXX + 0x0D0A + * + * Read double memory locations: + * + * Requests the contents of two location starting from XXX, this will return two bytes in hex format. + * + * [I][RD] + XXX + 0x0D0A + * + * Read triple memory locations: + * + * Requests the contents of three locations starting from XXX, this will return three bytes in hex format. + * + * [I][RT] + XXX + 0x0D0A + * + * XXX is in ACH format and has a maximum range of 0x000 to 0xFFF, however not all locations are + * available (see Memory Map), errors will be replied for out of spec memory reads. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ReadMemoryMessage extends InformationRequestMessage { + + + /* The memory location (0 .. 4095). */ + private final int location; + + /* Defines if you want to read single, double or triple memory locations. */ + private final Memory memory; + + + /** + * This message requests the contents of a single location XXX, this will return a single + * byte in hex format. + * + * @param memory Define if you want to read single, double or triple memory locations, must + * not be null. + * @param location The memory location (0 .. 4095), please refer to memory map of the Water + * Rower monitor. + */ + public ReadMemoryMessage(Memory memory, int location) { + if ((location < 0) || (location > 4095)) + throw new IllegalArgumentException("The value for the memory location must be between 0 and 4095!"); + this.memory = requireNonNull(memory); + this.location = location; + } + + + /** + * Returns the memory location (0 .. 4095), please refer to memory map of the WaterRower + * monitor. + * + * @return The memory location (0 .. 4095). + */ + public int getLocation() { + return location; + } + + + /** + * Returns if this message reads from single, double or triple memory locations. + * + * @return Single, double or triple memory locations. + */ + public Memory getMemory() { + return memory; + } + + + @Override + public String toString() { + return toStringHelper(this) + .add("memory", memory) + .add("location", location) + .toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/out/RequestModelInformationMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/out/RequestModelInformationMessage.java new file mode 100644 index 0000000000..64310b734a --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/out/RequestModelInformationMessage.java @@ -0,0 +1,24 @@ +package de.tbressler.waterrower.io.msg.out; + +import de.tbressler.waterrower.io.msg.InformationRequestMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; + +/** + * Request Model Information (PC -> S4/S5). + * + * Request details from the rowing computer on what it is and firmware version. + * + * [I][V?] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class RequestModelInformationMessage extends InformationRequestMessage { + + @Override + public String toString() { + return toStringHelper(this).toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/out/ResetMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/out/ResetMessage.java new file mode 100644 index 0000000000..fe93e5c8ff --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/out/ResetMessage.java @@ -0,0 +1,26 @@ +package de.tbressler.waterrower.io.msg.out; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; + +/** + * Request the rowing computer to reset (PC -> S4/S5). + * + * Request the rowing computer to perform a reset; this will be identical to the user performing + * this with the power button. Used prior to configuring the rowing computer from a PC. + * Interactive mode will be disabled on a reset. + * + * [R][ESET] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ResetMessage extends AbstractMessage { + + @Override + public String toString() { + return toStringHelper(this).toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/msg/out/StartCommunicationMessage.java b/src/android/src/de/tbressler/waterrower/io/msg/out/StartCommunicationMessage.java new file mode 100644 index 0000000000..d019a2953b --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/msg/out/StartCommunicationMessage.java @@ -0,0 +1,25 @@ +package de.tbressler.waterrower.io.msg.out; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +import static com.google.common.base.MoreObjects.toStringHelper; + +/** + * Application starting communication's (PC -> S4/S5). + * + * This is the very first packet sent by an application once the COM port is opened, this will + * tell the rowing computer to reply with its hardware type packet. + * + * [U][SB] + 0x0D0A + * + * @author Tobias Bressler + * @version 1.0 + */ +public class StartCommunicationMessage extends AbstractMessage { + + @Override + public String toString() { + return toStringHelper(this).toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/io/transport/DefaultSerialChannelConfig.java b/src/android/src/de/tbressler/waterrower/io/transport/DefaultSerialChannelConfig.java new file mode 100644 index 0000000000..0d04c94df7 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/transport/DefaultSerialChannelConfig.java @@ -0,0 +1,221 @@ +/* + * Copyright 2017 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package de.tbressler.waterrower.io.transport; + +import io.netty.buffer.ByteBufAllocator; +import io.netty.channel.ChannelOption; +import io.netty.channel.DefaultChannelConfig; +import io.netty.channel.MessageSizeEstimator; +import io.netty.channel.RecvByteBufAllocator; + +import java.util.Map; + +import static de.tbressler.waterrower.io.transport.SerialChannelOption.*; + +/** + * Default configuration class for jSerialComm device connections. + */ +final class DefaultSerialChannelConfig extends DefaultChannelConfig implements SerialChannelConfig { + + private volatile int baudrate = 115200; + private volatile Stopbits stopbits = Stopbits.STOPBITS_1; + private volatile int databits = 8; + private volatile Paritybit paritybit = Paritybit.NONE; + private volatile int waitTime; + private volatile int readTimeout = 1000; + + DefaultSerialChannelConfig(SerialChannel channel) { + super(channel); + } + + @Override + public Map, Object> getOptions() { + return getOptions(super.getOptions(), BAUD_RATE, STOP_BITS, DATA_BITS, PARITY_BIT, WAIT_TIME); + } + + @SuppressWarnings("unchecked") + @Override + public T getOption(ChannelOption option) { + if (option == BAUD_RATE) { + return (T) Integer.valueOf(getBaudrate()); + } + if (option == STOP_BITS) { + return (T) getStopbits(); + } + if (option == DATA_BITS) { + return (T) Integer.valueOf(getDatabits()); + } + if (option == PARITY_BIT) { + return (T) getParitybit(); + } + if (option == WAIT_TIME) { + return (T) Integer.valueOf(getWaitTimeMillis()); + } + if (option == READ_TIMEOUT) { + return (T) Integer.valueOf(getReadTimeout()); + } + return super.getOption(option); + } + + @Override + public boolean setOption(ChannelOption option, T value) { + validate(option, value); + + if (option == BAUD_RATE) { + setBaudrate((Integer) value); + } else if (option == STOP_BITS) { + setStopbits((Stopbits) value); + } else if (option == DATA_BITS) { + setDatabits((Integer) value); + } else if (option == PARITY_BIT) { + setParitybit((Paritybit) value); + } else if (option == WAIT_TIME) { + setWaitTimeMillis((Integer) value); + } else if (option == READ_TIMEOUT) { + setReadTimeout((Integer) value); + } else { + return super.setOption(option, value); + } + return true; + } + + @Override + public SerialChannelConfig setBaudrate(final int baudrate) { + this.baudrate = baudrate; + return this; + } + + @Override + public SerialChannelConfig setStopbits(final Stopbits stopbits) { + this.stopbits = stopbits; + return this; + } + + @Override + public SerialChannelConfig setDatabits(final int databits) { + this.databits = databits; + return this; + } + + @Override + public SerialChannelConfig setParitybit(final Paritybit paritybit) { + this.paritybit = paritybit; + return this; + } + + @Override + public int getBaudrate() { + return baudrate; + } + + @Override + public Stopbits getStopbits() { + return stopbits; + } + + @Override + public int getDatabits() { + return databits; + } + + @Override + public Paritybit getParitybit() { + return paritybit; + } + + + @Override + public int getWaitTimeMillis() { + return waitTime; + } + + @Override + public SerialChannelConfig setWaitTimeMillis(final int waitTimeMillis) { + if (waitTimeMillis < 0) { + throw new IllegalArgumentException("Wait time must be >= 0"); + } + waitTime = waitTimeMillis; + return this; + } + + @Override + public SerialChannelConfig setReadTimeout(int readTimeout) { + if (readTimeout < 0) { + throw new IllegalArgumentException("readTime must be >= 0"); + } + this.readTimeout = readTimeout; + return this; + } + + @Override + public int getReadTimeout() { + return readTimeout; + } + + @Override + public SerialChannelConfig setConnectTimeoutMillis(int connectTimeoutMillis) { + super.setConnectTimeoutMillis(connectTimeoutMillis); + return this; + } + + @Override + public SerialChannelConfig setWriteSpinCount(int writeSpinCount) { + super.setWriteSpinCount(writeSpinCount); + return this; + } + + @Override + public SerialChannelConfig setAllocator(ByteBufAllocator allocator) { + super.setAllocator(allocator); + return this; + } + + @Override + public SerialChannelConfig setRecvByteBufAllocator(RecvByteBufAllocator allocator) { + super.setRecvByteBufAllocator(allocator); + return this; + } + + @Override + public SerialChannelConfig setAutoRead(boolean autoRead) { + super.setAutoRead(autoRead); + return this; + } + + @Override + public SerialChannelConfig setAutoClose(boolean autoClose) { + super.setAutoClose(autoClose); + return this; + } + + @Override + public SerialChannelConfig setWriteBufferHighWaterMark(int writeBufferHighWaterMark) { + super.setWriteBufferHighWaterMark(writeBufferHighWaterMark); + return this; + } + + @Override + public SerialChannelConfig setWriteBufferLowWaterMark(int writeBufferLowWaterMark) { + super.setWriteBufferLowWaterMark(writeBufferLowWaterMark); + return this; + } + + @Override + public SerialChannelConfig setMessageSizeEstimator(MessageSizeEstimator estimator) { + super.setMessageSizeEstimator(estimator); + return this; + } +} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/io/transport/SerialChannel.java b/src/android/src/de/tbressler/waterrower/io/transport/SerialChannel.java new file mode 100644 index 0000000000..7643fc800c --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/transport/SerialChannel.java @@ -0,0 +1,290 @@ +/* + * Copyright 2017 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package de.tbressler.waterrower.io.transport; + +import com.hoho.android.usbserial.driver.UsbSerialPort; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelPromise; +import io.netty.channel.oio.OioByteStreamChannel; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.SocketAddress; +import java.util.concurrent.TimeUnit; + +import static de.tbressler.waterrower.io.transport.SerialChannelOption.*; + + +/** + * A channel to a serial device. + * + * On Android, USB CDC-ACM devices (like the WaterRower S4/S5 USB monitor) are not exposed as + * kernel tty nodes to unprivileged apps, so jSerialComm can not open them directly. Instead, the + * caller (see WaterRowerBridge) opens the device with the usb-serial-for-android library and + * hands the already-open {@link UsbSerialPort} to this channel via {@link #setUsbSerialPort}. + */ +public class SerialChannel extends OioByteStreamChannel { + + private static final SerialDeviceAddress LOCAL_ADDRESS = new SerialDeviceAddress("localhost"); + + /* The maximum number of bytes attempted per single read, matches typical USB full-speed + bulk endpoint packet size. */ + private static final int READ_CHUNK_SIZE = 64; + + /* Milliseconds to wait for data on each poll of the USB port before retrying. */ + private static final int READ_POLL_TIMEOUT_MS = 500; + + /* Milliseconds to wait for a write to complete. */ + private static final int WRITE_TIMEOUT_MS = 2000; + + /* The USB serial port that was opened by the caller before connect() was invoked. */ + private static volatile UsbSerialPort pendingUsbSerialPort; + + /** + * Sets the already-open USB serial port that will be used by the next {@link #doConnect} + * call. Must be called right before triggering the connection. + * + * @param port The opened USB serial port, or null to clear it. + */ + public static void setUsbSerialPort(UsbSerialPort port) { + pendingUsbSerialPort = port; + } + + private final SerialChannelConfig config; + + private boolean open = true; + private SerialDeviceAddress deviceAddress; + private UsbSerialPort usbSerialPort; + private UsbSerialInputStream usbSerialInputStream; + + public SerialChannel() { + super(null); + + config = new DefaultSerialChannelConfig(this); + config.setReadTimeout(3000); + config.setAutoClose(true); + } + + @Override + public SerialChannelConfig config() { + return config; + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + protected AbstractUnsafe newUnsafe() { + return new JSCUnsafe(); + } + + @Override + protected void doConnect(SocketAddress remoteAddress, SocketAddress localAddress) throws Exception { + SerialDeviceAddress remote = (SerialDeviceAddress) remoteAddress; + + UsbSerialPort port = pendingUsbSerialPort; + pendingUsbSerialPort = null; + if (port == null || !port.isOpen()) { + throw new IOException("Could not open port: " + remote.value()); + } + + deviceAddress = remote; + usbSerialPort = port; + } + + protected void doInit() throws Exception { + usbSerialInputStream = new UsbSerialInputStream(usbSerialPort); + activate(usbSerialInputStream, new UsbSerialOutputStream(usbSerialPort)); + } + + @Override + public SerialDeviceAddress localAddress() { + return (SerialDeviceAddress) super.localAddress(); + } + + @Override + public SerialDeviceAddress remoteAddress() { + return (SerialDeviceAddress) super.remoteAddress(); + } + + @Override + protected SerialDeviceAddress localAddress0() { + return LOCAL_ADDRESS; + } + + @Override + protected SerialDeviceAddress remoteAddress0() { + return deviceAddress; + } + + @Override + protected void doBind(SocketAddress localAddress) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + protected void doDisconnect() throws Exception { + doClose(); + } + + @Override + protected void doClose() throws Exception { + open = false; + if (usbSerialInputStream != null) { + usbSerialInputStream.close(); + } + try { + super.doClose(); + } finally { + if (usbSerialPort != null) { + try { + usbSerialPort.close(); + } catch (IOException ignored) { + // Already closed/detached. + } + usbSerialPort = null; + } + } + } + + @Override + protected boolean isInputShutdown() { + return !open; + } + + @Override + protected ChannelFuture shutdownInput() { + return newFailedFuture(new UnsupportedOperationException("shutdownInput")); + } + + + private final class JSCUnsafe extends AbstractUnsafe { + @Override + public void connect( + final SocketAddress remoteAddress, + final SocketAddress localAddress, final ChannelPromise promise) { + if (!promise.setUncancellable() || !isOpen()) { + return; + } + + try { + final boolean wasActive = isActive(); + doConnect(remoteAddress, localAddress); + + int waitTime = config().getOption(WAIT_TIME); + if (waitTime > 0) { + eventLoop().schedule(new Runnable() { + @Override + public void run() { + try { + doInit(); + safeSetSuccess(promise); + if (!wasActive && isActive()) { + pipeline().fireChannelActive(); + } + } catch (Throwable t) { + safeSetFailure(promise, t); + closeIfClosed(); + } + } + }, waitTime, TimeUnit.MILLISECONDS); + } else { + doInit(); + safeSetSuccess(promise); + if (!wasActive && isActive()) { + pipeline().fireChannelActive(); + } + } + } catch (Throwable t) { + safeSetFailure(promise, t); + closeIfClosed(); + } + } + } + + + /** + * Bridges the blocking {@link UsbSerialPort#read(byte[], int)} API to an {@link InputStream}. + * Since {@link OioByteStreamChannel} only calls {@link #read(byte[], int, int)} when + * {@link #available()} reports a positive value, this stream always reports a fixed chunk + * size as available, and performs the actual blocking wait inside {@link #read(byte[], int, int)}. + */ + private static final class UsbSerialInputStream extends InputStream { + private final UsbSerialPort port; + private volatile boolean closed = false; + + UsbSerialInputStream(UsbSerialPort port) { + this.port = port; + } + + @Override + public int available() { + return closed ? 0 : READ_CHUNK_SIZE; + } + + @Override + public int read() throws IOException { + byte[] single = new byte[1]; + int n = read(single, 0, 1); + return n <= 0 ? -1 : (single[0] & 0xFF); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (len == 0) { + return 0; + } + + byte[] tmp = new byte[len]; + while (!closed && port.isOpen()) { + int n = port.read(tmp, READ_POLL_TIMEOUT_MS); + if (n > 0) { + System.arraycopy(tmp, 0, b, off, n); + return n; + } + } + return -1; + } + + @Override + public void close() { + closed = true; + } + } + + private static final class UsbSerialOutputStream extends OutputStream { + private final UsbSerialPort port; + + UsbSerialOutputStream(UsbSerialPort port) { + this.port = port; + } + + @Override + public void write(int b) throws IOException { + write(new byte[]{(byte) b}, 0, 1); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + byte[] tmp = new byte[len]; + System.arraycopy(b, off, tmp, 0, len); + port.write(tmp, WRITE_TIMEOUT_MS); + } + } +} diff --git a/src/android/src/de/tbressler/waterrower/io/transport/SerialChannelConfig.java b/src/android/src/de/tbressler/waterrower/io/transport/SerialChannelConfig.java new file mode 100644 index 0000000000..acb25c33b0 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/transport/SerialChannelConfig.java @@ -0,0 +1,229 @@ +/* + * Copyright 2017 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package de.tbressler.waterrower.io.transport; + +import com.fazecast.jSerialComm.SerialPort; +import io.netty.buffer.ByteBufAllocator; +import io.netty.channel.ChannelConfig; +import io.netty.channel.MessageSizeEstimator; +import io.netty.channel.RecvByteBufAllocator; + +/** + * A configuration class for JSerialComm device connections. + * + *

Available options

+ * + * In addition to the options provided by {@link ChannelConfig}, + * {@link DefaultSerialChannelConfig} allows the following options in the option map: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
NameAssociated setter method
{@link SerialChannelOption#BAUD_RATE}{@link #setBaudrate(int)}
{@link SerialChannelOption#STOP_BITS}{@link #setStopbits(Stopbits)}
{@link SerialChannelOption#DATA_BITS}{@link #setDatabits(int)}
{@link SerialChannelOption#PARITY_BIT}{@link #setParitybit(Paritybit)}
{@link SerialChannelOption#WAIT_TIME}{@link #setWaitTimeMillis(int)}
+ */ +public interface SerialChannelConfig extends ChannelConfig { + enum Stopbits { + /** + * 1 stop bit will be sent at the end of every character + */ + STOPBITS_1(SerialPort.ONE_STOP_BIT), + /** + * 2 stop bits will be sent at the end of every character + */ + STOPBITS_2(SerialPort.TWO_STOP_BITS), + /** + * 1.5 stop bits will be sent at the end of every character + */ + STOPBITS_1_5(SerialPort.ONE_POINT_FIVE_STOP_BITS); + + private final int value; + + Stopbits(int value) { + this.value = value; + } + + public int value() { + return value; + } + + public static Stopbits valueOf(int value) { + for (Stopbits stopbit : Stopbits.values()) { + if (stopbit.value == value) { + return stopbit; + } + } + throw new IllegalArgumentException("unknown " + Stopbits.class.getSimpleName() + " value: " + value); + } + } + + enum Paritybit { + /** + * No parity bit will be sent with each data character at all + */ + NONE(SerialPort.NO_PARITY), + /** + * An odd parity bit will be sent with each data character, ie. will be set + * to 1 if the data character contains an even number of bits set to 1. + */ + ODD(SerialPort.ODD_PARITY), + /** + * An even parity bit will be sent with each data character, ie. will be set + * to 1 if the data character contains an odd number of bits set to 1. + */ + EVEN(SerialPort.EVEN_PARITY), + /** + * A mark parity bit (ie. always 1) will be sent with each data character + */ + MARK(SerialPort.MARK_PARITY), + /** + * A space parity bit (ie. always 0) will be sent with each data character + */ + SPACE(SerialPort.SPACE_PARITY); + + private final int value; + + Paritybit(int value) { + this.value = value; + } + + public int value() { + return value; + } + + public static Paritybit valueOf(int value) { + for (Paritybit paritybit : Paritybit.values()) { + if (paritybit.value == value) { + return paritybit; + } + } + throw new IllegalArgumentException("unknown " + Paritybit.class.getSimpleName() + " value: " + value); + } + } + + /** + * Sets the baud rate (ie. bits per second) for communication with the serial device. + * The baud rate will include bits for framing (in the form of stop bits and parity), + * such that the effective data rate will be lower than this value. + * + * @param baudrate The baud rate (in bits per second) + */ + SerialChannelConfig setBaudrate(int baudrate); + + /** + * Sets the number of stop bits to include at the end of every character to aid the + * serial device in synchronising with the data. + * + * @param stopbits The number of stop bits to use + */ + SerialChannelConfig setStopbits(Stopbits stopbits); + + /** + * Sets the number of data bits to use to make up each character sent to the serial + * device. + * + * @param databits The number of data bits to use + */ + SerialChannelConfig setDatabits(int databits); + + /** + * Sets the type of parity bit to be used when communicating with the serial device. + * + * @param paritybit The type of parity bit to be used + */ + SerialChannelConfig setParitybit(Paritybit paritybit); + + /** + * @return The configured baud rate, defaulting to 115200 if unset + */ + int getBaudrate(); + + /** + * @return The configured stop bits, defaulting to {@link Stopbits#STOPBITS_1} if unset + */ + Stopbits getStopbits(); + + /** + * @return The configured data bits, defaulting to 8 if unset + */ + int getDatabits(); + + /** + * @return The configured parity bit, defaulting to {@link Paritybit#NONE} if unset + */ + Paritybit getParitybit(); + + /** + * @return The number of milliseconds to wait between opening the serial port and + * initialising. + */ + int getWaitTimeMillis(); + + /** + * Sets the time to wait after opening the serial port and before sending it any + * configuration information or data. A value of 0 indicates that no waiting should + * occur. + * + * @param waitTimeMillis The number of milliseconds to wait, defaulting to 0 (no + * wait) if unset + * @throws IllegalArgumentException if the supplied value is < 0 + */ + SerialChannelConfig setWaitTimeMillis(int waitTimeMillis); + + /** + * Sets the maximal time (in ms) to block while try to read from the serial port. Default is 1000ms + */ + SerialChannelConfig setReadTimeout(int readTimeout); + + /** + * Return the maximal time (in ms) to block and wait for something to be ready to read. + */ + int getReadTimeout(); + + @Override + SerialChannelConfig setConnectTimeoutMillis(int connectTimeoutMillis); + + @Override + SerialChannelConfig setWriteSpinCount(int writeSpinCount); + + @Override + SerialChannelConfig setAllocator(ByteBufAllocator allocator); + + @Override + SerialChannelConfig setRecvByteBufAllocator(RecvByteBufAllocator allocator); + + @Override + SerialChannelConfig setAutoRead(boolean autoRead); + + @Override + SerialChannelConfig setWriteBufferHighWaterMark(int writeBufferHighWaterMark); + + @Override + SerialChannelConfig setWriteBufferLowWaterMark(int writeBufferLowWaterMark); + + @Override + SerialChannelConfig setMessageSizeEstimator(MessageSizeEstimator estimator); +} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/io/transport/SerialChannelOption.java b/src/android/src/de/tbressler/waterrower/io/transport/SerialChannelOption.java new file mode 100644 index 0000000000..ee6c05a2cc --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/transport/SerialChannelOption.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package de.tbressler.waterrower.io.transport; + +import de.tbressler.waterrower.io.transport.SerialChannelConfig.Paritybit; +import de.tbressler.waterrower.io.transport.SerialChannelConfig.Stopbits; +import io.netty.channel.ChannelOption; + +/** + * Option for configuring a serial port connection + */ +public final class SerialChannelOption extends ChannelOption { + + public static final ChannelOption BAUD_RATE = valueOf("BAUD_RATE"); + public static final ChannelOption STOP_BITS = valueOf("STOP_BITS"); + public static final ChannelOption DATA_BITS = valueOf("DATA_BITS"); + public static final ChannelOption PARITY_BIT = valueOf("PARITY_BIT"); + public static final ChannelOption WAIT_TIME = valueOf("WAIT_TIME"); + public static final ChannelOption READ_TIMEOUT = valueOf("READ_TIMEOUT"); + + @SuppressWarnings({ "unused", "deprecation" }) + private SerialChannelOption() { + super(null); + } + +} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/io/transport/SerialDeviceAddress.java b/src/android/src/de/tbressler/waterrower/io/transport/SerialDeviceAddress.java new file mode 100644 index 0000000000..27afe37781 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/transport/SerialDeviceAddress.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017 The Netty Project + * + * The Netty Project licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +package de.tbressler.waterrower.io.transport; + +import java.net.SocketAddress; + +/** + * A {@link SocketAddress} subclass to wrap the serial port address of a jSerialComm + * device (e.g. COM1, /dev/ttyUSB0). + */ +public class SerialDeviceAddress extends SocketAddress { + + private static final long serialVersionUID = -2907820090993709523L; + + private final String value; + + /** + * Creates a JSerialCommDeviceAddress representing the address of the serial port. + * + * @param value the address of the device (e.g. COM1, /dev/ttyUSB0, ...) + */ + public SerialDeviceAddress(String value) { + this.value = value; + } + + /** + * @return The serial port address of the device (e.g. COM1, /dev/ttyUSB0, ...) + */ + public String value() { + return value; + } + +} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/io/utils/ByteUtils.java b/src/android/src/de/tbressler/waterrower/io/utils/ByteUtils.java new file mode 100644 index 0000000000..1d317d1a72 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/io/utils/ByteUtils.java @@ -0,0 +1,26 @@ +package de.tbressler.waterrower.io.utils; + +import io.netty.buffer.ByteBuf; + +/** + * Helper class for byte, byte buffers and byte arrays. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ByteUtils { + + /* Private constructor. */ + private ByteUtils() {} + + /** + * Returns the data of the buffer as String for debug purposes. + * + * @param buffer The byte buffer. + * @return The data of the buffer as String. + */ + public static String bufferToString(ByteBuf buffer) { + return "ByteBuf[index=" + buffer.readerIndex() + ",bytes=" + buffer.readableBytes() + "]"; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/log/Log.java b/src/android/src/de/tbressler/waterrower/log/Log.java new file mode 100644 index 0000000000..1a50ae102d --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/log/Log.java @@ -0,0 +1,42 @@ +package de.tbressler.waterrower.log; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Simple singleton for logging. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class Log { + + /* The logger. */ + private static final Logger logger = LoggerFactory.getLogger("WaterRowerLibrary"); + + + /* Private constructor. */ + private Log() {} + + + /** Logs debug messages. */ + public static void debug(String msg) { + logger.debug(msg); + } + + /** Logs debug messages. */ + public static void info(String msg) { + logger.info(msg); + } + + /** Logs warning messages. */ + public static void warn(String msg) { + logger.warn(msg); + } + + /** Logs error messages. */ + public static void error(String msg, Throwable t) { + logger.error(msg, t); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/model/ErrorCode.java b/src/android/src/de/tbressler/waterrower/model/ErrorCode.java new file mode 100644 index 0000000000..0197e879a3 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/model/ErrorCode.java @@ -0,0 +1,23 @@ +package de.tbressler.waterrower.model; + +/** + * Simple error codes which identifies the type of error. + * + * @author Tobias Bressler + * @version 1.0 + */ +public enum ErrorCode { + + /* The device is not supported. */ + DEVICE_NOT_SUPPORTED, + + /* IO or connection error. */ + COMMUNICATION_FAILED, + + /* Communication timed out (no ping received). */ + TIMEOUT, + + /* The WaterRower monitor sent a error message. */ + ERROR_MESSAGE_RECEIVED, + +} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/model/MemoryLocation.java b/src/android/src/de/tbressler/waterrower/model/MemoryLocation.java new file mode 100644 index 0000000000..455b4dfb31 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/model/MemoryLocation.java @@ -0,0 +1,202 @@ +package de.tbressler.waterrower.model; + +/** + * Memory locations of the WaterRower S4, version 2.00. + * See PDF file 'docs/Water_Rower_S4_S5_USB_Protocol_Iss_1.04.pdf' for more details. + * + * @author Tobias Bressler + * @version 1.0 + */ +public enum MemoryLocation { + + /* + * Flags: + * + * These registers can be read to determine the current state of the display; they are formed of + * 8 separate true or false flags. These flags are defined at the end of the memory map. + */ + + FEXTENDED(0x03e), // working and workout control flags + // bits for extended zones and workout modes: + // 0 = fzone_hr fextended; working in heartrate zone + // 1 = fzone_int fextended; working in intensity zone + // 2 = fzone_sr fextended; working in strokerate zone + // 3 = fprognostics fextended; prognostics active. + // 4 = fworkout_dis fextended; workout distance mode + // 5 = fworkout_dur fextended; workout duration mode + // 6 = fworkout_dis_i fextended; workout distance interval mode + // 7 = fworkout_dur_i fextended; workout duration interval mode + + FMISC_FLAGS(0x049), // zone words and misc windows flags + // 0 = fzone_fg_work fmisc_flags; set when to turn on or if flashed is clear and flash is set + // 1 = fzone_fg_rest fmisc_flags; set when to turn on or if flashed is clear and flash is set + // 2 = fmisc_fg_lowbat fmisc_flags; set when to turn on or if flashed is clear and flash is set + // 3 = fmisc_fg_pc fmisc_flags; set when to turn on or if flashed is clear and flash is set + // 4 = fmisc_fg_line fmisc_flags; set when to turn on or if flashed is clear and flash is set + // 5 = fmisc_fg_mmc_cd fmisc_flags; set when to turn on or if flashed is clear and flash is set + // 6 = fmisc_fg_mmc_up fmisc_flags; set when to turn on or if flashed is clear and flash is set + // 7 = fmisc_fg_mmc_dn fmisc_flags; set when to turn on or if flashed is clear and flash is set + + /* + * Variables: + * + * The following memory locations are available to the user for reading. Other locations not specified are + * unavailable for reading. A lot of the timers count 1bit per 25mS of actual time, remember this for ALL maths, + * otherwise things can be confusing of how much time say a stroke was done IN. + */ + + /* Distance variables: */ + + MS_DISTANCE_DEC(0x054), // 0.1m count (only counts up from 0-9 + MS_DISTANCE_LOW(0x055), // low byte of meters + MS_DISTANCE_HI(0x056), // hi byte of meters and km (65535meters max) + + /* This is the displayed distance: */ + + DISTANCE_LOW(0x057), // low byte of meters + DISTANCE_HI(0x058), // hi byte of meters and km (65535meters max) + + /* Clock count down, this is 16bit value: */ + + CLOCK_DOWN_DEC(0x05a), // seconds 0.9-0.0 + CLOCK_DOWN_LOW(0x05b), // low byte clock count down + CLOCK_DOWN_HI(0x05c), // hi byte clock count down + + + KCAL_WATTS_LOW(0x088), + KCAL_WATTS_HI(0x089), + TOTAL_KCAL_LOW(0x08a), + TOTAL_KCAL_HI(0x08b), + TOTAL_KCAL_UP(0x08c), + + + /* Total distance meter counter - this is stored at switch off: */ + + TOTAL_DIS_DEC(0x080), // dec byte of meters + TOTAL_DIS_LOW(0x081), // low byte of meters + TOTAL_DIS_HI(0x082), // hi byte of meters and km (65535meters max) + + /* Tank volume in liters: */ + + TANK_VOLUME(0x0a9), // volume of water in tank + + /* Stroke counter: */ + + STROKES_CNT_LOW(0x140), // low byte count + STROKES_CNT_HI(0x141), // high byte count + STROKE_AVERAGE(0x142), // average time for a whole stroke + STROKE_PULL(0x143), // average time for a pull (acc to dec) + + // Stroke_pull is first subtracted from stroke_average then a modifier of 1.25 multiplied + // by the result to generate the ratio value for display. + + /* Meters per second registers: */ + + M_S_LOW_TOTAL(0x148), // total distance per second in cm low byte + M_S_HI_TOTAL(0x149), // total distance per second in cm hi byte + M_S_LOW_AVERAGE(0x14a), // instant average distance in cm low byte + M_S_HI_AVERAGE(0x14b), // instant average distance in cm hi byte + M_S_STORED(0x14c), // no. of the stored values. + M_S_PROJL_AVG(0x14d), // all average for projected distance/duration maths + M_S_PROJH_AVG(0x14e), // all average for projected distance/duration maths + + + /* stored values for the zone maths (these are pre display values): */ + + ZONE_HR_VAL(0x1a0), // heart rate stored value + // ... + + /* Used to generate the display clock: */ + + DISPLAY_SEC_DEC(0x1e0), // seconds 0.0-0.9 + DISPLAY_SEC(0x1e1), // seconds 0-59 (send as ACH not byte value) + DISPLAY_MIN(0x1e2), // minutes 0-59 (send as ACH not byte value) + DISPLAY_HR(0x1e3), // hours 0-9 only (send as ACH not byte value) + + /* Workout total times/distances/limits: */ + + WORKOUT_TIMEL(0x1e8), // total workout time + WORKOUT_TIMEH(0x1e9), + WORKOUT_MS_L(0x1ea), // total workout m/s + WORKOUT_MS_H(0x1eb), + WORKOUT_STROKEL(0x1ec), // total workout strokes + WORKOUT_STROKEH(0x1ed), + WORKOUT_LIMIT_L(0x1ee), // this is the limit value for workouts + WORKOUT_LIMIT_H(0x1ef), + + /* + * Interval's: + * + * These are the interval timing's in use or being programmed. + */ + + WORKOUT_WORK1_L(0x1b0), + WORKOUT_WORK1_H(0x1b1), + WORKOUT_REST1_L(0x1b2), + WORKOUT_REST1_H(0x1b3), + + WORKOUT_WORK2_L(0x1b4), + WORKOUT_WORK2_H(0x1b5), + WORKOUT_REST2_L(0x1b6), + WORKOUT_REST2_H(0x1b7), + + WORKOUT_WORK3_L(0x1b8), + WORKOUT_WORK3_H(0x1b9), + WORKOUT_REST3_L(0x1ba), + WORKOUT_REST3_H(0x1bb), + + WORKOUT_WORK4_L(0x1bc), + WORKOUT_WORK4_H(0x1bd), + WORKOUT_REST4_L(0x1be), + WORKOUT_REST4_H(0x1bf), + + WORKOUT_WORK5_L(0x1c0), + WORKOUT_WORK5_H(0x1c1), + WORKOUT_REST5_L(0x1c2), + WORKOUT_REST5_H(0x1c3), + + WORKOUT_WORK6_L(0x1c4), + WORKOUT_WORK6_H(0x1c5), + WORKOUT_REST6_L(0x1c6), + WORKOUT_REST6_H(0x1c7), + + WORKOUT_WORK7_L(0x1c8), + WORKOUT_WORK7_H(0x1c9), + WORKOUT_REST7_L(0x1ca), + WORKOUT_REST7_H(0x1cb), + + WORKOUT_WORK8_L(0x1cc), + WORKOUT_WORK8_H(0x1cd), + WORKOUT_REST8_L(0x1ce), + WORKOUT_REST8_H(0x1cf), + + WORKOUT_WORK9_L(0x1d0), + WORKOUT_WORK9_H(0x1d1), + + WORKOUT_INTER(0x1d9); // No work workout intervals + + + /* The memory location as decimal. */ + private final int location; + + /** + * Constructor of the enum. + * + * @param location The memory location as decimal (0x000 .. 0xFFF). + */ + MemoryLocation(int location) { + if ((location < 0x000) || (location > 0xFFF)) + throw new IllegalArgumentException("Invalid memory location! Location must be between 0x000 and 0xFFF."); + this.location = location; + } + + /** + * Returns the memory location as decimal. + * + * @return The memory location. + */ + public int getLocation() { + return location; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/model/MiscFlags.java b/src/android/src/de/tbressler/waterrower/model/MiscFlags.java new file mode 100644 index 0000000000..c8cacc03fe --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/model/MiscFlags.java @@ -0,0 +1,106 @@ +package de.tbressler.waterrower.model; + +import static de.tbressler.waterrower.utils.MessageUtils.getBooleanFromByte; + +/** + * Zone words and misc windows flags. + * 0 = fzone_fg_work: a workout row interval is active + * 1 = fzone_fg_rest: a workout rest interval is active + * 2 = fmisc_fg_lowbat: set if battery of the Performance Monitor is low + * 3 = fmisc_fg_pc: a PC is connected (this is of course always the case) + * 4 = fmisc_fg_line + * 5 = fmisc_fg_mmc_cd + * 6 = fmisc_fg_mmc_up + * 7 = fmisc_fg_mmc_dn + * + * @author Tobias Bressler + * @version 1.0 + */ +public class MiscFlags { + + /* The flag values as byte. */ + private final int value; + + + /** + * Zone words and misc windows flags. + * + * @param value The flags as byte. + */ + public MiscFlags(int value) { + if ((value < 0x00) || (value > 0xFF)) + throw new IllegalArgumentException("Value must be in range 0x00 to 0xFF!"); + this.value = value; + } + + + /** + * True if a workout row interval or no workout is active. + * + * @return True if a workout row interval or no workout is active. + */ + public boolean isZoneWork() { + return getBooleanFromByte(value, 0); + } + + /** + * True if a workout rest interval is active. + * + * @return True if a workout rest interval is active. + */ + public boolean isZoneRest() { + return getBooleanFromByte(value, 1); + } + + /** + * True if the battery of the Performance Monitor is low. + * + * @return True if the battery is low. + */ + public boolean isBatteryLow() { + return getBooleanFromByte(value, 2); + } + + /** + * True if a PC is connected. + * + * @return True if a PC is connected. + */ + public boolean isPCConnected() { + return getBooleanFromByte(value, 3); + } + + + public boolean isMiscLine() { + return getBooleanFromByte(value, 4); + } + + public boolean isMiscMmcCd() { + return getBooleanFromByte(value, 5); + } + + public boolean isMiscMmcUp() { + return getBooleanFromByte(value, 6); + } + + public boolean isMiscMmcDn() { + return getBooleanFromByte(value, 7); + } + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + MiscFlags that = (MiscFlags) o; + + return value == that.value; + } + + @Override + public int hashCode() { + return value; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/model/ModelInformation.java b/src/android/src/de/tbressler/waterrower/model/ModelInformation.java new file mode 100644 index 0000000000..e907703804 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/model/ModelInformation.java @@ -0,0 +1,61 @@ +package de.tbressler.waterrower.model; + +import static com.google.common.base.MoreObjects.toStringHelper; +import static java.util.Objects.requireNonNull; + +/** + * Model information (details from the rowing computer), e.g. Monitor type / model and firmware version. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class ModelInformation { + + /* The type of monitor (e.g. S4 or S5). */ + private final MonitorType monitorType; + + /* The firmware version of the monitor */ + private final String firmwareVersion; + + + /** + * Current model information. + * + * @param monitorType The type of monitor (e.g. S4 or S5), must not be null. + * @param firmwareVersion The firmware version of the monitor, must not be null. + */ + public ModelInformation(MonitorType monitorType, String firmwareVersion) { + this.monitorType = requireNonNull(monitorType); + this.firmwareVersion = requireNonNull(firmwareVersion); + } + + + /** + * Returns the type of monitor (e.g. S4 or S5). + * + * @return The type of monitor, never null. + */ + public MonitorType getMonitorType() { + return monitorType; + } + + + /** + * The firmware version of the monitor. + * + * @return The firmware version of the monitor, never null. + */ + public String getFirmwareVersion() { + return firmwareVersion; + } + + + @Override + public String toString() { + return toStringHelper(this) + .add("monitorType", monitorType) + .add("firmwareVersion", firmwareVersion) + .toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/model/MonitorType.java b/src/android/src/de/tbressler/waterrower/model/MonitorType.java new file mode 100644 index 0000000000..2bae5f26fb --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/model/MonitorType.java @@ -0,0 +1,20 @@ +package de.tbressler.waterrower.model; + +/** + * The type of monitor. + * + * @author Tobias Bressler + * @version 1.0 + */ +public enum MonitorType { + + /** WaterRower monitor S4. */ + WATER_ROWER_S4, + + /** WaterRower monitor S5. */ + WATER_ROWER_S5, + + /** Unknown type of monitor. */ + UNKNOWN_MONITOR_TYPE + +} diff --git a/src/android/src/de/tbressler/waterrower/model/StrokeType.java b/src/android/src/de/tbressler/waterrower/model/StrokeType.java new file mode 100644 index 0000000000..583b0d1956 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/model/StrokeType.java @@ -0,0 +1,23 @@ +package de.tbressler.waterrower.model; + +/** + * Different types of strokes. + * + * @author Tobias Bressler + * @version 1.0 + */ +public enum StrokeType { + + /** + * Start of stroke pull to show when the rowing computer determined acceleration occurring in + * the paddle. + */ + START_OF_STROKE, + + /** + * End of stroke pull to show when the rowing computer determined deceleration occurring in + * the paddle. (Now entered the relax phase). + */ + END_OF_STROKE + +} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/model/WorkoutFlags.java b/src/android/src/de/tbressler/waterrower/model/WorkoutFlags.java new file mode 100644 index 0000000000..4bd1e95d38 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/model/WorkoutFlags.java @@ -0,0 +1,125 @@ +package de.tbressler.waterrower.model; + +import static de.tbressler.waterrower.utils.MessageUtils.getBooleanFromByte; + +/** + * Working and workout control flags. + * 0 = fzone_hr: working in heartrate zone + * 1 = fzone_int: working in intensity zone + * 2 = fzone_sr: working in strokerate zone + * 3 = fprognostics: prognostics active + * 4 = fworkout_dis: workout distance mode + * 5 = fworkout_dur: workout duration mode + * 6 = fworkout_dis_i: workout distance interval mode + * 7 = fworkout_dur_i: workout duration interval mode + * + * @author Tobias Bressler + * @version 1.0 + */ +public class WorkoutFlags { + + /* The flag values as byte. */ + private final int value; + + + /** + * Working and workout control flags. + * + * @param value The flags as byte. + */ + public WorkoutFlags(int value) { + if ((value < 0x00) || (value > 0xFF)) + throw new IllegalArgumentException("Value must be in range 0x00 to 0xFF!"); + this.value = value; + } + + + /** + * True if working in heartrate zone. + * + * @return True if working in heartrate zone. + */ + public boolean isWorkingInHeartRateZone() { + return getBooleanFromByte(value, 0); + } + + /** + * True if working in intensity zone. + * + * @return True if working in intensity zone. + */ + public boolean isWorkingInIntensityZone() { + return getBooleanFromByte(value, 1); + } + + /** + * True if working in strokerate zone. + * + * @return True if working in strokerate zone. + */ + public boolean isWorkingInStrokeRateZone() { + return getBooleanFromByte(value, 2); + } + + /** + * True if prognostics active. + * + * @return True if prognostics active. + */ + public boolean isPrognosticsActive() { + return getBooleanFromByte(value, 3); + } + + /** + * True if in workout distance mode. + * + * @return True if in workout distance mode. + */ + public boolean isWorkoutDistanceMode() { + return getBooleanFromByte(value, 4); + } + + /** + * True if in workout duration mode. + * + * @return True if in workout duration mode. + */ + public boolean isWorkoutDurationMode() { + return getBooleanFromByte(value, 5); + } + + /** + * True if in workout distance interval mode. + * + * @return True if in workout distance interval mode. + */ + public boolean isWorkoutDistanceIntervalMode() { + return getBooleanFromByte(value, 6); + } + + /** + * True if in workout duration interval mode. + * + * @return True if in workout duration interval mode. + */ + public boolean isWorkoutDurationIntervalMode() { + return getBooleanFromByte(value, 7); + } + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + WorkoutFlags that = (WorkoutFlags) o; + + return value == that.value; + } + + @Override + public int hashCode() { + return value; + } + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/AbstractMemorySubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/AbstractMemorySubscription.java new file mode 100644 index 0000000000..5e38904810 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/AbstractMemorySubscription.java @@ -0,0 +1,105 @@ +package de.tbressler.waterrower.subscriptions; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.Memory; +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.io.msg.out.ReadMemoryMessage; +import de.tbressler.waterrower.log.Log; +import de.tbressler.waterrower.model.MemoryLocation; + +import java.util.concurrent.atomic.AtomicInteger; + +import static com.google.common.base.MoreObjects.toStringHelper; +import static java.util.Objects.requireNonNull; + +/** + * An abstract subscription for memory locations. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class AbstractMemorySubscription implements ISubscription { + + /* The priority. */ + private final Priority priority; + + /* Single, double or triple memory. */ + private final Memory memory; + + /* The memory location. */ + private final MemoryLocation location; + + /* Because of missing incoming messages, count the outgoing messages + until an incoming message was received. */ + private final AtomicInteger counterLatch = new AtomicInteger(0); + + + /** + * An abstract subscription for memory locations. + * + * @param priority The priority, must not be null. + * @param memory Single, double or triple memory. Must not be null. + * @param location The memory location, must not be null. + */ + public AbstractMemorySubscription(Priority priority, Memory memory, MemoryLocation location) { + this.priority = requireNonNull(priority); + this.memory = requireNonNull(memory); + this.location = requireNonNull(location); + } + + @Override + public Priority getPriority() { + return priority; + } + + @Override + public final AbstractMessage poll() { + counterLatch.incrementAndGet(); + return new ReadMemoryMessage(memory, location.getLocation()); + } + + @Override + public final void handle(AbstractMessage msg) { + if (!(msg instanceof DataMemoryMessage)) + return; + + DataMemoryMessage dataMemoryMessage = (DataMemoryMessage) msg; + + // Check if memory location and memory type matches: + if (dataMemoryMessage.getLocation() != location.getLocation()) + return; + if (dataMemoryMessage.getMemory() != memory) { + Log.warn("Received message has memory type '"+dataMemoryMessage.getMemory()+"', but expected is '"+memory+"'!"); + return; + } + + int counter = this.counterLatch.getAndSet(0); + if (counter > 3) { + // If the counter is greater than 1, some polling messages were not answered by + // the WaterRower. This seems to happen when the paddle is not moving and it is not + // a bug of the library. A log message will be created when the counter is + // greater than 3. + Log.debug("Not all messages were answered by the WaterRower (missing "+(counter - 1)+" message(s))."); + } + + handle(dataMemoryMessage); + } + + /** + * Called if a memory message was received, which is for the location and memory type given. + * + * @param msg The message, never null. + */ + abstract protected void handle(DataMemoryMessage msg); + + + @Override + public String toString() { + return toStringHelper(this) + .add("priority", priority) + .add("memory", memory) + .add("location", location) + .toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/DebugSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/DebugSubscription.java new file mode 100644 index 0000000000..fb4c8d7f3b --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/DebugSubscription.java @@ -0,0 +1,21 @@ +package de.tbressler.waterrower.subscriptions; + +import de.tbressler.waterrower.io.msg.Memory; +import de.tbressler.waterrower.model.MemoryLocation; + +/** + * For test purposes only! + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class DebugSubscription extends AbstractMemorySubscription { + + /** + * For test purposes only! + */ + public DebugSubscription(Priority priority, Memory memory, MemoryLocation location) { + super(priority, memory, location); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/ISubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/ISubscription.java new file mode 100644 index 0000000000..21c504105f --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/ISubscription.java @@ -0,0 +1,36 @@ +package de.tbressler.waterrower.subscriptions; + +import de.tbressler.waterrower.io.msg.AbstractMessage; + +/** + * A generic interface for subscriptions. + * + * @author Tobias Bressler + * @version 1.0 + */ +public interface ISubscription { + + /** + * Returns the priority of the subscription. The priority determines how + * often a subscription will be polled. + * + * @return The priority, never null. + */ + Priority getPriority(); + + /** + * Returns the message that must be send to the WaterRower S4/S5 monitor to send the current value + * for the subscription. + * + * @return The poll message or null, if not message must be send. + */ + AbstractMessage poll(); + + /** + * Handles the received message from the WaterRower S4/S5 monitor. + * + * @param msg The message from the WaterRower S4/S5 monitor. + */ + void handle(AbstractMessage msg); + +} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/ISubscriptionPollingService.java b/src/android/src/de/tbressler/waterrower/subscriptions/ISubscriptionPollingService.java new file mode 100644 index 0000000000..cbafe8c0a8 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/ISubscriptionPollingService.java @@ -0,0 +1,38 @@ +package de.tbressler.waterrower.subscriptions; + +/** + * Interface for the subscription polling service. + * + * @author Tobias Bressler + * @version 1.0 + */ +public interface ISubscriptionPollingService { + + /** + * Start the subscription polling service. + */ + void start(); + + + /** + * Subscribe to data/events. This will start the polling for the given data. + * + * @param subscription The subscription and callback, must not be null. + */ + void subscribe(ISubscription subscription); + + + /** + * Unsubscribe from data/events. This will stop the polling for the given data. + * + * @param subscription The subscription, must not be null. + */ + void unsubscribe(ISubscription subscription); + + + /** + * Stop the subscription polling service. + */ + void stop(); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/Priority.java b/src/android/src/de/tbressler/waterrower/subscriptions/Priority.java new file mode 100644 index 0000000000..13e3278ffc --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/Priority.java @@ -0,0 +1,35 @@ +package de.tbressler.waterrower.subscriptions; + +/** + * The priority of a subscription. + * + * The priority determines in which interval the subscription is polled by + * the subscription polling serve. + * + * |------------|-------------------------------------------| + * | Priority | Cycles (1=poll, 0=no poll) | + * |------------|-------------------------------------------| + * | HIGH | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | + * | MEDIUM | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 1 | + * | LOW | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | + * | NO_POLLING | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | + * |------------|-------------------------------------------| + * + * @author Tobias Breßler + * @version 1.0 + */ +public enum Priority { + + /** The subscription will be polled every polling cycle. */ + HIGH, + + /** The subscription will be polled every 2nd polling cycle. */ + MEDIUM, + + /** The subscription will be polled every 5th polling cycle. */ + LOW, + + /** This subscription must not be polled. */ + NO_POLLING + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/SubscriptionPollingService.java b/src/android/src/de/tbressler/waterrower/subscriptions/SubscriptionPollingService.java new file mode 100644 index 0000000000..cba7a95b5d --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/SubscriptionPollingService.java @@ -0,0 +1,207 @@ +package de.tbressler.waterrower.subscriptions; + +import de.tbressler.waterrower.io.ConnectionListener; +import de.tbressler.waterrower.io.IConnectionListener; +import de.tbressler.waterrower.io.WaterRowerConnector; +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.log.Log; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import static de.tbressler.waterrower.subscriptions.Priority.*; +import static java.util.Objects.requireNonNull; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +/** + * The implementation of the subscription polling service. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class SubscriptionPollingService implements ISubscriptionPollingService { + + /* The maximum size of messages in the queue. */ + private static final int MESSAGE_QUEUE_SIZE = 20; + + + /* The interval between two poll messages (in ms). */ + private final long interval; + + /* List of subscriptions. */ + private final List subscriptions = new CopyOnWriteArrayList<>(); + + /* The connector to the WaterRower. */ + private final WaterRowerConnector connector; + + + /* The executor service for polling of subscriptions. */ + private final ScheduledExecutorService executorService; + + /* True if subscription polling is active. */ + private final AtomicBoolean isActive = new AtomicBoolean(false); + + + /* Counter for the polling cycles. */ + private final AtomicLong pollCycle = new AtomicLong(0); + + /* Message queue for the current polling cycle. */ + private final BlockingQueue messageQueue = new ArrayBlockingQueue<>(MESSAGE_QUEUE_SIZE, true); + + + /* Listener for the connection to the WaterRower, which handles the received messages*/ + private final IConnectionListener listener = new ConnectionListener() { + @Override + public void onMessageReceived(AbstractMessage msg) { + + // If not active skip execution. + if (!isActive.get()) + return; + + for(ISubscription subscription : subscriptions) { + subscription.handle(msg); + } + } + }; + + + /** + * The subscription polling manager. + * + * @param connector The connector to the WaterRower, must not be null. + * @param executorService The executor service for the subscription polling, must not be null. + * @param interval The interval between messages, must not be null. + * Recommended = 200 ms. + */ + public SubscriptionPollingService(WaterRowerConnector connector, ScheduledExecutorService executorService, Duration interval) { + this.interval = requireNonNull(interval).toMillis(); + this.connector = requireNonNull(connector); + this.connector.addConnectionListener(listener); + this.executorService = requireNonNull(executorService); + } + + + /** + * Start the subscription polling service. + */ + @Override + public void start() { + + Log.debug("Start subscription polling service."); + + isActive.set(true); + + collectMessagesForNextPollingInterval(); + + scheduleSendMessageTask(); + } + + + /* Collect messages from the current subscriptions for polling. */ + private void collectMessagesForNextPollingInterval() { + + // If not active skip execution. + if (!isActive.get()) + return; + + Log.debug("Schedule polling for "+subscriptions.size()+" subscription(s)..."); + + long cycle = pollCycle.getAndIncrement(); + boolean pollMedium = cycle % 2 == 0; + boolean pollLow = cycle % 5 == 0; + + for (ISubscription subscription : subscriptions) { + + if ((subscription.getPriority() == NO_POLLING) + || (subscription.getPriority() == MEDIUM && !pollMedium) + || (subscription.getPriority() == LOW && !pollLow)) + continue; + + AbstractMessage msg = subscription.poll(); + + boolean addedToQueue = messageQueue.offer(msg); + + // If the message couldn't be added to queue, show a warning. This is the case + // when too many subscriptions are subscribed. The size of the queue = MESSAGE_QUEUE_SIZE. + if (!addedToQueue) { + Log.warn("Can not add more messages, the message queue is full! Skipping remaining messages in current cycle."); + return; + } + } + } + + + /* Schedule the send task for execution. */ + private void scheduleSendMessageTask() { + executorService.schedule(this::sendNextMessage, interval, MILLISECONDS); + } + + /* Send the first message from the message queue to the WaterRower. */ + private void sendNextMessage() { + + try { + + // If not active skip execution. + if (!isActive.get()) + return; + + AbstractMessage msg = messageQueue.poll(); + + if (msg != null) { + Log.debug("Send scheduled polling message >" + msg.toString() + "<"); + connector.send(msg); + } + + } catch (IOException e) { + Log.error("Couldn't send polling message due to an error!", e); + } + + if (messageQueue.isEmpty()) + collectMessagesForNextPollingInterval(); + + scheduleSendMessageTask(); + } + + + /** + * Stop the subscription polling service. + */ + @Override + public void stop() { + + Log.debug("Stop subscription polling service."); + + isActive.set(false); + } + + + /** + * Subscribe to data/events. This will start the polling for the given data. + * + * @param subscription The subscription and callback, must not be null. + */ + @Override + public void subscribe(ISubscription subscription) { + subscriptions.add(requireNonNull(subscription)); + Log.debug("Added subscription: " + subscription); + } + + /** + * Unsubscribe from data/events. This will stop the polling for the given data. + * + * @param subscription The subscription, must not be null. + */ + @Override + public void unsubscribe(ISubscription subscription) { + subscriptions.remove(requireNonNull(subscription)); + Log.debug("Removed subscription: " + subscription); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/flags/MiscFlagsSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/flags/MiscFlagsSubscription.java new file mode 100644 index 0000000000..875e5fce48 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/flags/MiscFlagsSubscription.java @@ -0,0 +1,73 @@ +package de.tbressler.waterrower.subscriptions.flags; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.model.MiscFlags; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.SINGLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.FMISC_FLAGS; +import static de.tbressler.waterrower.subscriptions.Priority.MEDIUM; + +/** + * Subscription for zone words and misc windows flags (FMISC_FLAGS). + * + * The received message contains the following flags: + * 0 = fzone_fg_work: a workout row interval is active + * 1 = fzone_fg_rest: a workout rest interval is active + * 2 = fmisc_fg_lowbat: set if battery of the Performance Monitor is low + * 3 = fmisc_fg_pc: a PC is connected (this is of course always the case) + * 4 = fmisc_fg_line + * 5 = fmisc_fg_mmc_cd + * 6 = fmisc_fg_mmc_up + * 7 = fmisc_fg_mmc_dn + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class MiscFlagsSubscription extends AbstractMemorySubscription { + + /* The last received zone flags. */ + private MiscFlags lastFlags; + + + /** + * Subscription for zone words and misc windows flags (FMISC_FLAGS). + */ + public MiscFlagsSubscription() { + this(MEDIUM); + } + + /** + * Supscription for zone words and misc windows flags (FMISC_FLAGS). + * + * @param priority The priority (recommended MEDIUM). + */ + public MiscFlagsSubscription(Priority priority) { + super(priority, SINGLE_MEMORY, FMISC_FLAGS); + } + + + @Override + public final void handle(DataMemoryMessage msg) { + + MiscFlags flags = new MiscFlags(msg.getValue1()); + + // If the received flags are the same as before, + // don't send an update. + if (flags.equals(lastFlags)) + return; + lastFlags = flags; + + onMiscFlagsUpdated(flags); + } + + + /** + * Is called, when an update of the misc flags was received. + * + * @param flags The flags, never null. + */ + abstract protected void onMiscFlagsUpdated(MiscFlags flags); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/AverageStrokeRateSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/AverageStrokeRateSubscription.java new file mode 100644 index 0000000000..42b1e23c58 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/AverageStrokeRateSubscription.java @@ -0,0 +1,72 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.SINGLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.STROKE_AVERAGE; +import static de.tbressler.waterrower.subscriptions.Priority.HIGH; + +/** + * Subscription for the average stroke rate (strokes/min) of a whole stroke which is displayed in + * the stroke rate window of the Performance Monitor. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class AverageStrokeRateSubscription extends AbstractMemorySubscription { + + /* The last stroke rate received. */ + private int lastStrokeRate = -1; + + + /** + * Subscription for the average stroke rate (strokes/min) of a whole stroke which is displayed in + * the stroke rate window of the Performance Monitor. + */ + public AverageStrokeRateSubscription() { + this(HIGH); + } + + /** + * Subscription for the average stroke rate (strokes/min) of a whole stroke which is displayed in + * the stroke rate window of the Performance Monitor. + * + * @param priority The priority (recommended HIGH). + */ + public AverageStrokeRateSubscription(Priority priority) { + super(priority, SINGLE_MEMORY, STROKE_AVERAGE); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + + int strokeRate = msg.getValue1(); + + // If the received duration is the same as before, + // don't send an update. + if (lastStrokeRate == strokeRate) + return; + lastStrokeRate = strokeRate; + + onStrokeRateUpdated(calculateAverageStrokeRate(strokeRate)); + } + + /* Calculate the average stroke rate. */ + private double calculateAverageStrokeRate(int strokeRate) { + if (strokeRate == 0) + return 0D; + return (60000D / (((double) strokeRate) * 25D)); + } + + + /** + * Is called if the value for the average stroke rate was updated. + * + * @param strokeRate The new stroke rate (in strokes per minute). + */ + abstract protected void onStrokeRateUpdated(double strokeRate); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/AverageVelocitySubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/AverageVelocitySubscription.java new file mode 100644 index 0000000000..72d5d86a90 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/AverageVelocitySubscription.java @@ -0,0 +1,68 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.DOUBLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.M_S_LOW_AVERAGE; +import static de.tbressler.waterrower.subscriptions.Priority.HIGH; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for the displayed average velocity (in meters per second) on the intensity window + * of the Performance Monitor. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class AverageVelocitySubscription extends AbstractMemorySubscription { + + /* The last velocity received (in cm/s). */ + private int lastVelocity = -1; + + + /** + * Subscription for the displayed average velocity on the intensity window + * of the Performance Monitor. + */ + public AverageVelocitySubscription() { + this(HIGH); + } + + /** + * Subscription for the displayed average velocity on the intensity window + * of the Performance Monitor. + * + * @param priority The priority (recommended HIGH). + */ + public AverageVelocitySubscription(Priority priority) { + super(priority, DOUBLE_MEMORY, M_S_LOW_AVERAGE); + } + + + @Override + protected void handle(DataMemoryMessage msg) { + + int velocity = intFromHighAndLow(msg.getValue2(), msg.getValue1()); + + // If the received velocity is the same as before, + // don't send an update. + if (lastVelocity == velocity) + return; + lastVelocity = velocity; + + double value = ((double) velocity) / 100D; + + onVelocityUpdated(value); + } + + + /** + * Is called if the value for the average velocity was updated. + * + * @param velocity The new value (in meters per second). + */ + abstract protected void onVelocityUpdated(double velocity); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/ClockCountDownSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/ClockCountDownSubscription.java new file mode 100644 index 0000000000..76e91fe66a --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/ClockCountDownSubscription.java @@ -0,0 +1,72 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; +import de.tbressler.waterrower.utils.MessageUtils; + +import java.time.Duration; + +import static de.tbressler.waterrower.io.msg.Memory.TRIPLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.CLOCK_DOWN_DEC; +import static de.tbressler.waterrower.subscriptions.Priority.HIGH; +import static java.time.Duration.ofSeconds; + +/** + * Subscription for clock count down values. + * + * This value is only set if a count down is running. The count down is also transmitted with the + * DisplayedDurationSubscription when the count down is active. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class ClockCountDownSubscription extends AbstractMemorySubscription { + + /* The last clock count down received. */ + private Duration lastClockCountDown = null; + + + /** + * Subscription to the distance value. + */ + public ClockCountDownSubscription() { + this(HIGH); + } + + /** + * Subscription to the distance value. + * + * @param priority The priority (recommended HIGH). + */ + public ClockCountDownSubscription(Priority priority) { + super(priority, TRIPLE_MEMORY, CLOCK_DOWN_DEC); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + + int millis = msg.getValue1(); + int sec = MessageUtils.intFromHighAndLow(msg.getValue3(), msg.getValue2()); + + Duration duration = ofSeconds(sec).plusMillis(millis * 100); + + // If the received duration is the same as before, + // don't send an update. + if (duration.equals(lastClockCountDown)) + return; + lastClockCountDown = duration; + + onClockCountDownUpdated(duration); + } + + + /** + * Is called if the value for the clock count-down was updated. + * + * @param duration The new clock count-down, never null. + */ + abstract protected void onClockCountDownUpdated(Duration duration); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/DisplayedDistanceSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/DisplayedDistanceSubscription.java new file mode 100644 index 0000000000..a324d27bac --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/DisplayedDistanceSubscription.java @@ -0,0 +1,66 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.DOUBLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.DISTANCE_LOW; +import static de.tbressler.waterrower.subscriptions.Priority.HIGH; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for the displayed distance on the distance window of the Performance Monitor. + * + * The distance window displays the distance covered (or distance to be covered in a + * distance workout). + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class DisplayedDistanceSubscription extends AbstractMemorySubscription { + + /* The last distance received. */ + private int lastDistance = -1; + + + /** + * Subscription to the displayed distance values. + */ + public DisplayedDistanceSubscription() { + this(HIGH); + } + + /** + * Subscription to the displayed distance values. + * + * @param priority The priority (recommended HIGH). + */ + public DisplayedDistanceSubscription(Priority priority) { + super(priority, DOUBLE_MEMORY, DISTANCE_LOW); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + + int distance = intFromHighAndLow(msg.getValue2(), msg.getValue1()); + + // If the received distance is the same as before, + // don't send an update. + if (lastDistance == distance) + return; + lastDistance = distance; + + // Notify update. + onDistanceUpdated(distance); + } + + /** + * Is called if the value for the displayed distance was updated. + * + * @param distance The new distance (in meter). + */ + abstract protected void onDistanceUpdated(int distance); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/DisplayedDurationSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/DisplayedDurationSubscription.java new file mode 100644 index 0000000000..4d870224fb --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/DisplayedDurationSubscription.java @@ -0,0 +1,76 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import java.time.Duration; + +import static de.tbressler.waterrower.io.msg.Memory.TRIPLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.DISPLAY_SEC; +import static de.tbressler.waterrower.subscriptions.Priority.HIGH; +import static java.lang.Integer.parseInt; +import static java.time.Duration.ofSeconds; + +/** + * Subscription for the displayed duration on the duration window of the Performance Monitor. + * + * The duration window displays the time covered (or time to be covered in a duration workout) + * in units of hours, minutes, seconds and decimal seconds. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class DisplayedDurationSubscription extends AbstractMemorySubscription { + + /* The last duration received. */ + private Duration lastDuration = null; + + + /** + * Subscription to the displayed duration value. + */ + public DisplayedDurationSubscription() { + this(HIGH); + } + + /** + * Subscription to the displayed duration value. + * + * @param priority The priority (recommended HIGH). + */ + public DisplayedDurationSubscription(Priority priority) { + super(priority, TRIPLE_MEMORY, DISPLAY_SEC); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + + int sec = parseInt(msg.getValue1AsACH()); + int min = parseInt(msg.getValue2AsACH()); + int hrs = parseInt(msg.getValue3AsACH()); + + Duration duration = ofSeconds(sec) + .plusMinutes(min) + .plusHours(hrs); + + // If the received duration is the same as before, + // don't send an update. + if (duration.equals(lastDuration)) + return; + lastDuration = duration; + + // Notify update. + onDurationUpdated(duration); + } + + + /** + * Is called if the value for the displayed duration was updated. + * + * @param duration The new duration, never null. + */ + abstract protected void onDurationUpdated(Duration duration); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/DistanceSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/DistanceSubscription.java new file mode 100644 index 0000000000..23597bbc15 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/DistanceSubscription.java @@ -0,0 +1,66 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.TRIPLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.MS_DISTANCE_DEC; +import static de.tbressler.waterrower.subscriptions.Priority.HIGH; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for the current distance. + * + * The value will be set to 0 by the Performance Monitor when a new row interval begins or + * the user performs a RESET. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class DistanceSubscription extends AbstractMemorySubscription { + + /* The last distance received. */ + private double lastDistance = -1D; + + + /** + * Subscription to the distance values. + */ + public DistanceSubscription() { + this(HIGH); + } + + /** + * Subscription to the distance values. + * + * @param priority The priority (recommended HIGH). + */ + public DistanceSubscription(Priority priority) { + super(priority, TRIPLE_MEMORY, MS_DISTANCE_DEC); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + + double distance = intFromHighAndLow(msg.getValue3(), msg.getValue2()) + (((double) msg.getValue1()) / 100D); + + // If the received distance is the same as before, + // don't send an update. + if (lastDistance == distance) + return; + lastDistance = distance; + + // Notify update. + onDistanceUpdated(distance); + } + + /** + * Is called if the value for the current distance was updated. + * + * @param distance The new distance (in meter). + */ + abstract protected void onDistanceUpdated(double distance); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/HeartRateSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/HeartRateSubscription.java new file mode 100644 index 0000000000..bb3ea9fa10 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/HeartRateSubscription.java @@ -0,0 +1,64 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.SINGLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.ZONE_HR_VAL; +import static de.tbressler.waterrower.subscriptions.Priority.MEDIUM; + +/** + * Subscription for the heart rate value (in beats per minute). + * + * TODO Because of the absence of the optional heart rate (pulse) device this value couldn't be tested so far. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class HeartRateSubscription extends AbstractMemorySubscription { + + /* The last heart rate received. */ + private int lastHeartRate = -1; + + + /** + * Subscription for the heart rate value (in beats per minute). + */ + public HeartRateSubscription() { + this(MEDIUM); + } + + /** + * Subscription for the heart rate value (in beats per minute). + * + * @param priority The priority (recommended MEDIUM). + */ + public HeartRateSubscription(Priority priority) { + super(priority, SINGLE_MEMORY, ZONE_HR_VAL); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + + int heartRate = msg.getValue1(); + + // If the received heart rate is the same as before, + // don't send an update. + if (lastHeartRate == heartRate) + return; + lastHeartRate = heartRate; + + onHeartRateUpdated(heartRate); + } + + + /** + * Is called if the value for the heart rate was updated. + * + * @param heartRate The new heart rate (in bpm). + */ + abstract protected void onHeartRateUpdated(int heartRate); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/PulseCountSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/PulseCountSubscription.java new file mode 100644 index 0000000000..5cf28d68fa --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/PulseCountSubscription.java @@ -0,0 +1,51 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.in.PulseCountMessage; +import de.tbressler.waterrower.subscriptions.ISubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +/** + * A subscription for pulse count events. + * + * Will be called, when pulse count was updated. The value is representing the number of + * pulse’s counted during the last 25mS period; this value can range from 1 to 50 + * typically. (Zero values will not be transmitted). + * + * This packet is auto transmitted by the rowing computer. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class PulseCountSubscription implements ISubscription { + + @Override + public final Priority getPriority() { + return Priority.NO_POLLING; + } + + @Override + public final AbstractMessage poll() { + // No poll necessary! Pulse count will be send automatically by + // the WaterRower Performance Monitor. + return null; + } + + @Override + public final void handle(AbstractMessage msg) { + if (!(msg instanceof PulseCountMessage)) + return; + onPulseCount(((PulseCountMessage) msg).getPulsesCounted()); + } + + + /** + * Will be called, when pulse count was updated. The value is representing the number of + * pulse’s counted during the last 25mS period; this value can range from 1 to 50 + * typically. (Zero values will not be transmitted). + * + * @param pulsesCount The number of pulse’s counted during the last 25mS period. + */ + abstract protected void onPulseCount(int pulsesCount); + +} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/StrokeCountSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/StrokeCountSubscription.java new file mode 100644 index 0000000000..387438db4f --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/StrokeCountSubscription.java @@ -0,0 +1,63 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.DOUBLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.STROKES_CNT_LOW; +import static de.tbressler.waterrower.subscriptions.Priority.MEDIUM; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for the stroke count value. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class StrokeCountSubscription extends AbstractMemorySubscription { + + /* The last stroke count received. */ + private int lastStrokeCount = -1; + + + /** + * Subscription for the stroke count value. + */ + public StrokeCountSubscription() { + this(MEDIUM); + } + + /** + * Subscription for the stroke count value. + * + * @param priority The priority (recommended MEDIUM). + */ + public StrokeCountSubscription(Priority priority) { + super(priority, DOUBLE_MEMORY, STROKES_CNT_LOW); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + + int strokes = intFromHighAndLow(msg.getValue2(), msg.getValue1()); + + // If the received stroke count is the same as before, + // don't send an update. + if (lastStrokeCount == strokes) + return; + lastStrokeCount = strokes; + + onStrokeCountUpdated(strokes); + } + + + /** + * Is called if the value for the stroke count was updated. + * + * @param strokes The new stroke count. + */ + abstract protected void onStrokeCountUpdated(int strokes); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/StrokeSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/StrokeSubscription.java new file mode 100644 index 0000000000..56fa994435 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/StrokeSubscription.java @@ -0,0 +1,51 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.AbstractMessage; +import de.tbressler.waterrower.io.msg.in.StrokeMessage; +import de.tbressler.waterrower.model.StrokeType; +import de.tbressler.waterrower.subscriptions.ISubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +/** + * A subscription for stroke events. + * + * Start of stroke pull to show when the rowing computer determined acceleration occurring in + * the paddle. End of stroke pull to show when the rowing computer determined deceleration occurring in + * the paddle. (Now entered the relax phase). + * + * This packet is auto transmitted by the rowing computer. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class StrokeSubscription implements ISubscription { + + @Override + public final Priority getPriority() { + return Priority.NO_POLLING; + } + + @Override + public final AbstractMessage poll() { + // No poll necessary! Strokes will be send automatically by + // the WaterRower Performance Monitor. + return null; + } + + @Override + public final void handle(AbstractMessage msg) { + if (!(msg instanceof StrokeMessage)) + return; + onStroke(((StrokeMessage) msg).getStrokeType()); + } + + + /** + * Will be called, when the rowing computer determined acceleration (start of stroke) or + * deceleration (end of stroke) occurring in the paddle. + * + * @param strokeType The type of stroke (e.g. start or end), never null. + */ + abstract protected void onStroke(StrokeType strokeType); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/TankVolumeSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/TankVolumeSubscription.java new file mode 100644 index 0000000000..0635255314 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/TankVolumeSubscription.java @@ -0,0 +1,61 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.SINGLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.TANK_VOLUME; +import static de.tbressler.waterrower.subscriptions.Priority.LOW; + +/** + * Subscription for the tank volume value (in liters). + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class TankVolumeSubscription extends AbstractMemorySubscription { + + /* The last tank volume received. */ + int lastTankVolume = -1; + + + /** + * Subscription for the tank volume value. + */ + public TankVolumeSubscription() { + this(LOW); + } + + /** + * Subscription for the tank volume value. + * + * @param priority The priority (recommended LOW). + */ + public TankVolumeSubscription(Priority priority) { + super(priority, SINGLE_MEMORY, TANK_VOLUME); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + int tankVolume = msg.getValue1(); + + if (lastTankVolume == tankVolume) + return; + lastTankVolume = tankVolume; + + double value = ((double) tankVolume) / 10D; + + onTankVolumeUpdated(value); + } + + + /** + * Is called if the value for the tank volume was updated. + * + * @param tankVolume The volume of water in the tank (in liters). + */ + abstract protected void onTankVolumeUpdated(double tankVolume); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/TotalCaloriesSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/TotalCaloriesSubscription.java new file mode 100644 index 0000000000..82c540fe44 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/TotalCaloriesSubscription.java @@ -0,0 +1,63 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.TRIPLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.TOTAL_KCAL_LOW; +import static de.tbressler.waterrower.subscriptions.Priority.MEDIUM; +import static de.tbressler.waterrower.utils.MessageUtils.intFromUpHighAndLow; + +/** + * Subscription for the value of the total calories. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class TotalCaloriesSubscription extends AbstractMemorySubscription { + + /* The last stroke count received. */ + private int lastValue = -1; + + + /** + * Subscription for the value of the total calories. + */ + public TotalCaloriesSubscription() { + this(MEDIUM); + } + + /** + * Subscription for the value of the total calories. + * + * @param priority The priority (recommended MEDIUM). + */ + public TotalCaloriesSubscription(Priority priority) { + super(priority, TRIPLE_MEMORY, TOTAL_KCAL_LOW); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + + int kcal = intFromUpHighAndLow(msg.getValue3(), msg.getValue2(), msg.getValue1()); + + // If the received kcal is the same as before, + // don't send an update. + if (lastValue == kcal) + return; + lastValue = kcal; + + onCaloriesUpdated(kcal); + } + + + /** + * Is called if the value for the total calories was updated. + * + * @param cal The new value (in cal). + */ + abstract protected void onCaloriesUpdated(int cal); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/TotalDistanceSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/TotalDistanceSubscription.java new file mode 100644 index 0000000000..4d6373244e --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/TotalDistanceSubscription.java @@ -0,0 +1,66 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.TRIPLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.TOTAL_DIS_DEC; +import static de.tbressler.waterrower.subscriptions.Priority.MEDIUM; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for the total distance values of the Performance Monitor. + * + * The value represents the total distance meter counter - this value will be reset to zero when the Performance + * Monitor is switched off. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class TotalDistanceSubscription extends AbstractMemorySubscription { + + /* The last distance received. */ + private double lastDistance = -1D; + + + /** + * Subscription to the displayed distance values. + */ + public TotalDistanceSubscription() { + this(MEDIUM); + } + + /** + * Subscription to the displayed distance values. + * + * @param priority The priority (recommended MEDIUM). + */ + public TotalDistanceSubscription(Priority priority) { + super(priority, TRIPLE_MEMORY, TOTAL_DIS_DEC); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + + double distance = intFromHighAndLow(msg.getValue3(), msg.getValue2()) + (((double) msg.getValue1()) / 100D); + + // If the received distance is the same as before, + // don't send an update. + if (lastDistance == distance) + return; + lastDistance = distance; + + // Notify update. + onDistanceUpdated(distance); + } + + /** + * Is called if the value for the total distance was updated. + * + * @param distance The new distance (in meter). + */ + abstract protected void onDistanceUpdated(double distance); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/TotalVelocitySubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/TotalVelocitySubscription.java new file mode 100644 index 0000000000..abe480336f --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/TotalVelocitySubscription.java @@ -0,0 +1,66 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.DOUBLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.M_S_LOW_TOTAL; +import static de.tbressler.waterrower.subscriptions.Priority.LOW; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for the total velocity (in meters per second). + * TODO The interpretation of this value is unknown at the moment. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class TotalVelocitySubscription extends AbstractMemorySubscription { + + /* The last velocity received (in cm/s). */ + private int lastVelocity = -1; + + + /** + * Subscription for the total velocity. + */ + public TotalVelocitySubscription() { + this(LOW); + } + + /** + * Subscription for the total velocity. + * + * @param priority The priority (recommended LOW). + */ + public TotalVelocitySubscription(Priority priority) { + super(priority, DOUBLE_MEMORY, M_S_LOW_TOTAL); + } + + + @Override + protected void handle(DataMemoryMessage msg) { + + int velocity = intFromHighAndLow(msg.getValue2(), msg.getValue1()); + + // If the received velocity is the same as before, + // don't send an update. + if (lastVelocity == velocity) + return; + lastVelocity = velocity; + + double value = ((double) velocity) / 100D; + + onVelocityUpdated(value); + } + + + /** + * Is called if the value for the total velocity was updated. + * + * @param velocity The new value (in meters per second). + */ + abstract protected void onVelocityUpdated(double velocity); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/values/WattsSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/values/WattsSubscription.java new file mode 100644 index 0000000000..df907671b3 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/values/WattsSubscription.java @@ -0,0 +1,66 @@ +package de.tbressler.waterrower.subscriptions.values; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.DOUBLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.KCAL_WATTS_LOW; +import static de.tbressler.waterrower.subscriptions.Priority.HIGH; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for the watt value. + * + * During a stroke pull the value is greater than 0 W (accelerating). In the relax + * and the release phase the value is 0 W. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class WattsSubscription extends AbstractMemorySubscription { + + /* The last stroke count received. */ + private int lastValue = -1; + + + /** + * Subscription for the watt value. + */ + public WattsSubscription() { + this(HIGH); + } + + /** + * Subscription for the watt value. + * + * @param priority The priority (recommended HIGH). + */ + public WattsSubscription(Priority priority) { + super(priority, DOUBLE_MEMORY, KCAL_WATTS_LOW); + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + + int watt = intFromHighAndLow(msg.getValue2(), msg.getValue1()); + + // If the received watt is the same as before, + // don't send an update. + if (lastValue == watt) + return; + lastValue = watt; + + onWattsUpdated(watt); + } + + + /** + * Is called if the value for watts was updated. + * + * @param watt The new value (in watt). + */ + abstract protected void onWattsUpdated(int watt); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutDistanceSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutDistanceSubscription.java new file mode 100644 index 0000000000..f6f221e5ca --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutDistanceSubscription.java @@ -0,0 +1,66 @@ +package de.tbressler.waterrower.subscriptions.workouts; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.DOUBLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.WORKOUT_MS_L; +import static de.tbressler.waterrower.subscriptions.Priority.LOW; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for values of the total workout distance. + * The distance is updated by the WaterRower after each workout interval. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class TotalWorkoutDistanceSubscription extends AbstractMemorySubscription { + + /* The last value received. */ + private int lastValue = -1; + + + /** + * Subscription for values of the total workout distance. + * The distance is updated by the WaterRower after each workout interval. + */ + public TotalWorkoutDistanceSubscription() { + this(LOW); + } + + /** + * Subscription for values of the total workout distance. + * The distance is updated by the WaterRower after each workout interval. + * + * @param priority The priority (recommended LOW). + */ + public TotalWorkoutDistanceSubscription(Priority priority) { + super(priority, DOUBLE_MEMORY, WORKOUT_MS_L); + } + + + @Override + protected void handle(DataMemoryMessage msg) { + + int value = intFromHighAndLow(msg.getValue2(), msg.getValue1()); + + // If the received value is the same as before, + // don't send an update. + if (lastValue == value) + return; + lastValue = value; + + onDistanceUpdated(value); + } + + + /** + * Is called if the total workout distance value was updated. + * + * @param distance The new workout distance. + */ + abstract protected void onDistanceUpdated(int distance); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutLimitSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutLimitSubscription.java new file mode 100644 index 0000000000..72119e02ff --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutLimitSubscription.java @@ -0,0 +1,64 @@ +package de.tbressler.waterrower.subscriptions.workouts; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.DOUBLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.WORKOUT_LIMIT_L; +import static de.tbressler.waterrower.subscriptions.Priority.LOW; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for values of the total workout limit. + * TODO The interpretation of this value is unknown at the moment. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class TotalWorkoutLimitSubscription extends AbstractMemorySubscription { + + /* The last value received. */ + private int lastValue = -1; + + + /** + * Subscription for values of the total workout limit. + */ + public TotalWorkoutLimitSubscription() { + this(LOW); + } + + /** + * Subscription for values of the total workout limit. + * + * @param priority The priority (recommended LOW). + */ + public TotalWorkoutLimitSubscription(Priority priority) { + super(priority, DOUBLE_MEMORY, WORKOUT_LIMIT_L); + } + + + @Override + protected void handle(DataMemoryMessage msg) { + + int value = intFromHighAndLow(msg.getValue2(), msg.getValue1()); + + // If the received value is the same as before, + // don't send an update. + if (lastValue == value) + return; + lastValue = value; + + onLimitUpdated(value); + } + + + /** + * Is called if the total workout limit value was updated. + * + * @param limit The new workout limit. + */ + abstract protected void onLimitUpdated(int limit); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutStrokesSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutStrokesSubscription.java new file mode 100644 index 0000000000..65479b5243 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutStrokesSubscription.java @@ -0,0 +1,66 @@ +package de.tbressler.waterrower.subscriptions.workouts; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.DOUBLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.WORKOUT_STROKEL; +import static de.tbressler.waterrower.subscriptions.Priority.LOW; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for values of the total workout strokes. + * The stroke value is updated by the WaterRower after each workout interval. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class TotalWorkoutStrokesSubscription extends AbstractMemorySubscription { + + /* The last value received. */ + private int lastValue = -1; + + + /** + * Subscription for values of the total workout strokes. + * The stroke value is updated by the WaterRower after each workout interval. + */ + public TotalWorkoutStrokesSubscription() { + this(LOW); + } + + /** + * Subscription for values of the total workout strokes. + * The stroke value is updated by the WaterRower after each workout interval. + * + * @param priority The priority (recommended LOW). + */ + public TotalWorkoutStrokesSubscription(Priority priority) { + super(priority, DOUBLE_MEMORY, WORKOUT_STROKEL); + } + + + @Override + protected void handle(DataMemoryMessage msg) { + + int value = intFromHighAndLow(msg.getValue2(), msg.getValue1()); + + // If the received value is the same as before, + // don't send an update. + if (lastValue == value) + return; + lastValue = value; + + onStrokesUpdated(value); + } + + + /** + * Is called if the value of the total workout strokes was updated. + * + * @param strokes The new value for strokes. + */ + abstract protected void onStrokesUpdated(int strokes); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutTimeSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutTimeSubscription.java new file mode 100644 index 0000000000..c9ac99a063 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/TotalWorkoutTimeSubscription.java @@ -0,0 +1,68 @@ +package de.tbressler.waterrower.subscriptions.workouts; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import java.time.Duration; + +import static de.tbressler.waterrower.io.msg.Memory.DOUBLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.WORKOUT_TIMEL; +import static de.tbressler.waterrower.subscriptions.Priority.LOW; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; + +/** + * Subscription for values of the total workout times. + * The time is updated by the WaterRower after each workout interval. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class TotalWorkoutTimeSubscription extends AbstractMemorySubscription { + + /* The last value received. */ + private int lastValue = -1; + + + /** + * Subscription for values of the total workout times. + * The time is updated by the WaterRower after each workout interval. + */ + public TotalWorkoutTimeSubscription() { + this(LOW); + } + + /** + * Subscription for values of the total workout times. + * The time is updated by the WaterRower after each workout interval. + * + * @param priority The priority (recommended LOW). + */ + public TotalWorkoutTimeSubscription(Priority priority) { + super(priority, DOUBLE_MEMORY, WORKOUT_TIMEL); + } + + + @Override + protected void handle(DataMemoryMessage msg) { + + int value = intFromHighAndLow(msg.getValue2(), msg.getValue1()); + + // If the received value is the same as before, + // don't send an update. + if (lastValue == value) + return; + lastValue = value; + + onTimeUpdated(Duration.ofSeconds(value)); + } + + + /** + * Is called if the total workout time value was updated. + * + * @param time The new workout time. + */ + abstract protected void onTimeUpdated(Duration time); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/workouts/WorkoutFlagsSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/WorkoutFlagsSubscription.java new file mode 100644 index 0000000000..291b305a46 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/WorkoutFlagsSubscription.java @@ -0,0 +1,73 @@ +package de.tbressler.waterrower.subscriptions.workouts; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.model.WorkoutFlags; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.SINGLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.FEXTENDED; +import static de.tbressler.waterrower.subscriptions.Priority.HIGH; + +/** + * Subscription for working and workout control flags (FEXTENDED). + * + * The received message contains the following flags: + * 0 = fzone_hr: working in heartrate zone + * 1 = fzone_int: working in intensity zone + * 2 = fzone_sr: working in strokerate zone + * 3 = fprognostics: prognostics active + * 4 = fworkout_dis: workout distance mode + * 5 = fworkout_dur: workout duration mode + * 6 = fworkout_dis_i: workout distance interval mode + * 7 = fworkout_dur_i: workout duration interval mode + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class WorkoutFlagsSubscription extends AbstractMemorySubscription { + + /* The last received workout flags. */ + private WorkoutFlags lastWorkoutFlags; + + + /** + * Subscription for working and workout control flags (FEXTENDED). + */ + public WorkoutFlagsSubscription() { + this(HIGH); + } + + /** + * Subscription for working and workout control flags (FEXTENDED). + * + * @param priority The priority (recommended HIGH). + */ + public WorkoutFlagsSubscription(Priority priority) { + super(priority, SINGLE_MEMORY, FEXTENDED); + } + + + @Override + public final void handle(DataMemoryMessage msg) { + + WorkoutFlags flags = new WorkoutFlags(msg.getValue1()); + + // If the received workout flags are the same as before, + // don't send an update. + if (flags.equals(lastWorkoutFlags)) + return; + lastWorkoutFlags = flags; + + onWorkoutFlagsUpdated(flags); + } + + + /** + * Is called, when an update of the workout mode flags was received. + * + * @param flags The flags of the workout mode, never null. + */ + abstract protected void onWorkoutFlagsUpdated(WorkoutFlags flags); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/workouts/WorkoutIntervalValueSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/WorkoutIntervalValueSubscription.java new file mode 100644 index 0000000000..59cf5bdada --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/WorkoutIntervalValueSubscription.java @@ -0,0 +1,126 @@ +package de.tbressler.waterrower.subscriptions.workouts; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.model.MemoryLocation; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.DOUBLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.*; +import static de.tbressler.waterrower.subscriptions.Priority.LOW; +import static de.tbressler.waterrower.utils.MessageUtils.intFromHighAndLow; +import static java.util.Objects.requireNonNull; + +/** + * Subscription for the workout interval values. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class WorkoutIntervalValueSubscription extends AbstractMemorySubscription { + + /** + * The interval type (e.g. row or rest). + */ + public enum IntervalType { + + /* Rest interval. */ + REST_INTERVAL, + + /* Row interval. */ + ROW_INTERVAL + } + + + /* The interval type. */ + private final IntervalType intervalType; + + /* The interval index. */ + private final int intervalIndex; + + + /* The last value received. */ + private int lastValue = -1; + + + /** + * Subscription for the workout interval values. + * + * @param intervalType The interval type (e.g. row or rest), must not be null. + * @param intervalIndex The index of the workout interval. Must be between 1 and 9 for + * interval type REST_INTERVAL and between 1 and 8 for interval type + * ROW_INTERVAL. + */ + public WorkoutIntervalValueSubscription(IntervalType intervalType, int intervalIndex) { + this(LOW, intervalType, intervalIndex); + } + + /** + * Subscription for the workout interval values. + * + * @param priority The priority (recommended LOW). + * @param intervalType The interval type (e.g. row or rest), must not be null. + * @param intervalIndex The index of the workout interval. Must be between 1 and 9 for + * interval type REST_INTERVAL and between 1 and 8 for interval type + * ROW_INTERVAL. + */ + public WorkoutIntervalValueSubscription(Priority priority, IntervalType intervalType, int intervalIndex) { + super(priority, DOUBLE_MEMORY, getMemoryLocation(intervalType, intervalIndex)); + this.intervalType = intervalType; + this.intervalIndex = intervalIndex; + } + + /* Returns the memory location for the given interval type and interval index. */ + private static MemoryLocation getMemoryLocation(IntervalType intervalType, int intervalIndex) { + switch (requireNonNull(intervalType)) { + case ROW_INTERVAL: + if (intervalIndex == 1) return WORKOUT_WORK1_L; + else if (intervalIndex == 2) return WORKOUT_WORK2_L; + else if (intervalIndex == 3) return WORKOUT_WORK3_L; + else if (intervalIndex == 4) return WORKOUT_WORK4_L; + else if (intervalIndex == 5) return WORKOUT_WORK5_L; + else if (intervalIndex == 6) return WORKOUT_WORK6_L; + else if (intervalIndex == 7) return WORKOUT_WORK7_L; + else if (intervalIndex == 8) return WORKOUT_WORK8_L; + else if (intervalIndex == 9) return WORKOUT_WORK9_L; + else throw new IllegalArgumentException("Interval index is out of range! Index must be between 1 and 9."); + case REST_INTERVAL: + if (intervalIndex == 1) return WORKOUT_REST1_L; + else if (intervalIndex == 2) return WORKOUT_REST2_L; + else if (intervalIndex == 3) return WORKOUT_REST3_L; + else if (intervalIndex == 4) return WORKOUT_REST4_L; + else if (intervalIndex == 5) return WORKOUT_REST5_L; + else if (intervalIndex == 6) return WORKOUT_REST6_L; + else if (intervalIndex == 7) return WORKOUT_REST7_L; + else if (intervalIndex == 8) return WORKOUT_REST8_L; + else throw new IllegalArgumentException("Interval index is out of range! Index must be between 1 and 8."); + default: + throw new IllegalStateException("Unhandled interval type!"); + } + } + + + @Override + protected final void handle(DataMemoryMessage msg) { + int value = intFromHighAndLow(msg.getValue2(), msg.getValue1()); + + // If the received value is the same as before, + // don't send an update. + if (lastValue == value) + return; + lastValue = value; + + onWorkoutIntervalUpdated(intervalType, intervalIndex, value); + } + + + /** + * Is called if the value for the workout interval was updated. + * + * @param intervalType The interval type (e.g. row or rest), never null. + * @param intervalIndex The index of the workout interval. + * @param value The new value (the unit depends on the interval and workout type). + */ + abstract protected void onWorkoutIntervalUpdated(IntervalType intervalType, int intervalIndex, int value); + +} diff --git a/src/android/src/de/tbressler/waterrower/subscriptions/workouts/WorkoutIntervalsSubscription.java b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/WorkoutIntervalsSubscription.java new file mode 100644 index 0000000000..0626714bde --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/subscriptions/workouts/WorkoutIntervalsSubscription.java @@ -0,0 +1,65 @@ +package de.tbressler.waterrower.subscriptions.workouts; + +import de.tbressler.waterrower.io.msg.in.DataMemoryMessage; +import de.tbressler.waterrower.subscriptions.AbstractMemorySubscription; +import de.tbressler.waterrower.subscriptions.Priority; + +import static de.tbressler.waterrower.io.msg.Memory.SINGLE_MEMORY; +import static de.tbressler.waterrower.model.MemoryLocation.WORKOUT_INTER; +import static de.tbressler.waterrower.subscriptions.Priority.LOW; + +/** + * Subscription for the number of configured workout intervals at the + * Performance Monitor. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class WorkoutIntervalsSubscription extends AbstractMemorySubscription { + + /* The last value received. */ + private int lastValue = -1; + + + /** + * Subscription for the number of configured workout intervals at the + * Performance Monitor. + */ + public WorkoutIntervalsSubscription() { + this(LOW); + } + + /** + * Subscription for the number of configured workout intervals at the + * Performance Monitor. + * + * @param priority The priority (recommended LOW). + */ + public WorkoutIntervalsSubscription(Priority priority) { + super(priority, SINGLE_MEMORY, WORKOUT_INTER); + } + + + @Override + protected void handle(DataMemoryMessage msg) { + + int value = msg.getValue1(); + + // If the received value is the same as before, + // don't send an update. + if (lastValue == value) + return; + lastValue = value; + + onIntervalsUpdated(value); + } + + + /** + * Is called if the value for the number of workout intervals was updated. + * + * @param intervals The new value. + */ + abstract protected void onIntervalsUpdated(int intervals); + +} diff --git a/src/android/src/de/tbressler/waterrower/utils/AvailablePort.java b/src/android/src/de/tbressler/waterrower/utils/AvailablePort.java new file mode 100644 index 0000000000..99667ea1eb --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/utils/AvailablePort.java @@ -0,0 +1,57 @@ +package de.tbressler.waterrower.utils; + +import com.fazecast.jSerialComm.SerialPort; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static java.util.Objects.requireNonNull; + +/** + * Wrapper for the SerialPort implementation. + * This wrapper is used in the unit tests. + * + * @author Tobias Breßler + * @version 1.0 + */ +public class AvailablePort { + + /* The underlying jSerialComm port. */ + private final SerialPort serialPort; + + + /** + * Wrapper for the SerialPort implementation. + * + * @param serialPort The underlying jSerialComm port, must not be null. + */ + public AvailablePort(SerialPort serialPort) { + this.serialPort = requireNonNull(serialPort); + } + + /** + * The system name of the port. + * + * @return The name of the port. + */ + public String getSystemPortName() { + return serialPort.getSystemPortName(); + } + + /** + * The description of the port. + * + * @return The description of the port, never null. + */ + public String getDescription() { + return firstNonNull(serialPort.getPortDescription(), ""); + } + + /** + * Returns true if the port is open. + * + * @return True if the port is open. + */ + public boolean isOpen() { + return serialPort.isOpen(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/utils/Compatibility.java b/src/android/src/de/tbressler/waterrower/utils/Compatibility.java new file mode 100644 index 0000000000..3ac6b79c09 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/utils/Compatibility.java @@ -0,0 +1,50 @@ +package de.tbressler.waterrower.utils; + +import de.tbressler.waterrower.model.ModelInformation; +import de.tbressler.waterrower.model.MonitorType; + +import static java.util.Objects.requireNonNull; + +/** + * Helper class to check compatibility of model/monitor type and firmware version with this library. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class Compatibility { + + /* Private constructor. */ + private Compatibility() {} + + + /** + * Returns true if monitor type and firmware are supported by this library. + * + * @param modelInformation The model information from the device, must not be null. + * @return True if monitor type and firmware are supported by this library. + */ + public static boolean isSupportedWaterRower(ModelInformation modelInformation) { + requireNonNull(modelInformation); + + if (!isModelTypeSupported(modelInformation.getMonitorType())) + return false; + return isFirmwareVersionSupported(modelInformation.getFirmwareVersion()); + } + + /* Returns true if monitor type is supported. */ + private static boolean isModelTypeSupported(MonitorType monitorType) { + switch (monitorType) { + case WATER_ROWER_S4: + case WATER_ROWER_S5: + return true; + default: + return false; + } + } + + /* Returns true if firmware version is supported. */ + private static boolean isFirmwareVersionSupported(String firmwareVersion) { + return firmwareVersion.startsWith("02."); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/utils/MessageUtils.java b/src/android/src/de/tbressler/waterrower/utils/MessageUtils.java new file mode 100644 index 0000000000..c4163ae425 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/utils/MessageUtils.java @@ -0,0 +1,138 @@ +package de.tbressler.waterrower.utils; + +import static java.lang.Integer.parseInt; +import static java.lang.Integer.toHexString; +import static java.lang.String.valueOf; +import static java.util.Objects.requireNonNull; + +/** + * Utils for conversion of ASCII data. + * The class supports: + * - ACD (ASCII coded decimal) + * - ACH (ASCII coded hexadecimal) + * + * @author Tobias Bressler + * @version 1.0 + */ +public class MessageUtils { + + /* Private constructor. */ + private MessageUtils() {} + + + /** + * Returns the ACD (ASCII coded decimal) as int value. + * + * @param ascii The ACD value, must not be null. + * @return The integer value. + * + * @throws NumberFormatException If not a decimal value. + */ + public static int acdToInt(String ascii) throws NumberFormatException { + return parseInt(requireNonNull(ascii)); + } + + + /** + * Returns the int value as ACD (ASCII coded decimal). + * + * @param value The int value. + * @param chars The number of chars. + * @return The int value as ACD. + * + * @throws NumberFormatException If the int has more characters than the number of chars given. + */ + public static String intToAcd(int value, int chars) throws NumberFormatException { + String ascii = valueOf(value); + return addLeadingZeros(chars, ascii); + } + + + /** + * Returns the ACH (ASCII coded hexadecimal) as int value. + * + * @param ascii The ACH value, must not be null. + * @return The integer value. + * + * @throws NumberFormatException If not a hexadecimal value. + */ + public static int achToInt(String ascii) throws NumberFormatException { + return parseInt(requireNonNull(ascii), 16); + } + + + /** + * Returns the int value as ACH (ASCII coded hexadecimal). + * + * @param value The int value. + * @param chars The number of chars. + * @return The int value as ACH. + * + * @throws NumberFormatException If the int has more characters than the number of chars given. + */ + public static String intToAch(int value, int chars) throws NumberFormatException { + String ascii = toHexString(value).toUpperCase(); + return addLeadingZeros(chars, ascii); + } + + + /* Add leading zeros to string. */ + private static String addLeadingZeros(int chars, String ascii) { + int numberOfLeadingZeros = chars - ascii.length(); + if (numberOfLeadingZeros < 0) + throw new NumberFormatException("Number has more than "+chars+" characters!"); + for(int i=0; i 7) || (index < 0)) + throw new IllegalArgumentException("The index is out of range! Only values between 0 and 7 allowed."); + if ((value < 0) || (value > 0xFF)) + throw new IllegalArgumentException("The value is is out of range! Only values between 0x00 and 0xFF allowed.!"); + + int mask = 0x01; + for (int i=0; i < index; i++) + mask = mask << 1; + + return ((mask & value) > 0); + } + + + /** + * Returns the integer from the two byte values. + * + * @param high The high byte. + * @param low The low byte. + * @return The integer as combination of high and low bytes. + */ + public static int intFromHighAndLow(int high, int low) { + if ((high < 0x00) || (high > 0xFF) || (low < 0x00) || (low > 0xFF)) + throw new IllegalArgumentException("The low or high value is out of range!"); + return (high << 8) + low; + } + + /** + * Returns the integer from the two byte values. + * + * @param up The up byte. + * @param high The high byte. + * @param low The low byte. + * @return The integer as combination of up, high and low bytes. + */ + public static int intFromUpHighAndLow(int up, int high, int low) { + if ((up < 0x00) || (up > 0xFF) || (high < 0x00) || (high > 0xFF) || (low < 0x00) || (low > 0xFF)) + throw new IllegalArgumentException("The low, high or up value is out of range!"); + return ((up << 16) + (high << 8) + low); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/utils/SerialPortWrapper.java b/src/android/src/de/tbressler/waterrower/utils/SerialPortWrapper.java new file mode 100644 index 0000000000..b61444cbeb --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/utils/SerialPortWrapper.java @@ -0,0 +1,32 @@ +package de.tbressler.waterrower.utils; + +import com.fazecast.jSerialComm.SerialPort; + +import java.util.Arrays; +import java.util.List; + +import static java.util.Collections.emptyList; +import static java.util.stream.Collectors.toList; + +/** + * A wrapper for the serial port class of jSerialComm. + * This wrapper is used in the unit tests. + * + * @author Tobias Breßler + * @version 1.0 + */ +public class SerialPortWrapper { + + /** + * Returns the list of available ports. + * + * @return A list of the available ports, never null. + */ + public List getAvailablePorts() { + SerialPort[] serialPorts = SerialPort.getCommPorts(); + if (serialPorts.length == 0) + return emptyList(); + return Arrays.stream(serialPorts).map((port) -> new AvailablePort(port)).collect(toList()); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/watchdog/DeviceVerificationWatchdog.java b/src/android/src/de/tbressler/waterrower/watchdog/DeviceVerificationWatchdog.java new file mode 100644 index 0000000000..956d1ec806 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/watchdog/DeviceVerificationWatchdog.java @@ -0,0 +1,75 @@ +package de.tbressler.waterrower.watchdog; + +import de.tbressler.waterrower.log.Log; + +import java.time.Duration; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; + +import static de.tbressler.waterrower.watchdog.TimeoutReason.DEVICE_NOT_CONFIRMED_TIMEOUT; + +/** + * A watchdog which checks after the given amount of time, if the device is confirmed. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class DeviceVerificationWatchdog extends Watchdog { + + /* True if the device is confirmed. */ + private final AtomicBoolean deviceConfirmed = new AtomicBoolean(false); + + + /** + * A watchdog which checks after the given amount of time, if the device is confirmed. + * + * @param duration The duration when to check if the device is confirmed, must not be null. + * @param executorService The executor service, must not be null. + */ + public DeviceVerificationWatchdog(Duration duration, ScheduledExecutorService executorService) { + super(duration, false, executorService); + } + + + /** + * Set the device as confirmed or unconfirmed. + * + * @param isConfirmed True if the device is confirmed. + */ + public void setDeviceConfirmed(boolean isConfirmed) { + deviceConfirmed.set(isConfirmed); + } + + /** + * Returns true if the device is confirmed. + * + * @return True if the device is confirmed. + */ + public boolean isDeviceConfirmed() { + return deviceConfirmed.get(); + } + + + @Override + protected final void wakeUpAndCheck() { + Log.debug("Checking if device type is confirmed."); + if (!isDeviceConfirmed()) { + Log.warn("The device type was not confirmed yet!"); + fireOnTimeout(DEVICE_NOT_CONFIRMED_TIMEOUT); + } + } + + + @Override + public void start() { + setDeviceConfirmed(false); + super.start(); + } + + + @Override + public void stop() { + super.stop(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/watchdog/ITimeoutListener.java b/src/android/src/de/tbressler/waterrower/watchdog/ITimeoutListener.java new file mode 100644 index 0000000000..1026adff5d --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/watchdog/ITimeoutListener.java @@ -0,0 +1,18 @@ +package de.tbressler.waterrower.watchdog; + +/** + * Interface for watchdog. + * + * @author Tobias Bressler + * @version 1.0 + */ +public interface ITimeoutListener { + + /** + * Called if a timeout occurs at a watchdog. + * + * @param reason The reason for the timeout. + */ + void onTimeout(TimeoutReason reason); + +} diff --git a/src/android/src/de/tbressler/waterrower/watchdog/PingWatchdog.java b/src/android/src/de/tbressler/waterrower/watchdog/PingWatchdog.java new file mode 100644 index 0000000000..fb50f7a5da --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/watchdog/PingWatchdog.java @@ -0,0 +1,70 @@ +package de.tbressler.waterrower.watchdog; + +import de.tbressler.waterrower.log.Log; + +import java.time.Duration; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicLong; + +import static de.tbressler.waterrower.watchdog.TimeoutReason.PING_TIMEOUT; +import static java.lang.System.currentTimeMillis; + +/** + * A watchdog which checks if a message (e.g. a ping) was received in a specified amount of time. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class PingWatchdog extends Watchdog { + + /* Maximum duration (in ms) between messages. If the duration between messages + * exceeds, the method onTimeout() is called. */ + private final long maxPingDuration; + + /* Last time a ping was received. */ + private final AtomicLong lastReceivedPing = new AtomicLong(0); + + + /** + * A watchdog which checks if a message (e.g. a ping) was received in a specified amount of time. + * + * @param duration Maximum duration between messages, must not be null. + * @param executorService The executor service, must not be null. + */ + public PingWatchdog(Duration duration, ScheduledExecutorService executorService) { + super(duration, true, executorService); + this.maxPingDuration = duration.toMillis(); + } + + + /** + * Notifies the watchdog about a received message (e.g. a ping). + */ + public void pingReceived() { + lastReceivedPing.set(currentTimeMillis()); + } + + + @Override + protected void wakeUpAndCheck() { + Log.debug("Checking if a message (e.g. ping) was received in the last "+maxPingDuration+" ms."); + if (currentTimeMillis() - lastReceivedPing.get() > maxPingDuration) { + Log.warn("No message (e.g. ping) received in the last "+maxPingDuration+" ms."); + fireOnTimeout(PING_TIMEOUT); + } + } + + + @Override + public void start() { + pingReceived(); + super.start(); + } + + + @Override + public void stop() { + super.stop(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/watchdog/TimeoutReason.java b/src/android/src/de/tbressler/waterrower/watchdog/TimeoutReason.java new file mode 100644 index 0000000000..2659ba2d62 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/watchdog/TimeoutReason.java @@ -0,0 +1,17 @@ +package de.tbressler.waterrower.watchdog; + +/** + * The reason for a timeout at a watchdog. + * + * @author Tobias Bressler + * @version 1.0 + */ +public enum TimeoutReason { + + /* No message received in given interval. */ + PING_TIMEOUT, + + /* No confirmation of the device type received. */ + DEVICE_NOT_CONFIRMED_TIMEOUT + +} diff --git a/src/android/src/de/tbressler/waterrower/watchdog/Watchdog.java b/src/android/src/de/tbressler/waterrower/watchdog/Watchdog.java new file mode 100644 index 0000000000..c654c62301 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/watchdog/Watchdog.java @@ -0,0 +1,113 @@ +package de.tbressler.waterrower.watchdog; + +import java.time.Duration; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; + +import static java.util.Objects.requireNonNull; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +/** + * An abstract watchdog. + * + * @author Tobias Bressler + * @version 1.0 + */ +public abstract class Watchdog { + + /* The wakeup interval for the watchdog. */ + private final Duration interval; + + /* Repeat the watchdog periodically. */ + private final boolean doRepeat; + + /* The executor service. */ + private final ScheduledExecutorService executorService; + + /* True if watchdog is stopped. */ + private final AtomicBoolean isStopped = new AtomicBoolean(true); + + /* The listener that should be notified on timeout. */ + private ITimeoutListener timeoutListener; + + + /** + * A watchdog. + * + * @param interval The interval, must not be null. + * @param repeat True if the watchdog task should be repeated periodically. + * @param executorService The scheduled executor service, must not be null. + */ + public Watchdog(Duration interval, boolean repeat, ScheduledExecutorService executorService) { + this.interval = requireNonNull(interval); + this.doRepeat = repeat; + this.executorService = requireNonNull(executorService); + } + + + /** + * Starts the watchdog. + */ + public void start() { + isStopped.set(false); + scheduleWatchdogTask(); + } + + /* Schedule the watchdog task for execution. */ + private void scheduleWatchdogTask() { + executorService.schedule(this::executeWatchdogTask, interval.toMillis(), MILLISECONDS); + } + + /* Execute the watchdog task. */ + private void executeWatchdogTask() { + + // Check if already stopped. + if (isStopped.get()) + return; + + wakeUpAndCheck(); + + // Start the next period if the task should + // be executed periodically. + if (doRepeat && !isStopped.get()) + scheduleWatchdogTask(); + } + + + /** + * The task that should be executed, when the watchdog wakes up. Please call + * #fireOnTimeout(...) if a timeout was detected. + */ + protected abstract void wakeUpAndCheck(); + + + /** + * Notifies the listener about a timeout. + * + * @param reason The reason for the timeout, must not be null. + */ + protected void fireOnTimeout(TimeoutReason reason) { + if (timeoutListener == null) + return; + timeoutListener.onTimeout(requireNonNull(reason)); + } + + + /** + * Sets a timeout listener. + * + * @param listener The timeout listener or null. + */ + public void setTimeoutListener(ITimeoutListener listener) { + this.timeoutListener = listener; + } + + + /** + * Stops the watchdog. + */ + public void stop() { + isStopped.set(true); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/workout/Workout.java b/src/android/src/de/tbressler/waterrower/workout/Workout.java new file mode 100644 index 0000000000..a3aa8750a7 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/workout/Workout.java @@ -0,0 +1,114 @@ +package de.tbressler.waterrower.workout; + +import java.util.ArrayList; +import java.util.List; + +import static com.google.common.base.MoreObjects.toStringHelper; +import static java.util.Objects.requireNonNull; + +/** + * A workout configuration. + * + * @author Tobias Bressler + * @version 1.0 + */ +public class Workout { + + /* True if this workout is a single workout. */ + private boolean isSingleWorkout = true; + + /* A list of workout intervals. */ + private final List intervals = new ArrayList<>(); + + /* The unit for the distance/duration of the single or interval workout. */ + private final WorkoutUnit unit; + + + /** + * A workout configuration. + * + * @param value The distance (in meters/strokes) or duration (in seconds) of the workout. When unit = METERS, + * MILES or KMS: this value is in Meters, the display value for miles is a conversion and valid + * values are 0x0001 to 0xFA00. When unit = STROKES this value is the number of strokes and valid + * values are 0x0001 to 0x1388. When unit = SECONDS this value is in seconds. Valid values are 0x0001 + * to 0x4650. This value is limited to 5 Hours, which is 18,000 seconds. + * @param unit The unit for the distance/duration of the single or interval workout. + */ + public Workout(int value, WorkoutUnit unit) { + this.unit = requireNonNull(unit); + intervals.add(new WorkoutInterval(value, unit)); + } + + + /** + * Adds an interval to the workout. 8 additional intervals can be added. + * + * @param restInterval The rest interval (in seconds). Valid values are 0x0001 to 0x0E10. + * @param value The distance/duration of the interval, using the same workout unit from the + * first interval (constructor). When unit = METERS, MILES or KMS: this value + * is in Meters, the display value for miles is a conversion and valid values + * are 0x0001 to 0xFA00. When unit = STROKES this value is the number of + * strokes and valid values are 0x0001 to 0x1388. When unit = SECONDS this + * value is in seconds. Valid values are 0x0001 to 0x4650. This value is + * limited to 5 Hours, which is 18,000 seconds. + */ + public void addInterval(int restInterval, int value) { + if (intervals.size() > 8) + throw new IllegalStateException("Only 8 additional intervals allowed!"); + if (restInterval < 1) + throw new IllegalArgumentException("Rest interval must be greater than 0!"); + isSingleWorkout = false; + intervals.add(new WorkoutInterval(restInterval, value, unit)); + } + + + /** + * Returns true if this is a single workout (only one interval). + * + * @return True if this is a single workout. + */ + public boolean isSingleWorkout() { + return isSingleWorkout; + } + + + /** + * Returns true if this is an interval workout (more than one interval). + * + * @return True if this is an interval workout. + */ + public boolean isIntervalWorkout() { + return !isSingleWorkout; + } + + + /** + * Returns the unit of the workout distance/duration. + * + * @return The unit of the workout distance/duration. + */ + public WorkoutUnit getUnit() { + return unit; + } + + + /** + * Returns the intervals of the workout. + * + * @return The intervals of the workout. + */ + public List getWorkoutIntervals() { + return intervals; + } + + + @Override + public String toString() { + return toStringHelper(this) + .add("unit", unit) + .add("intervals(size)", intervals.size()) + .add("isSingleWorkout", isSingleWorkout) + .toString(); + } + +} \ No newline at end of file diff --git a/src/android/src/de/tbressler/waterrower/workout/WorkoutInterval.java b/src/android/src/de/tbressler/waterrower/workout/WorkoutInterval.java new file mode 100644 index 0000000000..a0f1547228 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/workout/WorkoutInterval.java @@ -0,0 +1,138 @@ +package de.tbressler.waterrower.workout; + +import static com.google.common.base.MoreObjects.toStringHelper; +import static java.util.Objects.requireNonNull; + +/** + * A workout interval (a part of a workout). + * + * @author Tobias Bressler + * @version 1.0 + */ +public class WorkoutInterval { + + /* The rest interval. */ + private final int restInterval; + + /* The distance or duration (depending on the unit). */ + private final int value; + + /* The unit of the workout distance/duration. */ + private final WorkoutUnit unit; + + + /** + * A workout interval. + * + * @param value The distance (in meters/strokes) or duration (in seconds) of the workout. When unit = METERS, + * MILES or KMS: this value is in Meters, the display value for miles is a conversion and valid + * values are 0x0001 to 0xFA00. When unit = STROKES this value is the number of strokes and valid + * values are 0x0001 to 0x1388. When unit = SECONDS this value is in seconds. Valid values are 0x0001 + * to 0x4650. This value is limited to 5 Hours, which is 18,000 seconds. + * @param unit The unit of the workout distance/duration, must not be null. + */ + public WorkoutInterval(int value, WorkoutUnit unit) { + this(0, value, unit); + } + + + /** + * A workout interval. + * + * @param restInterval The rest interval (in seconds) or 0 if no rest interval must be + * set. Usually for single workouts or the first interval of an interval + * workout. Valid values are 0x0000 to 0x0E10. + * @param value The distance (in meters/strokes) or duration (in seconds) of the workout. When unit = METERS, + * MILES or KMS: this value is in Meters, the display value for miles is a conversion and valid + * values are 0x0001 to 0xFA00. When unit = STROKES this value is the number of strokes and valid + * values are 0x0001 to 0x1388. When unit = SECONDS this value is in seconds. Valid values are 0x0001 + * to 0x4650. This value is limited to 5 Hours, which is 18,000 seconds. + * @param unit The unit of the workout distance/duration, must not be null. + */ + public WorkoutInterval(int restInterval, int value, WorkoutUnit unit) { + this.restInterval = checkRestInterval(restInterval); + this.unit = requireNonNull(unit); + this.value = checkValue(value, unit); + } + + /* Checks if the rest interval is in range. */ + private int checkRestInterval(int restInterval) { + if ((restInterval < 0x0000) || (restInterval > 0x0E10)) + throw new IllegalArgumentException("The rest interval must be between 0x0000 and 0x0E10!"); + return restInterval; + } + + /* Checks if distance or duration is in range. */ + private int checkValue(int distance, WorkoutUnit unit) { + switch(unit) { + case METERS: + case MILES: + case KMS: + // When unit = METERS, MILES or KMS: this value is in Meters, the display value for + // miles is a conversion and valid values are 0x0001 to 0xFA00. + checkRange(distance, 0x0001, 0xFA00, "The distance of the workout must be between 0x0001 and 0xFA00!"); + break; + case STROKES: + // When unit = STROKES this value is the number of strokes and valid values are + // 0x0001 to 0x1388. + checkRange(distance, 0x0001, 0x1388, "The distance of the workout must be between 0x0001 and 0x1388!"); + break; + case SECONDS: + // When unit = SECONDS this value is in seconds. Valid values are 0x0001 to 0x4650. This value is limited + // to 5 Hours, which is 18,000 seconds. + checkRange(distance, 0x0001, 0x4650, "The duration of the workout must be between 0x0001 and 0x4650!"); + break; + } + return distance; + } + + /* Checks if the range is correct. */ + private void checkRange(int distance, int min, int max, String msg) { + if ((distance < min) || (distance > max)) + throw new IllegalArgumentException(msg); + } + + + /** + * The rest interval in seconds. + * + * Can be 0 if no rest interval is specified. Usually this is the case if this is the first + * interval of an interval workout. + * + * @return The rest interval. + */ + public int getRestInterval() { + return restInterval; + } + + + /** + * Returns the distance or duration of the workout interval. + * + * @return The distance or duration. + */ + public int getValue() { + return value; + } + + + /** + * Returns the unit of the workout distance/duration. + * + * @return The unit of the workout distance/duration. + */ + public WorkoutUnit getUnit() { + return unit; + } + + + @Override + public String toString() { + return toStringHelper(this) + .add("restInterval", restInterval) + .add("value", value) + .add("unit", unit) + .toString(); + } + +} diff --git a/src/android/src/de/tbressler/waterrower/workout/WorkoutUnit.java b/src/android/src/de/tbressler/waterrower/workout/WorkoutUnit.java new file mode 100644 index 0000000000..b4d4ce8313 --- /dev/null +++ b/src/android/src/de/tbressler/waterrower/workout/WorkoutUnit.java @@ -0,0 +1,26 @@ +package de.tbressler.waterrower.workout; + +/** + * Different units for distance and duration workouts. + * + * @author Tobias Bressler + * @version 1.0 + */ +public enum WorkoutUnit { + + /* Meters */ + METERS, + + /* Miles */ + MILES, + + /* Km's */ + KMS, + + /* Strokes */ + STROKES, + + /* Seconds */ + SECONDS + +} diff --git a/src/devices/bluetooth.cpp b/src/devices/bluetooth.cpp index 95fbe5e811..61a8624161 100644 --- a/src/devices/bluetooth.cpp +++ b/src/devices/bluetooth.cpp @@ -75,6 +75,8 @@ bluetooth::bluetooth(bool logs, const QString &deviceName, bool noWriteResistanc settings.value(QZSettings::proform_elliptical_ip, QZSettings::default_proform_elliptical_ip).toString(); QString proform_rower_ip_ctor = settings.value(QZSettings::proform_rower_ip, QZSettings::default_proform_rower_ip).toString(); + bool waterrower_usb_ctor = + settings.value(QZSettings::waterrower_usb, QZSettings::default_waterrower_usb).toBool(); bool fake_bike = settings.value(QZSettings::applewatch_fakedevice, QZSettings::default_applewatch_fakedevice).toBool(); bool fake_treadmill = @@ -95,7 +97,8 @@ bluetooth::bluetooth(bool logs, const QString &deviceName, bool noWriteResistanc bool reliesOnFakeOrVirtualDevice = fake_bike || fake_treadmill || fakedevice_elliptical_ctor || fakedevice_rower_ctor || !nordictrack_2950_ip.isEmpty() || !tdf_10_ip_ctor.isEmpty() || !proform_elliptical_ip_ctor.isEmpty() || - !proform_rower_ip_ctor.isEmpty() || antbike_ctor || android_antbike_ctor; + !proform_rower_ip_ctor.isEmpty() || antbike_ctor || android_antbike_ctor || + waterrower_usb_ctor; if (!gymMode && settings.value(QZSettings::peloton_bike_ocr, QZSettings::default_peloton_bike_ocr).toBool() && !pelotonBike) { @@ -220,10 +223,11 @@ void bluetooth::finished() { bool fakedevice_rower = settings.value(QZSettings::fakedevice_rower, QZSettings::default_fakedevice_rower).toBool(); bool fakedevice_treadmill = settings.value(QZSettings::fakedevice_treadmill, QZSettings::default_fakedevice_treadmill).toBool(); + bool waterrower_usb = settings.value(QZSettings::waterrower_usb, QZSettings::default_waterrower_usb).toBool(); // wifi devices on windows - if (!nordictrack_2950_ip.isEmpty() || !tdf_10_ip.isEmpty() || fake_bike || fakedevice_elliptical || fakedevice_rower || fakedevice_treadmill || !proform_elliptical_ip.isEmpty() || !proform_rower_ip.isEmpty() || antbike || android_antbike) { - // faking a bluetooth device - qDebug() << "faking a bluetooth device for nordictrack_2950_ip"; + if (!nordictrack_2950_ip.isEmpty() || !tdf_10_ip.isEmpty() || fake_bike || fakedevice_elliptical || fakedevice_rower || fakedevice_treadmill || !proform_elliptical_ip.isEmpty() || !proform_rower_ip.isEmpty() || antbike || android_antbike || waterrower_usb) { + // faking a bluetooth device for non-BLE devices + qDebug() << "faking a bluetooth device for non-BLE device"; deviceDiscovered(QBluetoothDeviceInfo()); } @@ -624,6 +628,7 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) { QString kettlerUsbSerialPort = settings.value(QZSettings::kettler_usb_serialport, QZSettings::default_kettler_usb_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 = settings.value(QZSettings::csafe_elliptical_port, QZSettings::default_csafe_elliptical_port).toString(); bool manufacturerDeviceFound = false; @@ -963,6 +968,18 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) { } this->signalBluetoothDeviceConnected(csafeRower); + } else if (waterrowerUSBEnabled && !waterRowerUSB) { + qDebug() << QStringLiteral("WaterRower USB enabled, creating USB rower device"); + this->stopDiscovery(); + waterRowerUSB = new waterrowerusb(noWriteResistance, noHeartService, false); + emit deviceConnected(b); + connect(waterRowerUSB, &bluetoothdevice::connectedAndDiscovered, this, &bluetooth::connectedAndDiscovered); + connect(waterRowerUSB, &waterrowerusb::debug, this, &bluetooth::debug); + waterRowerUSB->deviceDiscovered(b); + if (this->discoveryAgent && !this->discoveryAgent->isActive()) { + emit searchingStop(); + } + this->signalBluetoothDeviceConnected(waterRowerUSB); } else if (!csafeellipticalSerialPort.isEmpty() && !csafeElliptical) { this->stopDiscovery(); // csafeElliptical = new csafeelliptical(noWriteResistance, noHeartService, false); @@ -3877,6 +3894,11 @@ void bluetooth::restart() { delete nordictrackifitadbRower; nordictrackifitadbRower = nullptr; } + if (waterRowerUSB) { + + delete waterRowerUSB; + waterRowerUSB = nullptr; + } if (powerBike) { delete powerBike; @@ -4483,6 +4505,8 @@ bluetoothdevice *bluetooth::device() { return concept2Skierg; } else if (smartrowRower) { return smartrowRower; + } else if (waterRowerUSB) { + return waterRowerUSB; } else if (yesoulBike) { return yesoulBike; } else if (proformBike) { diff --git a/src/devices/bluetooth.h b/src/devices/bluetooth.h index 1a430d7d16..c5ffffb3b8 100644 --- a/src/devices/bluetooth.h +++ b/src/devices/bluetooth.h @@ -150,6 +150,7 @@ #include "devices/trxappgateusbelliptical/trxappgateusbelliptical.h" #include "devices/trxappgateusbrower/trxappgateusbrower.h" #include "devices/trxappgateusbtreadmill/trxappgateusbtreadmill.h" +#include "devices/waterrowerusb/waterrowerusb.h" #include "devices/ultrasportbike/ultrasportbike.h" #include "devices/wahookickrheadwind/wahookickrheadwind.h" #include "devices/wahookickrsnapbike/wahookickrsnapbike.h" @@ -279,6 +280,7 @@ class bluetooth : public QObject, public SignalHandler { echelonrower *echelonRower = nullptr; ftmsrower *ftmsRower = nullptr; smartrowrower *smartrowRower = nullptr; + waterrowerusb *waterRowerUSB = nullptr; sunnyfitstepper *sunnyfitStepper = nullptr; echelonstride *echelonStride = nullptr; echelonstairclimber *echelonStairclimber = nullptr; diff --git a/src/devices/waterrowerusb/waterrowerusb.cpp b/src/devices/waterrowerusb/waterrowerusb.cpp new file mode 100644 index 0000000000..09e7092254 --- /dev/null +++ b/src/devices/waterrowerusb/waterrowerusb.cpp @@ -0,0 +1,413 @@ +#include "waterrowerusb.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "qzsettings.h" +#include "virtualdevices/virtualbike.h" +#include "virtualdevices/virtualrower.h" + +#ifdef Q_OS_ANDROID +#include +#include +#endif + +using namespace std::chrono_literals; + +waterrowerusbThread::waterrowerusbThread(QObject *parent) : QThread(parent) { + qDebug() << QStringLiteral("waterrowerusbThread::waterrowerusbThread()"); +} + +void waterrowerusbThread::run() { + qDebug() << QStringLiteral("waterrowerusbThread::run() start"); + + mutex.lock(); + running = true; + mutex.unlock(); + + while (running) { +#ifdef Q_OS_ANDROID + bool isConnected = QAndroidJniObject::callStaticMethod( + "org/cagnulen/qdomyoszwift/WaterRowerBridge", + "isConnected", + "()Z"); + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (!isConnected && (lastInitializeAttempt == 0 || now - lastInitializeAttempt > 5000)) { + lastInitializeAttempt = now; + initializeWaterRower(); + } + processWaterRowerData(); +#endif + QThread::msleep(200); // Poll every 200ms + } + +#ifdef Q_OS_ANDROID + shutdownWaterRower(); +#endif + + qDebug() << QStringLiteral("waterrowerusbThread::run() end"); +} + +void waterrowerusbThread::stop() { + qDebug() << QStringLiteral("waterrowerusbThread::stop()"); + mutex.lock(); + running = false; + mutex.unlock(); +} + +#ifdef Q_OS_ANDROID +void waterrowerusbThread::initializeWaterRower() { + emit onDebug(QStringLiteral("Initializing WaterRower USB connection...")); + + // Call the Java WaterRower initialization + QAndroidJniEnvironment env; + + // Get the device path + QAndroidJniObject devicePathObject = QAndroidJniObject::callStaticObjectMethod( + "org/cagnulen/qdomyoszwift/WaterRowerBridge", + "getDevicePath", + "(Landroid/content/Context;)Ljava/lang/String;", + QtAndroid::androidContext().object()); + + if (env->ExceptionCheck()) { + env->ExceptionClear(); + emit onError(QStringLiteral("Failed to get WaterRower device path")); + return; + } + + QString devicePath = devicePathObject.toString(); + if (devicePath.isEmpty()) { + emit onDebug(QStringLiteral("WaterRower device not found.")); + return; + } + + emit onDebug(QStringLiteral("WaterRower device found at: ") + devicePath); + + // Create WaterRower instance through JNI + QAndroidJniObject result = QAndroidJniObject::callStaticObjectMethod( + "org/cagnulen/qdomyoszwift/WaterRowerBridge", + "connect", + "(Landroid/content/Context;Ljava/lang/String;)Ljava/lang/String;", + QtAndroid::androidContext().object(), QAndroidJniObject::fromString(devicePath).object()); + + if (env->ExceptionCheck()) { + env->ExceptionClear(); + emit onError(QStringLiteral("Failed to connect to WaterRower")); + return; + } + + QString initResult = result.toString(); + emit onDebug(QStringLiteral("WaterRower connection result: ") + initResult); + + if (initResult == "SUCCESS") { + emit onDebug(QStringLiteral("WaterRower connection successful - waiting for data...")); + // Don't emit onConnected() here - wait for actual device connection from processWaterRowerData() + } else { + emit onError(QStringLiteral("WaterRower connection failed: ") + initResult); + } +} + +void waterrowerusbThread::processWaterRowerData() { + QAndroidJniEnvironment env; + + // Get stroke data from Java + QAndroidJniObject strokeData = QAndroidJniObject::callStaticObjectMethod( + "org/cagnulen/qdomyoszwift/WaterRowerBridge", + "getStrokeData", + "()Ljava/lang/String;"); + + if (env->ExceptionCheck()) { + env->ExceptionClear(); + return; + } + + QString data = strokeData.toString(); + if (!data.isEmpty() && data != "NO_DATA") { + // Parse data format: "strokeRate,distance,pace,watts,calories,strokeCount" + QStringList values = data.split(','); + if (values.size() >= 5) { + double strokeRate = values[0].toDouble(); + double distance = values[1].toDouble(); + double pace = values[2].toDouble(); + double watts = values[3].toDouble(); + double calories = values[4].toDouble(); + double strokeCount = values.size() >= 6 ? values[5].toDouble() : 0; + + emit onStroke(strokeRate, distance, pace, watts, calories, strokeCount); + } + } + + // Check connection status + bool isConnected = QAndroidJniObject::callStaticMethod( + "org/cagnulen/qdomyoszwift/WaterRowerBridge", + "isConnected", + "()Z"); + + static bool lastConnectedState = false; + if (isConnected != lastConnectedState) { + lastConnectedState = isConnected; + if (isConnected) { + emit onDebug(QStringLiteral("WaterRower device connected")); + emit onConnected(); + } else { + emit onDebug(QStringLiteral("WaterRower device disconnected")); + emit onDisconnected(); + } + } +} + +void waterrowerusbThread::shutdownWaterRower() { + emit onDebug(QStringLiteral("Shutting down WaterRower connection...")); + +#ifdef Q_OS_ANDROID + QAndroidJniObject::callStaticMethod( + "org/cagnulen/qdomyoszwift/WaterRowerBridge", + "shutdown", + "()V"); +#endif +} +#endif + +waterrowerusb::waterrowerusb(bool noWriteResistance, bool noHeartService, bool noVirtualDevice) { + qDebug() << QStringLiteral("waterrowerusb::waterrowerusb()"); + + this->noWriteResistance = noWriteResistance; + this->noHeartService = noHeartService; + this->noVirtualDevice = noVirtualDevice; + + refresh = new QTimer(this); + connect(refresh, &QTimer::timeout, this, &waterrowerusb::update); + refresh->start(200ms); + +#ifdef Q_OS_IOS + h = new lockscreen(); +#endif + + // Create and start worker thread + workerThread = new waterrowerusbThread(this); + connect(workerThread, &waterrowerusbThread::onDebug, this, &waterrowerusb::debug); + connect(workerThread, &waterrowerusbThread::onConnected, this, &waterrowerusb::onWaterRowerConnected); + connect(workerThread, &waterrowerusbThread::onDisconnected, this, &waterrowerusb::onWaterRowerDisconnected); + connect(workerThread, &waterrowerusbThread::onError, this, &waterrowerusb::onWaterRowerError); + connect(workerThread, &waterrowerusbThread::onStroke, this, &waterrowerusb::onWaterRowerStroke); + + workerThread->start(); +} + +waterrowerusb::~waterrowerusb() { + qDebug() << QStringLiteral("~waterrowerusb()"); + + if (workerThread) { + workerThread->stop(); + workerThread->wait(3000); + delete workerThread; + } + +#ifdef Q_OS_IOS + if (h) + delete h; +#endif +} + +void waterrowerusb::deviceDiscovered(const QBluetoothDeviceInfo &device) { + Q_UNUSED(device) + // WaterRower USB doesn't use Bluetooth discovery +} + +bool waterrowerusb::connected() { + return initDone; +} + +uint16_t waterrowerusb::watts() { + return m_watt.value(); +} + +void waterrowerusb::update() { + if (initRequest) { + initRequest = false; + // WaterRower USB initialization is handled by the worker thread + qDebug() << QStringLiteral("WaterRower USB init requested"); + } + + if (initDone) { + QSettings settings; + QString heartRateBeltName = + settings.value(QZSettings::heart_rate_belt_name, QZSettings::default_heart_rate_belt_name).toString(); + bool heart_rate_check = heartRateBeltName.startsWith(QStringLiteral("Disabled")); + + update_metrics(false, watts()); + + if (Cadence.value() > 0) { + CrankRevs++; + LastCrankEventTime += (uint16_t)(1024.0 / (((double)(Cadence.value())) / 60.0)); + } + + // ******************************************* virtual bike/rower init ************************************* + 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(); + bool virtual_device_rower = + settings.value(QZSettings::virtual_device_rower, QZSettings::default_virtual_device_rower).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 && !virtual_device_rower) { + qDebug() << "ios_peloton_workaround activated!"; + h = new lockscreen(); + h->virtualbike_ios(); + } else +#endif +#endif + { + if (!noVirtualDevice && virtual_device_enabled) { + if (!virtual_device_rower) { + qDebug() << QStringLiteral("creating virtual bike interface..."); + auto virtualBike = new virtualbike(this, noWriteResistance, noHeartService); + this->setVirtualDevice(virtualBike, VIRTUAL_DEVICE_MODE::PRIMARY); + } else { + qDebug() << QStringLiteral("creating virtual rower interface..."); + auto virtualRower = new virtualrower(this, noWriteResistance, noHeartService); + this->setVirtualDevice(virtualRower, VIRTUAL_DEVICE_MODE::PRIMARY); + } + } + } + } + if (!firstStateChanged) + emit connectedAndDiscovered(); + firstStateChanged = 1; + // ******************************************************************************************************** + + if (Heart.value() == 0.0 && !heart_rate_check) { + 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_ios()->setCadence(currentCrankRevolutions(), lastCrankEventTime()); + h->virtualbike_ios()->setHeartRate((uint8_t)currentHeart().value()); + } +#endif +#endif + + if (sec1Update++ == (1000 / refresh->interval())) { + sec1Update = 0; + updateDisplay(elapsed.value()); + } + + if (!noVirtualDevice) { +#ifdef Q_OS_ANDROID + if (settings.value(QZSettings::ant_heart, QZSettings::default_ant_heart).toBool()) + Heart = (uint8_t)KeepAwakeHelper::heart(); + else +#endif + { + if (heartRateBeltName.startsWith(QStringLiteral("Disabled"))) { + update_hr_from_external(); + } + } +#ifdef Q_OS_IOS +#ifndef IO_UNDER_QT + if (h) { + h->virtualbike_ios()->setHeartRate((uint8_t)currentHeart().value()); + } +#endif +#endif + } + } +} + +void waterrowerusb::onWaterRowerConnected() { + qDebug() << QStringLiteral("WaterRower USB connected"); + emit debug(QStringLiteral("WaterRower USB connected")); + initDone = true; + // emit connectedChanged(); // This signal doesn't exist in base class +} + +void waterrowerusb::onWaterRowerDisconnected() { + qDebug() << QStringLiteral("WaterRower USB disconnected"); + emit debug(QStringLiteral("WaterRower USB disconnected")); + initDone = false; + emit disconnected(); +} + +void waterrowerusb::onWaterRowerError(QString error) { + qDebug() << QStringLiteral("WaterRower USB error:") << error; + emit debug(QStringLiteral("WaterRower USB error: ") + error); +} + +void waterrowerusb::onWaterRowerStroke(double strokeRate, double distance, double pace, double watts, double calories, double strokeCount) { + qDebug() << QStringLiteral("WaterRower stroke data - Rate:") << strokeRate + << QStringLiteral("Distance:") << distance + << QStringLiteral("Pace:") << pace + << QStringLiteral("Watts:") << watts + << QStringLiteral("Calories:") << calories + << QStringLiteral("Stroke Count:") << strokeCount; + + // WaterRower reports distance in meters; QZ stores distance in kilometers. + const double distanceKm = distance / 1000.0; + Cadence = strokeRate; + Distance = distanceKm; + Distance1s = distanceKm; + StrokesCount = strokeCount; + if (watts > 0) { + m_watt = watts; + } else if (strokeRate <= 0 || m_watt.value() <= 0) { + m_watt = rower::calculateWattsFromPace(pace); + } + KCal = calories; + + // The USB bridge pace/velocity is quantized and makes the speed graph jump. + // Derive speed from distance deltas and smooth it before publishing. + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (distance <= 0 || lastSpeedDistanceMeters < 0 || distance < lastSpeedDistanceMeters) { + lastSpeedDistanceMeters = distance; + lastSpeedDistanceTimestamp = now; + filteredSpeedKph = 0; + Speed = 0; + } else if (distance > lastSpeedDistanceMeters) { + const qint64 deltaMs = now - lastSpeedDistanceTimestamp; + if (deltaMs >= 2000) { + const double deltaMeters = distance - lastSpeedDistanceMeters; + const double rawSpeedKph = (deltaMeters / (static_cast(deltaMs) / 1000.0)) * 3.6; + if (rawSpeedKph <= 30.0) { + filteredSpeedKph = filteredSpeedKph > 0 ? (filteredSpeedKph * 0.8) + (rawSpeedKph * 0.2) : rawSpeedKph; + Speed = filteredSpeedKph; + } + lastSpeedDistanceMeters = distance; + lastSpeedDistanceTimestamp = now; + } + } else if (lastSpeedDistanceTimestamp > 0 && now - lastSpeedDistanceTimestamp > 3000) { + filteredSpeedKph = 0; + Speed = 0; + } + + emit debug(QStringLiteral("Updated metrics - Cadence: %1, Distance: %2, Watts: %3, Speed: %4, Stroke Count: %5") + .arg(Cadence.value()).arg(Distance.value()).arg(m_watt.value()).arg(Speed.value()) + .arg(StrokesCount.value())); +} + +void waterrowerusb::updateDisplay(uint16_t elapsed) { + Q_UNUSED(elapsed); + // WaterRower USB doesn't need display updates +} diff --git a/src/devices/waterrowerusb/waterrowerusb.h b/src/devices/waterrowerusb/waterrowerusb.h new file mode 100644 index 0000000000..1be6b4e02e --- /dev/null +++ b/src/devices/waterrowerusb/waterrowerusb.h @@ -0,0 +1,116 @@ +#ifndef WATERROWERUSB_H +#define WATERROWERUSB_H + +#include +#include + +#ifndef Q_OS_ANDROID +#include +#else +#include +#endif +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "devices/rower.h" + +#ifdef Q_OS_ANDROID +#include "keepawakehelper.h" +#include +#endif + +#ifdef Q_OS_IOS +#include "ios/lockscreen.h" +#endif + +class waterrowerusbThread : public QThread { + Q_OBJECT + +public: + explicit waterrowerusbThread(QObject *parent = nullptr); + void run() override; + void stop(); + +signals: + void onDebug(QString debug); + void onConnected(); + void onDisconnected(); + void onError(QString error); + void onStroke(double strokeRate, double distance, double pace, double watts, double calories, double strokeCount); + +private: + bool running = false; + qint64 lastInitializeAttempt = 0; + QMutex mutex; + +#ifdef Q_OS_ANDROID + void initializeWaterRower(); + void shutdownWaterRower(); + void processWaterRowerData(); +#endif +}; + +class waterrowerusb : public rower { + Q_OBJECT +public: + waterrowerusb(bool noWriteResistance, bool noHeartService, bool noVirtualDevice); + ~waterrowerusb() override; + bool connected() override; + +private: + QTimer *refresh; + waterrowerusbThread *workerThread; + + uint8_t sec1Update = 0; + QByteArray lastPacket; + QDateTime lastRefreshCharacteristicChanged = QDateTime::currentDateTime(); + QDateTime lastGoodCadence = QDateTime::currentDateTime(); + uint8_t firstStateChanged = 0; + + uint16_t watts() override; + void updateDisplay(uint16_t elapsed); + + bool initDone = false; + bool initRequest = false; + + bool noWriteResistance = false; + bool noHeartService = false; + bool noVirtualDevice = false; + + uint16_t oldLastCrankEventTime = 0; + uint16_t oldCrankRevs = 0; + + bool distanceIsChanging = false; + metric distanceReceived; + + double lastSpeedDistanceMeters = -1.0; + qint64 lastSpeedDistanceTimestamp = 0; + double filteredSpeedKph = 0.0; + +#ifdef Q_OS_IOS + lockscreen *h = 0; +#endif + +signals: + void disconnected(); + void debug(QString string); + +private slots: + void update(); + void onWaterRowerConnected(); + void onWaterRowerDisconnected(); + void onWaterRowerError(QString error); + void onWaterRowerStroke(double strokeRate, double distance, double pace, double watts, double calories, double strokeCount); + +public slots: + void deviceDiscovered(const QBluetoothDeviceInfo &device); +}; + +#endif // WATERROWERUSB_H diff --git a/src/homeform.cpp b/src/homeform.cpp index 6a1024e792..4e5aad9e1b 100644 --- a/src/homeform.cpp +++ b/src/homeform.cpp @@ -7626,6 +7626,8 @@ void homeform::update() { // Get resistance and inclination values int resistance = 0; double inclination = 0.0; + int antEquipmentType = 0x19; // ANT+ FE Trainer/Stationary Bike + int strokeCount = 0; if (bluetoothManager->device()->deviceType() == BIKE) { resistance = (int)((bike*)bluetoothManager->device())->currentResistance().value(); @@ -7634,17 +7636,21 @@ void homeform::update() { resistance = (int)((elliptical*)bluetoothManager->device())->currentResistance().value(); inclination = ((elliptical*)bluetoothManager->device())->currentInclination().value(); } else if (bluetoothManager->device()->deviceType() == ROWING) { + antEquipmentType = 0x16; // ANT+ FE Rower resistance = (int)((rower*)bluetoothManager->device())->currentResistance().value(); + strokeCount = (int)((rower*)bluetoothManager->device())->currentStrokesCount().value(); } // Call the extended metrics update via JNI KeepAwakeHelper::antObject(false)->callMethod("updateBikeTransmitterExtendedMetrics", - "(JIDID)V", + "(JIDIDII)V", distanceMeters, heartRate, elapsedTimeSeconds, resistance, - inclination); + inclination, + antEquipmentType, + strokeCount); } #endif diff --git a/src/homeform.h b/src/homeform.h index fa772a2006..08635f8955 100644 --- a/src/homeform.h +++ b/src/homeform.h @@ -409,6 +409,7 @@ class homeform : public QObject { bool fakedevice_elliptical = settings.value(QZSettings::fakedevice_elliptical, QZSettings::default_fakedevice_elliptical).toBool(); bool fakedevice_rower = settings.value(QZSettings::fakedevice_rower, QZSettings::default_fakedevice_rower).toBool(); + bool waterrower_usb = settings.value(QZSettings::waterrower_usb, QZSettings::default_waterrower_usb).toBool(); bool fakedevice_treadmill = settings.value(QZSettings::fakedevice_treadmill, QZSettings::default_fakedevice_treadmill).toBool(); bool antbike = @@ -416,7 +417,7 @@ 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 && !fakedevice_treadmill && !antbike && !android_antbike && proform_elliptical_ip.isEmpty() && + !fakedevice_rower && !waterrower_usb && !fakedevice_treadmill && !antbike && !android_antbike && proform_elliptical_ip.isEmpty() && proformtdf4ip.isEmpty() && proformtdf1ip.isEmpty() && proformtreadmillip.isEmpty(); } diff --git a/src/qdomyos-zwift.pri b/src/qdomyos-zwift.pri index 114521c625..ab9562e63e 100644 --- a/src/qdomyos-zwift.pri +++ b/src/qdomyos-zwift.pri @@ -301,6 +301,7 @@ simplecrypt.cpp \ devices/skandikawiribike/skandikawiribike.cpp \ devices/smartrowrower/smartrowrower.cpp \ devices/smartspin2k/smartspin2k.cpp \ +devices/waterrowerusb/waterrowerusb.cpp \ smtpclient/src/emailaddress.cpp \ smtpclient/src/mimeattachment.cpp \ smtpclient/src/mimecontentformatter.cpp \ @@ -826,6 +827,7 @@ simplecrypt.h \ devices/skandikawiribike/skandikawiribike.h \ devices/smartrowrower/smartrowrower.h \ devices/smartspin2k/smartspin2k.h \ +devices/waterrowerusb/waterrowerusb.h \ smtpclient/src/SmtpMime \ smtpclient/src/emailaddress.h \ smtpclient/src/mimeattachment.h \ diff --git a/src/qzsettings.cpp b/src/qzsettings.cpp index 86ac061a41..7d517ef786 100644 --- a/src/qzsettings.cpp +++ b/src/qzsettings.cpp @@ -753,6 +753,7 @@ const QString QZSettings::csafe_rower = QStringLiteral("csafe_rower"); const QString QZSettings::default_csafe_rower = QStringLiteral(""); const QString QZSettings::csafe_elliptical_port = QStringLiteral("csafe_elliptical_port"); const QString QZSettings::default_csafe_elliptical_port = QStringLiteral(""); +const QString QZSettings::waterrower_usb = QStringLiteral("waterrower_usb"); const QString QZSettings::ftms_rower = QStringLiteral("ftms_rower"); const QString QZSettings::default_ftms_rower = QStringLiteral("Disabled"); const QString QZSettings::ftms_elliptical = QStringLiteral("ftms_elliptical"); @@ -1276,7 +1277,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 = 999; +const uint32_t allSettingsCount = 1000; QVariant allSettings[allSettingsCount][2] = { {QZSettings::cryptoKeySettingsProfiles, QZSettings::default_cryptoKeySettingsProfiles}, @@ -1899,6 +1900,7 @@ QVariant allSettings[allSettingsCount][2] = { {QZSettings::tts_act_target_pace, QZSettings::default_tts_act_target_pace}, {QZSettings::csafe_rower, QZSettings::default_csafe_rower}, {QZSettings::csafe_elliptical_port, QZSettings::default_csafe_elliptical_port}, + {QZSettings::waterrower_usb, QZSettings::default_waterrower_usb}, {QZSettings::ftms_rower, QZSettings::default_ftms_rower}, {QZSettings::ftms_elliptical, QZSettings::default_ftms_elliptical}, {QZSettings::zwift_workout_ocr, QZSettings::default_zwift_workout_ocr}, diff --git a/src/qzsettings.h b/src/qzsettings.h index e7a4853c27..799714e8fe 100644 --- a/src/qzsettings.h +++ b/src/qzsettings.h @@ -2076,6 +2076,9 @@ class QZSettings { static const QString csafe_elliptical_port; static const QString default_csafe_elliptical_port; + static const QString waterrower_usb; + static constexpr bool default_waterrower_usb = false; + static const QString ftms_rower; static const QString default_ftms_rower; diff --git a/src/settings-catalog.json b/src/settings-catalog.json index 70272801cd..2f18a1a8fd 100644 --- a/src/settings-catalog.json +++ b/src/settings-catalog.json @@ -13694,6 +13694,19 @@ "defaultValue": false, "defaultExpression": "false", "options": null + }, + { + "key": "waterrower_usb", + "name": "WaterRower USB", + "description": null, + "parent": "Rower Options", + "type": "boolean", + "qmlType": "bool", + "control": "switch", + "visible": true, + "defaultValue": false, + "defaultExpression": "false", + "options": null } ] } diff --git a/src/settings.qml b/src/settings.qml index 827d81b124..993b98e245 100644 --- a/src/settings.qml +++ b/src/settings.qml @@ -1508,6 +1508,7 @@ import AndroidStatusBar 1.0 property int tile_auto_virtual_shifting_sprint_order: 57 property string proform_rower_ip: "" property string ftms_elliptical: "Disabled" + property bool calories_active_only: false property real height: 175.0 property bool calories_from_hr: false @@ -1705,7 +1706,7 @@ import AndroidStatusBar 1.0 property bool tile_watt_color_enabled: true property bool tile_pace_color_enabled: true property bool treadmill_force_running_activity: false - property bool proform_treadmill_105_cst: false + property bool proform_treadmill_105_cst: false property real trainprogram_pid_hr_pushy_zone_limit: 0.8 property real trainprogram_pid_hr_recovery_zone_limit: 60.0 property bool rpe_feel_popup_enabled: false @@ -1734,6 +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 } @@ -11806,7 +11809,7 @@ import AndroidStatusBar 1.0 Layout.alignment: Qt.AlignRight | Qt.AlignVCenter onClicked: { settings.ftms_rower = stripRssi(ftmsRowerTextField.displayText); window.settings_restart_to_apply = true; toast.show(qsTr("Setting saved!")); } } - } + } Button { text: qsTr("Refresh Devices List") @@ -11827,6 +11830,21 @@ import AndroidStatusBar 1.0 color: Material.color(Material.Lime) } + CheckBox { + id: waterrowerUSBCheckBox + text: qsTr("WaterRower USB") + spacing: 0 + bottomPadding: 0 + topPadding: 0 + rightPadding: 0 + leftPadding: 0 + clip: false + checked: settings.waterrower_usb + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + Layout.fillWidth: true + onClicked: { settings.waterrower_usb = checked; window.settings_restart_to_apply = true; } + } + AccordionElement { title: qsTr("Proform/Nordictrack Rower Options") indicatRectColor: Material.color(Material.Grey)