From 634a7a4321202705aefe569e1a273abab6f4e935 Mon Sep 17 00:00:00 2001 From: Dennis Guse Date: Thu, 1 Oct 2020 17:55:33 +0200 Subject: [PATCH 1/5] Bluetooth Sensor: add cycling powermeter. --- README_TESTED_SENSORS.md | 10 +++++ .../opentracks/util/BluetoothUtilsTest.java | 12 ++++++ .../sensor/SensorDataCyclingPower.java | 32 ++++++++++++++ .../content/sensor/SensorDataSet.java | 23 ++++++++-- .../StatisticsRecordingFragment.java | 40 ++++++++++++++---- .../sensors/BluetoothConnectionManager.java | 30 ++++++++++++- .../sensors/BluetoothRemoteSensorManager.java | 9 ++++ .../opentracks/settings/SettingsActivity.java | 3 ++ .../BluetoothLeCyclingPowerPreference.java | 23 ++++++++++ .../opentracks/util/BluetoothUtils.java | 18 +++++++- .../opentracks/util/PreferencesUtils.java | 9 ++++ src/main/res/layout/statistics_recording.xml | 42 +++++++++++++++++++ src/main/res/values/colors.xml | 4 +- src/main/res/values/do_not_translate.xml | 1 + src/main/res/values/settings.xml | 1 + src/main/res/xml/settings.xml | 4 ++ 16 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingPower.java create mode 100644 src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingPowerPreference.java diff --git a/README_TESTED_SENSORS.md b/README_TESTED_SENSORS.md index 58f8dc3d0..3e6437c08 100644 --- a/README_TESTED_SENSORS.md +++ b/README_TESTED_SENSORS.md @@ -26,3 +26,13 @@ However, often only one value is provided. * Wahoo Cadence (Model: WFPODCAD2) This sensor reports cadence data as speed. A workaround is in place. + +## Cycling Power Measurement + +We only support _Instantaneous Power_. + +* QUARQ Red DZero Powermeter +* Wahoo Kickr v4.0 + WARNING: we only support physically movements with valid GPS coordinates. + Using a device indoors (no GPS available) will only show the current power values, but won't store anything. + Also the distance is not computed. \ No newline at end of file diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/BluetoothUtilsTest.java b/src/androidTest/java/de/dennisguse/opentracks/util/BluetoothUtilsTest.java index 30cf4dcc1..5137e9f6d 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/util/BluetoothUtilsTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/util/BluetoothUtilsTest.java @@ -75,4 +75,16 @@ public class BluetoothUtilsTest { assertEquals(200, sensor.getCadence().getCrankRevolutionsCount()); assertEquals(225, sensor.getSpeed().getWheelRevolutionsCount()); } + + @Test + public void parseCyclingPower_power() { + BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothUtils.CYCLING_POWER_MEASUREMENT_CHAR_UUID, 0, 0); + characteristic.setValue(new byte[]{0, 0, 40, 0}); + + // when + int power_w = BluetoothUtils.parseCyclingPower(characteristic); + + // then + assertEquals(40, power_w); + } } \ No newline at end of file diff --git a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingPower.java b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingPower.java new file mode 100644 index 000000000..705f58695 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingPower.java @@ -0,0 +1,32 @@ +package de.dennisguse.opentracks.content.sensor; + +import androidx.annotation.NonNull; + +public class SensorDataCyclingPower extends SensorData { + + private final Float power_w; + + public SensorDataCyclingPower(String address) { + super(address); + this.power_w = null; + } + + public SensorDataCyclingPower(String name, String address, float power_w) { + super(name, address); + this.power_w = power_w; + } + + public boolean hasPower_w() { + return power_w != null; + } + + public float getPower_w() { + return power_w; + } + + @NonNull + @Override + public String toString() { + return "power=" + power_w; + } +} diff --git a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataSet.java b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataSet.java index 9610f970a..7d9bd402c 100644 --- a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataSet.java +++ b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataSet.java @@ -12,6 +12,8 @@ public final class SensorDataSet { private SensorDataCycling.Speed cyclingSpeed; + private SensorDataCyclingPower cyclingPower; + public SensorDataSet() { } @@ -27,6 +29,10 @@ public final class SensorDataSet { return cyclingSpeed; } + public SensorDataCyclingPower getCyclingPower() { + return cyclingPower; + } + public void set(SensorData data) { set(data, data); } @@ -39,6 +45,7 @@ public final class SensorDataSet { this.heartRate = null; this.cyclingCadence = null; this.cyclingSpeed = null; + this.cyclingPower = null; } public void fillTrackPoint(TrackPoint trackPoint) { @@ -53,6 +60,10 @@ public final class SensorDataSet { if (cyclingSpeed != null && cyclingSpeed.hasSpeed_mps()) { trackPoint.setSpeed(cyclingSpeed.getSpeed_mps()); } + + if (cyclingPower != null && cyclingPower.hasPower_w()) { + trackPoint.setPower(cyclingPower.getPower_w()); + } } @NonNull @@ -60,10 +71,11 @@ public final class SensorDataSet { public String toString() { return (getHeartRate() != null ? "" + getHeartRate() : "") + (getCyclingCadence() != null ? " " + getCyclingCadence() : "") - + (getCyclingSpeed() != null ? " " + getCyclingSpeed() : ""); + + (getCyclingSpeed() != null ? " " + getCyclingSpeed() : "") + + (getCyclingPower() != null ? " " + getCyclingPower() : ""); } - private void set(SensorData type, SensorData data) { + private void set(@NonNull SensorData type, SensorData data) { if (type instanceof SensorDataHeartRate) { this.heartRate = (SensorDataHeartRate) data; return; @@ -78,6 +90,11 @@ public final class SensorDataSet { return; } - throw new UnsupportedOperationException(); + if (type instanceof SensorDataCyclingPower) { + this.cyclingPower = (SensorDataCyclingPower) data; + return; + } + + throw new UnsupportedOperationException(type.getClass().getCanonicalName()); } } diff --git a/src/main/java/de/dennisguse/opentracks/fragments/StatisticsRecordingFragment.java b/src/main/java/de/dennisguse/opentracks/fragments/StatisticsRecordingFragment.java index 52d236ca0..c801de5bc 100644 --- a/src/main/java/de/dennisguse/opentracks/fragments/StatisticsRecordingFragment.java +++ b/src/main/java/de/dennisguse/opentracks/fragments/StatisticsRecordingFragment.java @@ -22,6 +22,7 @@ import de.dennisguse.opentracks.content.data.Marker; import de.dennisguse.opentracks.content.data.Track; import de.dennisguse.opentracks.content.data.TrackPoint; import de.dennisguse.opentracks.content.sensor.SensorDataCycling; +import de.dennisguse.opentracks.content.sensor.SensorDataCyclingPower; import de.dennisguse.opentracks.content.sensor.SensorDataHeartRate; import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.databinding.StatisticsRecordingBinding; @@ -273,9 +274,9 @@ public class StatisticsRecordingFragment extends Fragment implements TrackDataLi if (sensorDataSet != null) { setHeartRateSensorData(sensorDataSet); setCadenceSensorData(sensorDataSet); - setSpeedSensorData(sensorDataSet, isSelectedTrackRecording()); + setPowerSensorData(sensorDataSet); + setSpeedSensorData(sensorDataSet); } - setTotalElevationGain(trackRecordingService.getElevationGain_m()); } } @@ -320,6 +321,29 @@ public class StatisticsRecordingFragment extends Fragment implements TrackDataLi viewBinding.statsSensorCadenceValue.setText(sensorValue); } + private void setPowerSensorData(SensorDataSet sensorDataSet) { + int isVisible = View.VISIBLE; + if (PreferencesUtils.isBluetoothCyclingPowerSensorAddressNone(getContext())) { + isVisible = View.GONE; + } + viewBinding.statsSensorPowerGroup.setVisibility(isVisible); + setVisibilitySensorHorizontalLine(); + + String sensorValue = getContext().getString(R.string.value_unknown); + String sensorName = getContext().getString(R.string.value_unknown); + if (sensorDataSet != null && sensorDataSet.getCyclingPower() != null) { + SensorDataCyclingPower data = sensorDataSet.getCyclingPower(); + sensorName = data.getSensorName(); + + if (data.hasPower_w() && data.isRecent()) { + sensorValue = StringUtils.formatDecimal(data.getPower_w(), 0); + } + } + + viewBinding.statsSensorPowerSensorValue.setText(sensorName); + viewBinding.statsSensorPowerValue.setText(sensorValue); + } + // Set elevation gain private void setTotalElevationGain(Float elevationGain_m) { //TODO Check if we can distribute the total elevation gain via trackStatistics instead of doing some computation in the UI layer. @@ -349,14 +373,12 @@ public class StatisticsRecordingFragment extends Fragment implements TrackDataLi } } - private void setSpeedSensorData(SensorDataSet sensorDataSet, boolean isRecording) { - if (isRecording) { - if (sensorDataSet != null && sensorDataSet.getCyclingSpeed() != null) { - SensorDataCycling.Speed data = sensorDataSet.getCyclingSpeed(); + private void setSpeedSensorData(SensorDataSet sensorDataSet) { + if (sensorDataSet != null && sensorDataSet.getCyclingSpeed() != null) { + SensorDataCycling.Speed data = sensorDataSet.getCyclingSpeed(); - if (data.hasSpeed_mps() && data.isRecent()) { - setSpeed(data.getSpeed_mps()); - } + if (data.hasSpeed_mps() && data.isRecent()) { + setSpeed(data.getSpeed_mps()); } } } diff --git a/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothConnectionManager.java b/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothConnectionManager.java index df2a92e48..671ec09ad 100644 --- a/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothConnectionManager.java +++ b/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothConnectionManager.java @@ -32,6 +32,7 @@ import java.util.UUID; import de.dennisguse.opentracks.content.sensor.SensorData; import de.dennisguse.opentracks.content.sensor.SensorDataCycling; +import de.dennisguse.opentracks.content.sensor.SensorDataCyclingPower; import de.dennisguse.opentracks.content.sensor.SensorDataHeartRate; import de.dennisguse.opentracks.util.BluetoothUtils; @@ -88,8 +89,14 @@ public abstract class BluetoothConnectionManager { // Register for updates. BluetoothGattDescriptor descriptor = characteristic.getDescriptor(BluetoothUtils.CLIENT_CHARACTERISTIC_CONFIG_UUID); + if (descriptor == null) { + Log.e(TAG, "CLIENT_CHARACTERISTIC_CONFIG_UUID characteristic not available; cannot request notifications for changed data."); + return; + } + descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); gatt.writeDescriptor(descriptor); + } @Override @@ -170,7 +177,7 @@ public abstract class BluetoothConnectionManager { public static class CyclingCadence extends BluetoothConnectionManager { CyclingCadence(SensorDataObserver observer) { - super(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, BluetoothUtils.CYCLING_SPPED_CADENCE_MEASUREMENT_CHAR_UUID, observer); + super(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, BluetoothUtils.CYCLING_SPEED_CADENCE_MEASUREMENT_CHAR_UUID, observer); } @Override @@ -201,7 +208,7 @@ public abstract class BluetoothConnectionManager { public static class CyclingSpeed extends BluetoothConnectionManager { CyclingSpeed(SensorDataObserver observer) { - super(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, BluetoothUtils.CYCLING_SPPED_CADENCE_MEASUREMENT_CHAR_UUID, observer); + super(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, BluetoothUtils.CYCLING_SPEED_CADENCE_MEASUREMENT_CHAR_UUID, observer); } @Override @@ -219,6 +226,25 @@ public abstract class BluetoothConnectionManager { } } + public static class CyclingPower extends BluetoothConnectionManager { + + CyclingPower(@NonNull SensorDataObserver observer) { + super(BluetoothUtils.CYCLING_POWER_UUID, BluetoothUtils.CYCLING_POWER_MEASUREMENT_CHAR_UUID, observer); + } + + @Override + protected SensorData createEmptySensorData(String address) { + return new SensorDataCyclingPower(address); + } + + @Override + protected SensorDataCyclingPower parsePayload(String sensorName, String address, BluetoothGattCharacteristic characteristic) { + Integer cyclingPower = BluetoothUtils.parseCyclingPower(characteristic); + + return cyclingPower != null ? new SensorDataCyclingPower(address, sensorName, cyclingPower) : null; + } + } + interface SensorDataObserver { void onChanged(SensorData sensorData); diff --git a/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothRemoteSensorManager.java b/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothRemoteSensorManager.java index 46547b7b1..9caba26ff 100644 --- a/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothRemoteSensorManager.java +++ b/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothRemoteSensorManager.java @@ -59,6 +59,7 @@ public class BluetoothRemoteSensorManager implements BluetoothConnectionManager. private final BluetoothConnectionManager.HeartRate heartRate = new BluetoothConnectionManager.HeartRate(this); private final BluetoothConnectionManager.CyclingCadence cyclingCadence = new BluetoothConnectionManager.CyclingCadence(this); private final BluetoothConnectionManager.CyclingSpeed cyclingSpeed = new BluetoothConnectionManager.CyclingSpeed(this); + private final BluetoothConnectionManager.CyclingPower cyclingPower = new BluetoothConnectionManager.CyclingPower(this); private final SensorDataSet sensorDataSet = new SensorDataSet(); @@ -82,6 +83,12 @@ public class BluetoothRemoteSensorManager implements BluetoothConnectionManager. connect(cyclingSpeed, address); } + + if (PreferencesUtils.isKey(context, R.string.settings_sensor_bluetooth_cycling_power_key, key)) { + String address = PreferencesUtils.getBluetoothCyclingPowerSensorAddress(context); + + connect(cyclingPower, address); + } } }; @@ -102,6 +109,8 @@ public class BluetoothRemoteSensorManager implements BluetoothConnectionManager. public synchronized void stop() { heartRate.disconnect(); cyclingCadence.disconnect(); + cyclingSpeed.disconnect(); + cyclingPower.disconnect(); sensorDataSet.clear(); diff --git a/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java b/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java index e22e19f8a..81652c04f 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java +++ b/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java @@ -20,6 +20,7 @@ import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment; import de.dennisguse.opentracks.io.file.TrackFileFormat; import de.dennisguse.opentracks.settings.bluetooth.BluetoothLeCyclingCadenceAndSpeedPreference; +import de.dennisguse.opentracks.settings.bluetooth.BluetoothLeCyclingPowerPreference; import de.dennisguse.opentracks.settings.bluetooth.BluetoothLeHeartRatePreference; import de.dennisguse.opentracks.settings.bluetooth.BluetoothLeSensorPreference; import de.dennisguse.opentracks.util.ActivityUtils; @@ -132,6 +133,8 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit dialogFragment = BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(preference.getKey(), BluetoothUtils.HEART_RATE_SERVICE_UUID); } else if (preference instanceof BluetoothLeCyclingCadenceAndSpeedPreference) { dialogFragment = BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(preference.getKey(), BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID); + } else if (preference instanceof BluetoothLeCyclingPowerPreference) { + dialogFragment = BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(preference.getKey(), BluetoothUtils.CYCLING_POWER_UUID); } if (dialogFragment != null) { diff --git a/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingPowerPreference.java b/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingPowerPreference.java new file mode 100644 index 000000000..486136bea --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingPowerPreference.java @@ -0,0 +1,23 @@ +package de.dennisguse.opentracks.settings.bluetooth; + +import android.content.Context; +import android.util.AttributeSet; + +public class BluetoothLeCyclingPowerPreference extends BluetoothLeSensorPreference { + + public BluetoothLeCyclingPowerPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public BluetoothLeCyclingPowerPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public BluetoothLeCyclingPowerPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public BluetoothLeCyclingPowerPreference(Context context) { + super(context); + } +} \ No newline at end of file diff --git a/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java b/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java index 9c2dd5c22..37a5aab09 100644 --- a/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java +++ b/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java @@ -39,8 +39,11 @@ public class BluetoothUtils { public static final UUID HEART_RATE_SERVICE_UUID = new UUID(0x180D00001000L, 0x800000805f9b34fbL); public static final UUID HEART_RATE_MEASUREMENT_CHAR_UUID = new UUID(0x2A3700001000L, 0x800000805f9b34fbL); + public static final UUID CYCLING_POWER_UUID = new UUID(0x181800001000L, 0x800000805f9b34fbL); + public static final UUID CYCLING_POWER_MEASUREMENT_CHAR_UUID = new UUID(0x2A6300001000L, 0x800000805f9b34fbL); + public static final UUID CYCLING_SPEED_CADENCE_SERVICE_UUID = new UUID(0x181600001000L, 0x800000805f9b34fbL); - public static final UUID CYCLING_SPPED_CADENCE_MEASUREMENT_CHAR_UUID = new UUID(0x2A5B00001000L, 0x800000805f9b34fbL); + public static final UUID CYCLING_SPEED_CADENCE_MEASUREMENT_CHAR_UUID = new UUID(0x2A5B00001000L, 0x800000805f9b34fbL); private static final String TAG = BluetoothUtils.class.getSimpleName(); @@ -62,7 +65,7 @@ public class BluetoothUtils { } public static Integer parseHeartRate(BluetoothGattCharacteristic characteristic) { - //DOCUMENTATION https://www.bluetooth.com/specifications/gatt/characteristics/ + //DOCUMENTATION https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Characteristics/org.bluetooth.characteristic.heart_rate_measurement.xml byte[] raw = characteristic.getValue(); if (raw.length == 0) { return null; @@ -79,10 +82,21 @@ public class BluetoothUtils { return null; } + public static Integer parseCyclingPower(BluetoothGattCharacteristic characteristic) { + // DOCUMENTATION https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Characteristics/org.bluetooth.characteristic.cycling_power_measurement.xml + int valueLength = characteristic.getValue().length; + if (valueLength < 4) { + return null; + } + + return characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_SINT16, 2); + } + /** * Documentation: https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=261449 */ public static SensorDataCycling.CadenceAndSpeed parseCyclingCrankAndWheel(String address, String sensorName, @NonNull BluetoothGattCharacteristic characteristic) { + // DOCUMENTATION https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Characteristics/org.bluetooth.characteristic.csc_measurement.xml int valueLength = characteristic.getValue().length; if (valueLength == 0) { return null; diff --git a/src/main/java/de/dennisguse/opentracks/util/PreferencesUtils.java b/src/main/java/de/dennisguse/opentracks/util/PreferencesUtils.java index 1fa437942..9b00dcece 100644 --- a/src/main/java/de/dennisguse/opentracks/util/PreferencesUtils.java +++ b/src/main/java/de/dennisguse/opentracks/util/PreferencesUtils.java @@ -262,6 +262,15 @@ public class PreferencesUtils { return getInt(context, R.string.settings_sensor_bluetooth_cycling_speed_wheel_circumference_key, DEFAULT); } + public static boolean isBluetoothCyclingPowerSensorAddressNone(Context context) { + return isBluetoothSensorAddressNone(context, getBluetoothCyclingPowerSensorAddress(context)); + } + + public static String getBluetoothCyclingPowerSensorAddress(Context context) { + return getString(context, R.string.settings_sensor_bluetooth_cycling_power_key, getBluetoothSensorAddressNone(context)); + } + + public static boolean shouldShowStatsOnLockscreen(Context context) { final boolean STATS_SHOW_ON_LOCKSCREEN_DEFAULT = context.getResources().getBoolean(R.bool.stats_show_on_lockscreen_while_recording_default); return getBoolean(context, R.string.stats_show_on_lockscreen_while_recording_key, STATS_SHOW_ON_LOCKSCREEN_DEFAULT); diff --git a/src/main/res/layout/statistics_recording.xml b/src/main/res/layout/statistics_recording.xml index ebc79b33e..94d8b1b48 100644 --- a/src/main/res/layout/statistics_recording.xml +++ b/src/main/res/layout/statistics_recording.xml @@ -487,5 +487,47 @@ app:layout_constraintRight_toLeftOf="@+id/guideline3" app:layout_constraintTop_toBottomOf="@+id/stats_sensor_cadence_value" /> + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml index 0bdbefe06..e3403afa0 100644 --- a/src/main/res/values/colors.xml +++ b/src/main/res/values/colors.xml @@ -22,10 +22,10 @@ limitations under the License. #40E88A70 #FF0066FF #40C8E0FF - #FF401465 - #40401465 #FF0066FF #40C8E0FF + #FF7A26C0 + #407A26C0 @android:color/holo_orange_dark #55FF8800 diff --git a/src/main/res/values/do_not_translate.xml b/src/main/res/values/do_not_translate.xml index 4cd700015..e29c1cbd8 100644 --- a/src/main/res/values/do_not_translate.xml +++ b/src/main/res/values/do_not_translate.xml @@ -26,6 +26,7 @@ limitations under the License. BPM RPM + W - diff --git a/src/main/res/values/settings.xml b/src/main/res/values/settings.xml index 515154264..ad77abd22 100644 --- a/src/main/res/values/settings.xml +++ b/src/main/res/values/settings.xml @@ -21,6 +21,7 @@ bluetoothSensor bluetoothCyclingCadenceSensor bluetoothCyclingSpeedSensor + bluetoothCyclingPowerSensor NONE bluetoothCyclingSpeedWheelCircumference diff --git a/src/main/res/xml/settings.xml b/src/main/res/xml/settings.xml index 8e23c55ab..e792e4d08 100644 --- a/src/main/res/xml/settings.xml +++ b/src/main/res/xml/settings.xml @@ -131,6 +131,10 @@ limitations under the License. android:defaultValue="@string/sensor_type_value_none" android:key="@string/settings_sensor_bluetooth_cycling_cadence_key" android:title="@string/sensor_state_cadence" /> + Date: Sun, 8 Nov 2020 13:00:25 +0100 Subject: [PATCH 2/5] Adaptive recording layout for sensors (heart rate, cycling cadence and cycling power). --- .../opentracks/adapters/SensorsAdapter.java | 155 ++++++++++++++++++ .../StatisticsRecordingFragment.java | 103 +++--------- src/main/res/layout/sensor_item.xml | 41 +++++ src/main/res/layout/statistics_recording.xml | 125 +------------- 4 files changed, 226 insertions(+), 198 deletions(-) create mode 100644 src/main/java/de/dennisguse/opentracks/adapters/SensorsAdapter.java create mode 100644 src/main/res/layout/sensor_item.xml diff --git a/src/main/java/de/dennisguse/opentracks/adapters/SensorsAdapter.java b/src/main/java/de/dennisguse/opentracks/adapters/SensorsAdapter.java new file mode 100644 index 000000000..8e0c939a9 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/adapters/SensorsAdapter.java @@ -0,0 +1,155 @@ +package de.dennisguse.opentracks.adapters; + +import android.content.Context; +import android.util.Pair; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.content.sensor.SensorData; +import de.dennisguse.opentracks.content.sensor.SensorDataCycling; +import de.dennisguse.opentracks.content.sensor.SensorDataCyclingPower; +import de.dennisguse.opentracks.content.sensor.SensorDataHeartRate; +import de.dennisguse.opentracks.util.StringUtils; + +public class SensorsAdapter extends RecyclerView.Adapter { + + public static final int HEART_RATE_TYPE = 0; + public static final int CADENCE_TYPE = 1; + public static final int POWER_TYPE = 2; + + private List> sensorDataList; + private final Context context; + + public SensorsAdapter(Context context) { + this.context = context; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.sensor_item, parent, false); + return new SensorsAdapter.ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + SensorsAdapter.ViewHolder viewHolder = (SensorsAdapter.ViewHolder) holder; + int type = sensorDataList.get(position).first; + SensorData sensorData = sensorDataList.get(position).second; + viewHolder.setData(sensorData, type); + } + + @Override + public int getItemCount() { + if (sensorDataList == null) { + return 0; + } else { + return sensorDataList.size(); + } + } + + @Override + public int getItemViewType(int position) { + return sensorDataList.get(position).first; + } + + public List> swapData(List> data) { + if (sensorDataList == data) { + return null; + } + + sensorDataList = data; + + if (data != null) { + this.notifyDataSetChanged(); + } + + return data; + } + + private class ViewHolder extends RecyclerView.ViewHolder { + TextView label; + TextView sensorValue; + TextView value; + TextView unit; + + public ViewHolder(@NonNull View itemView) { + super(itemView); + label = itemView.findViewById(R.id.stats_sensor_label); + sensorValue = itemView.findViewById(R.id.stats_sensor_sensor_value); + value = itemView.findViewById(R.id.stats_sensor_value); + unit = itemView.findViewById(R.id.stats_sensor_unit); + } + + public void setData(SensorData sensorData, int type) { + switch (type) { + case HEART_RATE_TYPE: + setHeartRateSensorData((SensorDataHeartRate) sensorData); + break; + case CADENCE_TYPE: + setCadenceSensorData((SensorDataCycling.Cadence) sensorData); + break; + case POWER_TYPE: + setPowerSensorData((SensorDataCyclingPower) sensorData); + break; + default: + throw new RuntimeException("Unknown sensor type"); + } + } + + private void setHeartRateSensorData(SensorDataHeartRate data) { + String sensorValue = context.getString(R.string.value_unknown); + String sensorName = context.getString(R.string.value_unknown); + if (data != null) { + sensorName = data.getSensorNameOrAddress(); + if (data.hasHeartRate_bpm() && data.isRecent()) { + sensorValue = StringUtils.formatDecimal(data.getHeartRate_bpm(), 0); + } + } + + this.label.setText(context.getString(R.string.sensor_state_heart_rate)); + this.sensorValue.setText(sensorName); + this.value.setText(sensorValue); + } + + private void setCadenceSensorData(SensorDataCycling.Cadence data) { + String sensorValue = context.getString(R.string.value_unknown); + String sensorName = context.getString(R.string.value_unknown); + if (data != null) { + sensorName = data.getSensorNameOrAddress(); + + if (data.hasCadence_rpm() && data.isRecent()) { + sensorValue = StringUtils.formatDecimal(data.getCadence_rpm(), 0); + } + } + + this.label.setText(context.getString(R.string.sensor_state_cadence)); + this.sensorValue.setText(sensorName); + this.value.setText(sensorValue); + } + + private void setPowerSensorData(SensorDataCyclingPower data) { + String sensorValue = context.getString(R.string.value_unknown); + String sensorName = context.getString(R.string.value_unknown); + if (data != null) { + sensorName = data.getSensorNameOrAddress(); + + if (data.hasPower_w() && data.isRecent()) { + sensorValue = StringUtils.formatDecimal(data.getPower_w(), 0); + } + } + + this.label.setText(context.getString(R.string.sensor_state_power)); + this.sensorValue.setText(sensorName); + this.value.setText(sensorValue); + } + } +} diff --git a/src/main/java/de/dennisguse/opentracks/fragments/StatisticsRecordingFragment.java b/src/main/java/de/dennisguse/opentracks/fragments/StatisticsRecordingFragment.java index c801de5bc..1ec55f4e0 100644 --- a/src/main/java/de/dennisguse/opentracks/fragments/StatisticsRecordingFragment.java +++ b/src/main/java/de/dennisguse/opentracks/fragments/StatisticsRecordingFragment.java @@ -13,17 +13,22 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.List; import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.TrackRecordingActivity; +import de.dennisguse.opentracks.adapters.SensorsAdapter; import de.dennisguse.opentracks.content.TrackDataHub; import de.dennisguse.opentracks.content.TrackDataListener; import de.dennisguse.opentracks.content.data.Marker; import de.dennisguse.opentracks.content.data.Track; import de.dennisguse.opentracks.content.data.TrackPoint; +import de.dennisguse.opentracks.content.sensor.SensorData; import de.dennisguse.opentracks.content.sensor.SensorDataCycling; -import de.dennisguse.opentracks.content.sensor.SensorDataCyclingPower; -import de.dennisguse.opentracks.content.sensor.SensorDataHeartRate; import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.databinding.StatisticsRecordingBinding; import de.dennisguse.opentracks.services.TrackRecordingServiceConnection; @@ -61,6 +66,9 @@ public class StatisticsRecordingFragment extends Fragment implements TrackDataLi private StatisticsRecordingBinding viewBinding; + private SensorsAdapter sensorsAdapter; + private RecyclerView sensorsRecyclerView; + public static Fragment newInstance() { return new StatisticsRecordingFragment(); } @@ -115,6 +123,11 @@ public class StatisticsRecordingFragment extends Fragment implements TrackDataLi } return true; }); + + sensorsAdapter = new SensorsAdapter(getContext()); + sensorsRecyclerView = viewBinding.statsSensorsRecyclerView; + sensorsRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 2)); + sensorsRecyclerView.setAdapter(sensorsAdapter); } @Override @@ -272,78 +285,23 @@ public class StatisticsRecordingFragment extends Fragment implements TrackDataLi } else { SensorDataSet sensorDataSet = trackRecordingService.getSensorData(); if (sensorDataSet != null) { - setHeartRateSensorData(sensorDataSet); - setCadenceSensorData(sensorDataSet); - setPowerSensorData(sensorDataSet); + List> sensorDataList = new ArrayList<>(); + if (sensorDataSet.getHeartRate() != null) { + sensorDataList.add(new Pair<>(SensorsAdapter.HEART_RATE_TYPE, sensorDataSet.getHeartRate())); + } + if (sensorDataSet.getCyclingCadence() != null) { + sensorDataList.add(new Pair<>(SensorsAdapter.CADENCE_TYPE, sensorDataSet.getCyclingCadence())); + } + if(sensorDataSet.getCyclingPower() != null) { + sensorDataList.add(new Pair<>(SensorsAdapter.POWER_TYPE, sensorDataSet.getCyclingPower())); + } + sensorsAdapter.swapData(sensorDataList); setSpeedSensorData(sensorDataSet); } setTotalElevationGain(trackRecordingService.getElevationGain_m()); } } - private void setHeartRateSensorData(SensorDataSet sensorDataSet) { - int isVisible = sensorDataSet.getHeartRate() != null ? View.VISIBLE : View.GONE; - viewBinding.statsSensorHeartRateGroup.setVisibility(isVisible); - setVisibilitySensorHorizontalLine(); - - String sensorValue = getContext().getString(R.string.value_unknown); - String sensorName = getContext().getString(R.string.value_unknown); - if (sensorDataSet.getHeartRate() != null) { - SensorDataHeartRate data = sensorDataSet.getHeartRate(); - - sensorName = data.getSensorNameOrAddress(); - if (data.hasHeartRate_bpm() && data.isRecent()) { - sensorValue = StringUtils.formatDecimal(data.getHeartRate_bpm(), 0); - } - } - - viewBinding.statsSensorHeartRateSensorValue.setText(sensorName); - viewBinding.statsSensorHeartRateValue.setText(sensorValue); - } - - private void setCadenceSensorData(SensorDataSet sensorDataSet) { - int isVisible = sensorDataSet.getCyclingCadence() != null ? View.VISIBLE : View.GONE; - viewBinding.statsSensorCadenceGroup.setVisibility(isVisible); - setVisibilitySensorHorizontalLine(); - - String sensorValue = getContext().getString(R.string.value_unknown); - String sensorName = getContext().getString(R.string.value_unknown); - if (sensorDataSet.getCyclingCadence() != null) { - SensorDataCycling.Cadence data = sensorDataSet.getCyclingCadence(); - sensorName = data.getSensorNameOrAddress(); - - if (data.hasCadence_rpm() && data.isRecent()) { - sensorValue = StringUtils.formatDecimal(data.getCadence_rpm(), 0); - } - } - - viewBinding.statsSensorCadenceSensorValue.setText(sensorName); - viewBinding.statsSensorCadenceValue.setText(sensorValue); - } - - private void setPowerSensorData(SensorDataSet sensorDataSet) { - int isVisible = View.VISIBLE; - if (PreferencesUtils.isBluetoothCyclingPowerSensorAddressNone(getContext())) { - isVisible = View.GONE; - } - viewBinding.statsSensorPowerGroup.setVisibility(isVisible); - setVisibilitySensorHorizontalLine(); - - String sensorValue = getContext().getString(R.string.value_unknown); - String sensorName = getContext().getString(R.string.value_unknown); - if (sensorDataSet != null && sensorDataSet.getCyclingPower() != null) { - SensorDataCyclingPower data = sensorDataSet.getCyclingPower(); - sensorName = data.getSensorName(); - - if (data.hasPower_w() && data.isRecent()) { - sensorValue = StringUtils.formatDecimal(data.getPower_w(), 0); - } - } - - viewBinding.statsSensorPowerSensorValue.setText(sensorName); - viewBinding.statsSensorPowerValue.setText(sensorValue); - } - // Set elevation gain private void setTotalElevationGain(Float elevationGain_m) { //TODO Check if we can distribute the total elevation gain via trackStatistics instead of doing some computation in the UI layer. @@ -364,15 +322,6 @@ public class StatisticsRecordingFragment extends Fragment implements TrackDataLi viewBinding.statsElevationGainUnit.setText(parts.second); } - /** - * If cadence and hear rate groups are invisible then sensor horizontal line hast to be invisible too. - */ - private void setVisibilitySensorHorizontalLine() { - if (viewBinding.statsSensorCadenceGroup.getVisibility() != View.VISIBLE && viewBinding.statsSensorHeartRateGroup.getVisibility() != View.VISIBLE) { - viewBinding.statsSensorHorizontalLine.setVisibility(View.GONE); - } - } - private void setSpeedSensorData(SensorDataSet sensorDataSet) { if (sensorDataSet != null && sensorDataSet.getCyclingSpeed() != null) { SensorDataCycling.Speed data = sensorDataSet.getCyclingSpeed(); diff --git a/src/main/res/layout/sensor_item.xml b/src/main/res/layout/sensor_item.xml new file mode 100644 index 000000000..4d89022bf --- /dev/null +++ b/src/main/res/layout/sensor_item.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/layout/statistics_recording.xml b/src/main/res/layout/statistics_recording.xml index 94d8b1b48..33d7320ba 100644 --- a/src/main/res/layout/statistics_recording.xml +++ b/src/main/res/layout/statistics_recording.xml @@ -403,131 +403,14 @@ app:layout_constraintRight_toLeftOf="@+id/guideline3" app:layout_constraintTop_toBottomOf="@+id/stats_location_barrier" /> - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 55c45b20503ba2593720e7e10e582b6fe1929ed3 Mon Sep 17 00:00:00 2001 From: Dennis Guse Date: Mon, 9 Nov 2020 12:07:35 +0100 Subject: [PATCH 3/5] Bugfix: SensorDataCycling.toString() should use attributes instead of their getters. --- .../opentracks/content/sensor/SensorDataCycling.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java index 704274280..f194c29bb 100644 --- a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java +++ b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java @@ -82,7 +82,7 @@ public final class SensorDataCycling { @NonNull @Override public String toString() { - return "cadence=" + getCadence_rpm() + " time=" + getCrankRevolutionsTime() + " count=" + getCrankRevolutionsCount(); + return "cadence=" + cadence_rpm + " time=" + crankRevolutionsTime + " count=" + crankRevolutionsCount; } @Override @@ -149,7 +149,7 @@ public final class SensorDataCycling { @NonNull @Override public String toString() { - return "speed=" + getSpeed_mps() + " time=" + getWheelRevolutionsTime() + " count=" + getWheelRevolutionsCount(); + return "speed=" + speed_mps + " time=" + wheelRevolutionsTime + " count=" + wheelRevolutionsCount; } @Override From bf92851c973a7eed3672fae0a4009aa41847a719 Mon Sep 17 00:00:00 2001 From: Dennis Guse Date: Mon, 9 Nov 2020 20:18:29 +0100 Subject: [PATCH 4/5] Show sensor data's unit. --- .../java/de/dennisguse/opentracks/adapters/SensorsAdapter.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/de/dennisguse/opentracks/adapters/SensorsAdapter.java b/src/main/java/de/dennisguse/opentracks/adapters/SensorsAdapter.java index 8e0c939a9..960410c1b 100644 --- a/src/main/java/de/dennisguse/opentracks/adapters/SensorsAdapter.java +++ b/src/main/java/de/dennisguse/opentracks/adapters/SensorsAdapter.java @@ -118,6 +118,7 @@ public class SensorsAdapter extends RecyclerView.Adapter Date: Wed, 11 Nov 2020 21:25:18 +0100 Subject: [PATCH 5/5] Bugfix: cadence and speed now can compare when data is not set. --- .../content/sensor/SensorDataCyclingTest.java | 29 +++++++++++++++++++ .../content/sensor/SensorDataCycling.java | 14 +++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/androidTest/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingTest.java b/src/androidTest/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingTest.java index 169bed732..2ba8e9a7e 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingTest.java @@ -9,6 +9,7 @@ import de.dennisguse.opentracks.util.UintUtils; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; @RunWith(AndroidJUnit4.class) public class SensorDataCyclingTest { @@ -117,4 +118,32 @@ public class SensorDataCyclingTest { // then assertEquals(2, current.getSpeed_mps(), 0.01); } + + @Test + public void equals_speed_with_no_data() { + // given + SensorDataCycling.Speed previous = new SensorDataCycling.Speed("sensorAddress"); + SensorDataCycling.Speed current = new SensorDataCycling.Speed("sensorAddress", "sensorName", 0, 2048); + + // when + previous.toString(); + + // then + assertNotEquals(previous, current); + assertNotEquals(previous, previous); + } + + @Test + public void equals_cadence_with_no_data() { + // given + SensorDataCycling.Cadence previous = new SensorDataCycling.Cadence("sensorAddress"); + SensorDataCycling.Cadence current = new SensorDataCycling.Cadence("sensorAddress", "sensorName", 0, 2048); + + // when + previous.toString(); + + // then + assertNotEquals(previous, current); + assertNotEquals(previous, previous); + } } \ No newline at end of file diff --git a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java index f194c29bb..4ee0e63cc 100644 --- a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java +++ b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java @@ -42,7 +42,7 @@ public final class SensorDataCycling { * Workaround for Wahoo CADENCE: provides speed instead of cadence */ public Cadence(@NonNull SensorDataCycling.Speed speed) { - this(speed.getSensorAddress(), speed.getSensorName(), speed.getWheelRevolutionsCount(), speed.getWheelRevolutionsTime()); + this(speed.getSensorAddress(), speed.getSensorName(), speed.wheelRevolutionsCount, speed.wheelRevolutionsTime); } public boolean hasData() { @@ -90,7 +90,11 @@ public final class SensorDataCycling { if (!(obj instanceof Cadence)) return false; Cadence comp = (Cadence) obj; - return getCrankRevolutionsCount() == comp.getCrankRevolutionsCount() && getCrankRevolutionsTime() == comp.getCrankRevolutionsTime(); + if (hasData() && comp.hasData() == hasData()) { + return getCrankRevolutionsCount() == comp.getCrankRevolutionsCount() && getCrankRevolutionsTime() == comp.getCrankRevolutionsTime(); + } else { + return false; + } } } @@ -157,7 +161,11 @@ public final class SensorDataCycling { if (!(obj instanceof Speed)) return false; Speed comp = (Speed) obj; - return getWheelRevolutionsCount() == comp.getWheelRevolutionsCount() && getWheelRevolutionsTime() == comp.getWheelRevolutionsTime(); + if (hasData() && comp.hasData() == hasData()) { + return getWheelRevolutionsCount() == comp.getWheelRevolutionsCount() && getWheelRevolutionsTime() == comp.getWheelRevolutionsTime(); + } else { + return false; + } } }