diff --git a/src/androidTest/java/de/dennisguse/opentracks/TrackStubUtils.java b/src/androidTest/java/de/dennisguse/opentracks/TrackStubUtils.java index 0a6420038..e258bce30 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/TrackStubUtils.java +++ b/src/androidTest/java/de/dennisguse/opentracks/TrackStubUtils.java @@ -19,7 +19,7 @@ package de.dennisguse.opentracks; import android.location.Location; import de.dennisguse.opentracks.content.data.TrackPoint; -import de.dennisguse.opentracks.content.sensor.SensorDataSet; +import de.dennisguse.opentracks.content.data.TrackPointSensorDataSet; /** * Commons utilities for creating stubs of track, location. @@ -63,6 +63,6 @@ public class TrackStubUtils { location.setTime(INITIAL_TIME); location.setBearing(INITIAL_BEARING); - return new TrackPoint(location, new SensorDataSet(Float.NaN, Float.NaN)); + return new TrackPoint(location, new TrackPointSensorDataSet()); } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingTest.java b/src/androidTest/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingTest.java new file mode 100644 index 000000000..b80b990a7 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/content/sensor/SensorDataCyclingTest.java @@ -0,0 +1,79 @@ +package de.dennisguse.opentracks.content.sensor; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + +import de.dennisguse.opentracks.util.UintUtils; + +@RunWith(AndroidJUnit4.class) +public class SensorDataCyclingTest { + + @Test + public void compute_cadence() { + // given + SensorDataCycling.Cadence previous = new SensorDataCycling.Cadence(1, 1024); // 1s + SensorDataCycling.Cadence current = new SensorDataCycling.Cadence(2, 2048); // 2s + + // when + current.compute(previous); + + // then + Assert.assertEquals(57.2519, current.getCadence_rpm(), 0.01); + } + + @Test + public void compute_cadence_sameCount() { + // given + SensorDataCycling.Cadence previous = new SensorDataCycling.Cadence(1, 1024); + SensorDataCycling.Cadence current = new SensorDataCycling.Cadence(1, 2048); + + // when + current.compute(previous); + + // then + Assert.assertEquals(0, current.getCadence_rpm(), 0.01); + } + + + @Test + public void compute_cadence_sameTime() { + // given + SensorDataCycling.Cadence previous = new SensorDataCycling.Cadence(1, 1024); + SensorDataCycling.Cadence current = new SensorDataCycling.Cadence(2, 1024); + + // when + current.compute(previous); + + // then + Assert.assertFalse(current.hasCadence_rpm()); + } + + @Test + public void compute_cadence_rollOverTime() { + // given + SensorDataCycling.Cadence previous = new SensorDataCycling.Cadence(1, UintUtils.UINT16_MAX - 1024); + SensorDataCycling.Cadence current = new SensorDataCycling.Cadence(2, 0); + + // when + current.compute(previous); + + // then + Assert.assertEquals(57.2519, current.getCadence_rpm(), 0.01); + } + + @Test + public void compute_cadence_rollOverCount() { + // given + SensorDataCycling.Cadence previous = new SensorDataCycling.Cadence(UintUtils.UINT32_MAX - 1, 1024); + SensorDataCycling.Cadence current = new SensorDataCycling.Cadence(0, 2048); + + // when + current.compute(previous); + + // then + Assert.assertEquals(57.2519, current.getCadence_rpm(), 0.01); + } +} \ No newline at end of file diff --git a/src/androidTest/java/de/dennisguse/opentracks/fragments/ChartFragmentTest.java b/src/androidTest/java/de/dennisguse/opentracks/fragments/ChartFragmentTest.java index 22c45f79e..acddf2688 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/fragments/ChartFragmentTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/fragments/ChartFragmentTest.java @@ -33,7 +33,7 @@ import de.dennisguse.opentracks.TrackStubUtils; import de.dennisguse.opentracks.chart.ChartPoint; import de.dennisguse.opentracks.chart.ChartView; import de.dennisguse.opentracks.content.data.TrackPoint; -import de.dennisguse.opentracks.content.sensor.SensorDataSet; +import de.dennisguse.opentracks.content.data.TrackPointSensorDataSet; import de.dennisguse.opentracks.util.UnitConversions; /** @@ -82,7 +82,7 @@ public class ChartFragmentTest { // Input incorrect state. // Creates SensorData. - SensorDataSet sensorDataSet = new SensorDataSet(SensorDataSet.DATA_UNAVAILABLE, SensorDataSet.DATA_UNAVAILABLE); + TrackPointSensorDataSet sensorDataSet = new TrackPointSensorDataSet(); trackPoint.setSensorDataSet(sensorDataSet); // Test. point = chartFragment.createPendingPoint(trackPoint); @@ -104,7 +104,10 @@ public class ChartFragmentTest { Assert.assertEquals(Float.NaN, point.getPower(), 0.01); // Creates SensorData. - SensorDataSet sensorDataSet = new SensorDataSet(100, 101, 102); + TrackPointSensorDataSet sensorDataSet = new TrackPointSensorDataSet(); + sensorDataSet.setHeartRate_bpm(100f); + sensorDataSet.setCyclingCadence(101); + sensorDataSet.setPower(102f); // Creates SensorDataSet. trackPoint.setSensorDataSet(sensorDataSet); diff --git a/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceTestLocation.java b/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceTestLocation.java index eaeda2d6c..81a64c416 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceTestLocation.java +++ b/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceTestLocation.java @@ -244,13 +244,8 @@ public class TrackRecordingServiceTestLocation { } @Override - public boolean isSensorDataSetValid() { - return true; - } - - @Override - public SensorDataSet getSensorDataSet() { - return new SensorDataSet(1, 2); + public SensorDataSet getSensorData() { + return new SensorDataSet(); } }); diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/BluetoothUtilsTest.java b/src/androidTest/java/de/dennisguse/opentracks/util/BluetoothUtilsTest.java new file mode 100644 index 000000000..2ff661487 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/util/BluetoothUtilsTest.java @@ -0,0 +1,76 @@ +package de.dennisguse.opentracks.util; + +import android.bluetooth.BluetoothGattCharacteristic; + +import org.junit.Assert; +import org.junit.Test; + +import de.dennisguse.opentracks.content.sensor.SensorDataCycling; + +public class BluetoothUtilsTest { + + @Test + public void parseHeartRate_uint8() { + // given + BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothUtils.HEART_RATE_SERVICE_UUID, 0, 0); + characteristic.setValue(new byte[]{0x02, 0x3C}); + + // when + int heartRate = BluetoothUtils.parseHeartRate(characteristic); + + // then + Assert.assertEquals(60, heartRate); + } + + @Test + public void parseHeartRate_uint16() { + // given + BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothUtils.HEART_RATE_SERVICE_UUID, 0, 0); + characteristic.setValue(new byte[]{0x01, 0x01, 0x01}); + + // when + int heartRate = BluetoothUtils.parseHeartRate(characteristic); + + // then + Assert.assertEquals(257, heartRate); + } + + @Test + public void parseCyclingSpeedCadence_crankOnly() { + BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, 0, 0); + characteristic.setValue(new byte[]{0x01, (byte) 0xC8, 0x00, 0x00, 0x00, 0x06, (byte) 0x99}); + + // when + SensorDataCycling.CadenceAndSpeed sensor = BluetoothUtils.parseCyclingCrankAndWheel("address", "sensorName", characteristic); + + // then + Assert.assertEquals(200, sensor.getCadence().getCrankRevolutionsCount()); + Assert.assertNull(sensor.getSpeed()); + } + + @Test + public void parseCyclingSpeedCadence_wheelOnly() { + BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, 0, 0); + characteristic.setValue(new byte[]{0x02, (byte) 0xE1, 0x00, 0x45, (byte) 0x99}); + + // when + SensorDataCycling.CadenceAndSpeed sensor = BluetoothUtils.parseCyclingCrankAndWheel("address", "sensorName", characteristic); + + // then + Assert.assertNull(sensor.getCadence()); + Assert.assertEquals(225, sensor.getSpeed().getWheelRevolutionsCount()); + } + + @Test + public void parseCyclingSpeedCadence_crankWheel() { + BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, 0, 0); + characteristic.setValue(new byte[]{0x03, (byte) 0xC8, 0x00, 0x00, 0x00, 0x06, (byte) 0x99, (byte) 0xE1, 0x00, 0x45, (byte) 0x99}); + + // when + SensorDataCycling.CadenceAndSpeed sensor = BluetoothUtils.parseCyclingCrankAndWheel("address", "sensorName", characteristic); + + // then + Assert.assertEquals(200, sensor.getCadence().getCrankRevolutionsCount()); + Assert.assertEquals(225, sensor.getSpeed().getWheelRevolutionsCount()); + } +} \ No newline at end of file diff --git a/src/main/java/de/dennisguse/opentracks/chart/ChartPoint.java b/src/main/java/de/dennisguse/opentracks/chart/ChartPoint.java index 940769378..262ad6231 100644 --- a/src/main/java/de/dennisguse/opentracks/chart/ChartPoint.java +++ b/src/main/java/de/dennisguse/opentracks/chart/ChartPoint.java @@ -3,7 +3,7 @@ package de.dennisguse.opentracks.chart; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; -import de.dennisguse.opentracks.content.sensor.SensorDataSet; +import de.dennisguse.opentracks.content.data.TrackPointSensorDataSet; import de.dennisguse.opentracks.stats.TrackStatistics; import de.dennisguse.opentracks.stats.TrackStatisticsUpdater; import de.dennisguse.opentracks.util.UnitConversions; @@ -25,7 +25,7 @@ public class ChartPoint { this.elevation = elevation; } - public ChartPoint(@NonNull TrackStatisticsUpdater trackStatisticsUpdater, SensorDataSet sensorDataSet, boolean chartByDistance, boolean metricUnits) { + public ChartPoint(@NonNull TrackStatisticsUpdater trackStatisticsUpdater, TrackPointSensorDataSet sensorDataSet, boolean chartByDistance, boolean metricUnits) { TrackStatistics trackStatistics = trackStatisticsUpdater.getTrackStatistics(); if (chartByDistance) { @@ -50,10 +50,10 @@ public class ChartPoint { pace = speed == 0 ? 0.0 : 60.0 / speed; if (sensorDataSet != null) { if (sensorDataSet.hasHeartRate()) { - heartRate = sensorDataSet.getHeartRate(); + heartRate = sensorDataSet.getHeartRate_bpm(); } - if (sensorDataSet.hasCadence()) { - cadence = sensorDataSet.getCadence(); + if (sensorDataSet.hasCyclingCadence()) { + cadence = sensorDataSet.getCyclingCadence_rpm(); } if (sensorDataSet.hasPower()) { power = sensorDataSet.getPower(); diff --git a/src/main/java/de/dennisguse/opentracks/content/data/TrackPoint.java b/src/main/java/de/dennisguse/opentracks/content/data/TrackPoint.java index a3f230bb8..992260471 100644 --- a/src/main/java/de/dennisguse/opentracks/content/data/TrackPoint.java +++ b/src/main/java/de/dennisguse/opentracks/content/data/TrackPoint.java @@ -20,8 +20,6 @@ import android.location.LocationManager; import androidx.annotation.NonNull; -import de.dennisguse.opentracks.content.sensor.SensorDataSet; - /** * This class extends the standard Android location with extra information. * @@ -39,17 +37,17 @@ public class TrackPoint { this.location = location; } - public TrackPoint(@NonNull Location location, SensorDataSet sensorDataSet) { + public TrackPoint(@NonNull Location location, TrackPointSensorDataSet sensorDataSet) { this.location = location; this.sensorDataSet = sensorDataSet; } - public TrackPoint(@NonNull TrackPoint trackPoint, SensorDataSet sensorDataSet) { + public TrackPoint(@NonNull TrackPoint trackPoint, TrackPointSensorDataSet sensorDataSet) { this.location = trackPoint.getLocation(); this.sensorDataSet = sensorDataSet; } - private SensorDataSet sensorDataSet = null; + private TrackPointSensorDataSet sensorDataSet = null; public TrackPoint(double latitude, double longitude, Double altitude, long time) { location = new Location(LocationManager.GPS_PROVIDER); @@ -90,11 +88,11 @@ public class TrackPoint { return location; } - public SensorDataSet getSensorDataSet() { + public TrackPointSensorDataSet getSensorDataSet() { return sensorDataSet; } - public void setSensorDataSet(SensorDataSet sensorDataSet) { + public void setSensorDataSet(TrackPointSensorDataSet sensorDataSet) { this.sensorDataSet = sensorDataSet; } diff --git a/src/main/java/de/dennisguse/opentracks/content/data/TrackPointSensorDataSet.java b/src/main/java/de/dennisguse/opentracks/content/data/TrackPointSensorDataSet.java new file mode 100644 index 000000000..e16118b9d --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/content/data/TrackPointSensorDataSet.java @@ -0,0 +1,99 @@ +package de.dennisguse.opentracks.content.data; + +import androidx.annotation.NonNull; + +//TODO Rename. +public final class TrackPointSensorDataSet { + private long time_ms; + + private Float heartRate_bpm = null; + private Float cyclingCadence = null; + private Float cyclingSpeed = null; + private Float power = null; + + public TrackPointSensorDataSet() { + this(System.currentTimeMillis()); + } + + public TrackPointSensorDataSet(long time_ms) { + this.time_ms = time_ms; + } + + public boolean hasHeartRate() { + return heartRate_bpm != null && heartRate_bpm > 0; + } + + public float getHeartRate_bpm() { + return heartRate_bpm; + } + + public void setHeartRate_bpm(Float heartRate_bpm) { + this.heartRate_bpm = heartRate_bpm; + } + + public boolean hasCyclingCadence() { + return cyclingCadence != null; + } + + public Float getCyclingCadence_rpm() { + return cyclingCadence; + } + + public void setCyclingCadence(Float cyclingCadence) { + this.cyclingCadence = cyclingCadence; + } + + public void setCyclingCadence(Integer cyclingCadence) { + this.cyclingCadence = cyclingCadence != null ? cyclingCadence.floatValue() : null; + } + + public boolean hasCyclingSpeed() { + return cyclingSpeed != null; + } + + public Float getCyclingSpeed() { + return cyclingSpeed; + } + + public void setCyclingSpeed(Float cyclingSpeed) { + this.cyclingSpeed = cyclingSpeed; + } + + public boolean hasPower() { + return power != null; + } + + public Float getPower() { + return power; + } + + public void setPower(Float power) { + this.power = power; + } + + public long getTime() { + return time_ms; + } + + public void setTime(long time_ms) { + this.time_ms = time_ms; + } + + /** + * Is the data recent considering the current time. + * + * @param maxAge the maximal age in milliseconds. + */ + public boolean isRecent(long maxAge) { + return time_ms + maxAge > System.currentTimeMillis(); + } + + @NonNull + @Override + public String toString() { + return "time=" + getTime() + + (hasHeartRate() ? " heart=" + getHeartRate_bpm() : "") + + (hasCyclingCadence() ? " cyclingCad=" + getCyclingCadence_rpm() : "") + + (hasCyclingSpeed() ? " cyclingSpeed=" + getCyclingSpeed() : ""); + } +} diff --git a/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java b/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java index 409b3cab2..b429be852 100644 --- a/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java +++ b/src/main/java/de/dennisguse/opentracks/content/provider/ContentProviderUtils.java @@ -36,11 +36,11 @@ import de.dennisguse.opentracks.android.ContentResolverWrapper; import de.dennisguse.opentracks.android.IContentResolver; import de.dennisguse.opentracks.content.data.Track; import de.dennisguse.opentracks.content.data.TrackPoint; +import de.dennisguse.opentracks.content.data.TrackPointSensorDataSet; import de.dennisguse.opentracks.content.data.TrackPointsColumns; import de.dennisguse.opentracks.content.data.TracksColumns; import de.dennisguse.opentracks.content.data.Waypoint; import de.dennisguse.opentracks.content.data.WaypointsColumns; -import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.stats.TrackStatistics; import de.dennisguse.opentracks.util.FileUtils; @@ -621,12 +621,19 @@ public class ContentProviderUtils { trackPoint.setBearing(cursor.getFloat(indexes.bearingIndex)); } - float heartRate = cursor.isNull(indexes.sensorHeartRateIndex) ? SensorDataSet.DATA_UNAVAILABLE : cursor.getFloat(indexes.sensorHeartRateIndex); - float cadence = cursor.isNull(indexes.sensorCadenceIndex) ? SensorDataSet.DATA_UNAVAILABLE : cursor.getFloat(indexes.sensorCadenceIndex); - float power = cursor.isNull(indexes.sensorPowerIndex) ? SensorDataSet.DATA_UNAVAILABLE : cursor.getFloat(indexes.sensorPowerIndex); + TrackPointSensorDataSet sensorDataSet = new TrackPointSensorDataSet(); - trackPoint.setSensorDataSet(new SensorDataSet(heartRate, cadence, power, SensorDataSet.DATA_UNAVAILABLE, trackPoint.getTime())); + if (cursor.isNull(indexes.sensorHeartRateIndex)) { + sensorDataSet.setHeartRate_bpm(cursor.getFloat(indexes.sensorHeartRateIndex)); + } + if (cursor.isNull(indexes.sensorCadenceIndex)) { + sensorDataSet.setCyclingCadence(cursor.getFloat(indexes.sensorCadenceIndex)); + } + if (cursor.isNull(indexes.sensorPowerIndex)) { + sensorDataSet.setCyclingSpeed(cursor.getFloat(indexes.sensorPowerIndex)); + } + trackPoint.setSensorDataSet(sensorDataSet); return trackPoint; } @@ -806,15 +813,15 @@ public class ContentProviderUtils { } //SensorData - SensorDataSet sensorDataSet = trackPoint.getSensorDataSet(); + TrackPointSensorDataSet sensorDataSet = trackPoint.getSensorDataSet(); if (sensorDataSet != null && sensorDataSet.hasHeartRate()) { - values.put(TrackPointsColumns.SENSOR_HEARTRATE, trackPoint.getSensorDataSet().getHeartRate()); + values.put(TrackPointsColumns.SENSOR_HEARTRATE, sensorDataSet.getHeartRate_bpm()); } - if (sensorDataSet != null && sensorDataSet.hasCadence()) { - values.put(TrackPointsColumns.SENSOR_CADENCE, trackPoint.getSensorDataSet().getCadence()); + if (sensorDataSet != null && sensorDataSet.hasCyclingCadence()) { + values.put(TrackPointsColumns.SENSOR_CADENCE, sensorDataSet.getCyclingCadence_rpm()); } if (sensorDataSet != null && sensorDataSet.hasPower()) { - values.put(TrackPointsColumns.SENSOR_POWER, trackPoint.getSensorDataSet().getPower()); + values.put(TrackPointsColumns.SENSOR_POWER, sensorDataSet.getPower()); } return values; } diff --git a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorData.java b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorData.java new file mode 100644 index 000000000..b073df96e --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorData.java @@ -0,0 +1,39 @@ +package de.dennisguse.opentracks.content.sensor; + +import androidx.annotation.VisibleForTesting; + +import de.dennisguse.opentracks.services.sensors.BluetoothRemoteSensorManager; + +public class SensorData { + + private String sensorAddress; + private String sensorName; + + private long timestamp_ms; + + SensorData(String sensorAddress, String sensorName) { + this(sensorAddress, sensorName, System.currentTimeMillis()); + } + + @VisibleForTesting + SensorData(String sensorAddress, String sensorName, long timestamp_ms) { + this.sensorAddress = sensorAddress; + this.sensorName = sensorName; + this.timestamp_ms = timestamp_ms; + } + + public String getSensorAddress() { + return sensorAddress; + } + + public String getSensorName() { + return sensorName; + } + + /** + * Is the data recent considering the current time. + */ + public boolean isRecent() { + return timestamp_ms + BluetoothRemoteSensorManager.MAX_SENSOR_DATE_SET_AGE_MS > System.currentTimeMillis(); + } +} diff --git a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java new file mode 100644 index 000000000..74dd70dd9 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataCycling.java @@ -0,0 +1,184 @@ +package de.dennisguse.opentracks.content.sensor; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import de.dennisguse.opentracks.util.UintUtils; +import de.dennisguse.opentracks.util.UnitConversions; + +/** + * Provides cadence in rpm and speed in milliseconds from Bluetooth LE Cycling Cadence and Speed sensors. + *

+ * https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=261449 + */ +public final class SensorDataCycling { + + private static final String TAG = SensorDataCycling.class.getSimpleName(); + + private static final int INVALID_VALUE_INT = -1; + private static final float INVALID_VALUE_FLOAT = Float.NaN; + + private SensorDataCycling() { + } + + public static class Cadence extends SensorData { + + private long crankRevolutionsCount; // UINT32 + private int crankRevolutionsTime; // UINT16; 1/1024s + private float cadence_rpm = INVALID_VALUE_FLOAT; + + public Cadence(String sensorAddress, String sensorName, long crankRevolutionsCount, int crankRevolutionsTime) { + super(sensorAddress, sensorName); + this.crankRevolutionsCount = crankRevolutionsCount; + this.crankRevolutionsTime = crankRevolutionsTime; + } + + /** + * Workaround for Wahoo CADENCE: provides speed instead of cadence + */ + public Cadence(@NonNull SensorDataCycling.Speed speed) { + this(speed.getSensorAddress(), speed.getSensorName(), speed.getWheelRevolutionsCount(), speed.getWheelRevolutionsTime()); + } + + @VisibleForTesting + public Cadence(long crankRevolutionsCount, int crankRevolutionsTime) { + super("sensorAddress", "sensorName"); + this.crankRevolutionsCount = crankRevolutionsCount; + this.crankRevolutionsTime = crankRevolutionsTime; + } + + public boolean hasData() { + return crankRevolutionsCount != INVALID_VALUE_INT && crankRevolutionsTime != INVALID_VALUE_INT; + } + + public long getCrankRevolutionsCount() { + return crankRevolutionsCount; + } + + public int getCrankRevolutionsTime() { + return crankRevolutionsTime; + } + + public boolean hasCadence_rpm() { + return !Float.isNaN(cadence_rpm); + } + + public float getCadence_rpm() { + return cadence_rpm; + } + + public void compute(Cadence previous) { + if (hasData() && previous != null && previous.hasData()) { + Log.e(TAG, previous.getCrankRevolutionsTime() + " " + previous.getCrankRevolutionsCount() + " - " + this.getCrankRevolutionsTime() + " " + getCrankRevolutionsCount()); //TODO REMOVE + long timeDiff_ms = UintUtils.diff(crankRevolutionsTime, previous.crankRevolutionsTime, UintUtils.UINT16_MAX) * 1024 / UnitConversions.S_TO_MS; + if (timeDiff_ms <= 0) { + Log.e(TAG, "Timestamps difference is invalid: cannot compute cadence."); + cadence_rpm = INVALID_VALUE_FLOAT; + } else { + long crankDiff = UintUtils.diff(crankRevolutionsCount, previous.crankRevolutionsCount, UintUtils.UINT32_MAX); + float cadence_ms = crankDiff / (float) timeDiff_ms; + cadence_rpm = (float) (cadence_ms / UnitConversions.MS_TO_S / UnitConversions.S_TO_MIN); + } + } + } + + @NonNull + @Override + public String toString() { + return "cadence=" + getCadence_rpm() + "_" + getCrankRevolutionsTime(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof Cadence)) return false; + + Cadence comp = (Cadence) obj; + return getCrankRevolutionsCount() == comp.getCrankRevolutionsCount() && getCrankRevolutionsTime() == comp.getCrankRevolutionsTime(); + } + } + + //TODO Speed computation; needs wheel diameter / circumference + public static class Speed extends SensorData { + + private int wheelRevolutionsCount; // UINT16 + private int wheelRevolutionsTime; // UINT16; 1/1024s + private float speed_ms = INVALID_VALUE_FLOAT; + + public Speed(String sensorAddress, String sensorName, int wheelRevolutionsCount, int wheelRevolutionsTime) { + super(sensorAddress, sensorName); + this.wheelRevolutionsCount = wheelRevolutionsCount; + this.wheelRevolutionsTime = wheelRevolutionsTime; + } + + public boolean hasData() { + return wheelRevolutionsCount != INVALID_VALUE_INT && wheelRevolutionsTime != INVALID_VALUE_INT; + } + + public int getWheelRevolutionsCount() { + return wheelRevolutionsCount; + } + + public int getWheelRevolutionsTime() { + return wheelRevolutionsTime; + } + + public boolean hasSpeed() { + return !Float.isNaN(speed_ms); + } + + public float getSpeed_ms() { + return speed_ms; + } + + public void compute(Speed previous) { + if (hasData() && previous != null && previous.hasData()) { + long timeDiff_ms = UintUtils.diff(wheelRevolutionsTime, previous.wheelRevolutionsTime, UintUtils.UINT16_MAX) * 1024 / UnitConversions.S_TO_MS; + if (timeDiff_ms <= 0) { + Log.e(TAG, "Timestamps difference is invalid: cannot compute cadence."); + speed_ms = INVALID_VALUE_FLOAT; + } else { + long crankDiff = UintUtils.diff(wheelRevolutionsCount, previous.wheelRevolutionsCount, UintUtils.UINT32_MAX); + speed_ms = crankDiff / (float) timeDiff_ms; + } + } + } + + @NonNull + @Override + public String toString() { + return "speed=" + getSpeed_ms() + "_" + getWheelRevolutionsTime(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof Speed)) return false; + + Speed comp = (Speed) obj; + return getWheelRevolutionsCount() == comp.getWheelRevolutionsCount() && getWheelRevolutionsTime() == comp.getWheelRevolutionsTime(); + } + } + + public static class CadenceAndSpeed extends SensorData { + + private Cadence cadence; + private Speed speed; + + public CadenceAndSpeed(String sensorAddress, String sensorName, @NonNull Cadence cadence, @NonNull Speed speed) { + super(sensorAddress, sensorName); + this.cadence = cadence; + this.speed = speed; + } + + public Cadence getCadence() { + return cadence; + } + + public Speed getSpeed() { + return speed; + } + } +} + diff --git a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataHeartRate.java b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataHeartRate.java new file mode 100644 index 000000000..c42c13f39 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataHeartRate.java @@ -0,0 +1,27 @@ +package de.dennisguse.opentracks.content.sensor; + +import androidx.annotation.NonNull; + +public class SensorDataHeartRate extends SensorData { + + private float heartRate_bpm; + + public SensorDataHeartRate(String name, String address, float heartRate_bpm) { + super(name, address); + this.heartRate_bpm = heartRate_bpm; + } + + public boolean hasHeartRate_bpm() { + return !Float.isNaN(heartRate_bpm); + } + + public float getHeartRate_bpm() { + return heartRate_bpm; + } + + @NonNull + @Override + public String toString() { + return "heart=" + heartRate_bpm; + } +} 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 60b1677ca..2ff83b011 100644 --- a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataSet.java +++ b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorDataSet.java @@ -1,102 +1,97 @@ package de.dennisguse.opentracks.content.sensor; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import de.dennisguse.opentracks.content.data.TrackPointSensorDataSet; + +/** + * + */ public final class SensorDataSet { - public static final float DATA_UNAVAILABLE = Float.NaN; + private SensorDataHeartRate heartRate; - //TODO It might be necessary to consider: if sensor was connected as well. - private String sensorName; - private String sensorAddress; - private float heartRate; - private float cadence; - private float power; - private float batteryLevel; - private long time; + private SensorDataCycling.Cadence cyclingCadence; - public SensorDataSet(float heartRate, float cadence, float power, float batteryLevel, long time) { + private SensorDataCycling.Speed cyclingSpeed; + + public SensorDataSet() { + } + + @VisibleForTesting + public SensorDataSet(SensorDataHeartRate heartRate, SensorDataCycling.Cadence cyclingCadence, SensorDataCycling.Speed cyclingSpeed) { this.heartRate = heartRate; - this.cadence = cadence; - this.power = power; - this.batteryLevel = batteryLevel; - this.time = time; + this.cyclingCadence = cyclingCadence; + this.cyclingSpeed = cyclingSpeed; } - public SensorDataSet(float heartRate, float cadence, float power, float batteryLevel) { - this(heartRate, cadence, power, batteryLevel, System.currentTimeMillis()); - } - - public SensorDataSet(float heartRate, float cadence, float power) { - this(heartRate, cadence, power, DATA_UNAVAILABLE, System.currentTimeMillis()); - } - - public SensorDataSet(float heartRate, float cadence) { - this(heartRate, cadence, DATA_UNAVAILABLE, DATA_UNAVAILABLE, System.currentTimeMillis()); - } - - public SensorDataSet(float heartRate, String sensorName, String sensorAddress) { - this(heartRate, DATA_UNAVAILABLE, DATA_UNAVAILABLE, DATA_UNAVAILABLE, System.currentTimeMillis()); - this.sensorName = sensorName; - this.sensorAddress = sensorAddress; - } - - public boolean hasHeartRate() { - return !Float.isNaN(heartRate) && heartRate > 0; - } - - public float getHeartRate() { + public SensorDataHeartRate getHeartRate() { return heartRate; } - public boolean hasCadence() { - return !Float.isNaN(cadence); + public SensorDataCycling.Cadence getCyclingCadence() { + return cyclingCadence; } - public float getCadence() { - return cadence; + public SensorDataCycling.Speed getCyclingSpeed() { + return cyclingSpeed; } - public boolean hasPower() { - return !Float.isNaN(power); + public void set(SensorData data) { + if (data == null) { + return; + } + + if (data instanceof SensorDataHeartRate) { + this.heartRate = (SensorDataHeartRate) data; + return; + } + + if (data instanceof SensorDataCycling.Cadence) { + this.cyclingCadence = (SensorDataCycling.Cadence) data; + return; + } + if (data instanceof SensorDataCycling.Speed) { + this.cyclingSpeed = (SensorDataCycling.Speed) data; + return; + } + if (data instanceof SensorDataCycling.CadenceAndSpeed) { + set(((SensorDataCycling.CadenceAndSpeed) data).getCadence()); + set(((SensorDataCycling.CadenceAndSpeed) data).getSpeed()); + } + + throw new UnsupportedOperationException(); } - public float getPower() { - return power; + public void clear() { + this.heartRate = null; + this.cyclingCadence = null; + this.cyclingSpeed = null; } - public long getTime() { - return time; - } + public TrackPointSensorDataSet createTrackPointSensorDataSet() { + TrackPointSensorDataSet sensorDataSet = new TrackPointSensorDataSet(); + if (heartRate != null) { + sensorDataSet.setHeartRate_bpm(heartRate.getHeartRate_bpm()); + } - /** - * Is the data recent considering the current time. - * - * @param maxAge the maximal age in milliseconds. - */ - public boolean isRecent(long maxAge) { - return time + maxAge > System.currentTimeMillis(); - } + if (cyclingCadence != null && cyclingCadence.hasCadence_rpm()) { + sensorDataSet.setCyclingCadence(cyclingCadence.getCadence_rpm()); + } - public boolean hasBatteryLevel() { - return !Float.isNaN(batteryLevel); - } + if (cyclingSpeed != null && cyclingSpeed.hasSpeed()) { + sensorDataSet.setCyclingCadence(cyclingSpeed.getSpeed_ms()); + } - public float getBatteryLevel() { - return batteryLevel; - } - - public String getSensorName() { - return sensorName; - } - - public String getSensorAddress() { - return sensorAddress; + return sensorDataSet; } @NonNull @Override public String toString() { - return "time=" + getTime() + " sensor=" + getSensorAddress() + " heart=" + getHeartRate(); + return (getHeartRate() != null ? "" + getHeartRate() : "") + + (getCyclingCadence() != null ? " " + getCyclingCadence() : "") + + (getCyclingSpeed() != null ? " " + getCyclingSpeed() : ""); } } diff --git a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorState.java b/src/main/java/de/dennisguse/opentracks/content/sensor/SensorState.java deleted file mode 100644 index 25da972e1..000000000 --- a/src/main/java/de/dennisguse/opentracks/content/sensor/SensorState.java +++ /dev/null @@ -1,29 +0,0 @@ -package de.dennisguse.opentracks.content.sensor; - -import android.content.Context; - -import de.dennisguse.opentracks.R; - -public enum SensorState { - NONE, - CONNECTING, - CONNECTED, - DISCONNECTING, - DISCONNECTED; - - public static String getStateAsString(SensorState state, Context c) { - switch (state) { - case NONE: - return c.getString(R.string.value_none); - case CONNECTING: - return c.getString(R.string.sensor_state_connecting); - case CONNECTED: - return c.getString(R.string.sensor_state_connected); - case DISCONNECTING: - case DISCONNECTED: - return c.getString(R.string.sensor_state_disconnected); - default: - return ""; - } - } -} diff --git a/src/main/java/de/dennisguse/opentracks/fragments/StatsFragment.java b/src/main/java/de/dennisguse/opentracks/fragments/StatsFragment.java index af45112d8..6696bd4c5 100644 --- a/src/main/java/de/dennisguse/opentracks/fragments/StatsFragment.java +++ b/src/main/java/de/dennisguse/opentracks/fragments/StatsFragment.java @@ -39,11 +39,11 @@ import de.dennisguse.opentracks.content.TrackDataHub; import de.dennisguse.opentracks.content.TrackDataListener; import de.dennisguse.opentracks.content.data.Track; import de.dennisguse.opentracks.content.data.TrackPoint; +import de.dennisguse.opentracks.content.data.TrackPointSensorDataSet; import de.dennisguse.opentracks.content.data.Waypoint; import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.services.TrackRecordingServiceConnection; import de.dennisguse.opentracks.services.TrackRecordingServiceInterface; -import de.dennisguse.opentracks.services.sensors.BluetoothRemoteSensorManager; import de.dennisguse.opentracks.stats.TrackStatistics; import de.dennisguse.opentracks.util.LocationUtils; import de.dennisguse.opentracks.util.PreferencesUtils; @@ -114,9 +114,12 @@ public class StatsFragment extends Fragment implements TrackDataListener { } /* Views */ - private View sensorContainerView; + private View heartRateContainer; private TextView heartRateValueView; private TextView heartRateSensorView; + private View cadenceContainer; + private TextView cadenceValueView; + private TextView cadenceSensorView; private TextView totalTimeValueView; private final Runnable updateUIeachSecond = new Runnable() { @@ -162,10 +165,14 @@ public class StatsFragment extends Fragment implements TrackDataListener { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - sensorContainerView = view.findViewById(R.id.stats_sensor_container); + heartRateContainer = view.findViewById(R.id.stats_sensor_heart_rate_container); heartRateValueView = view.findViewById(R.id.stats_sensor_heart_rate_value); heartRateSensorView = view.findViewById(R.id.stats_sensor_heart_rate_sensor_value); + cadenceContainer = view.findViewById(R.id.stats_sensor_cadence_container); + cadenceValueView = view.findViewById(R.id.stats_sensor_cadence_value); + cadenceSensorView = view.findViewById(R.id.stats_sensor_cadence_sensor_value); + totalTimeValueView = view.findViewById(R.id.stats_total_time_value); distanceValue = view.findViewById(R.id.stats_distance_value); @@ -269,9 +276,12 @@ public class StatsFragment extends Fragment implements TrackDataListener { @Override public void onDestroyView() { super.onDestroyView(); - sensorContainerView = null; + heartRateContainer = null; heartRateValueView = null; heartRateSensorView = null; + cadenceContainer = null; + cadenceValueView = null; + cadenceSensorView = null; totalTimeValueView = null; @@ -426,7 +436,7 @@ public class StatsFragment extends Fragment implements TrackDataListener { } /** - * Tries to fetch most recent {@link SensorDataSet} from {@link de.dennisguse.opentracks.services.TrackRecordingService}. + * Tries to fetch most recent {@link TrackPointSensorDataSet} from {@link de.dennisguse.opentracks.services.TrackRecordingService}. */ private void updateSensorDataUI() { TrackRecordingServiceInterface trackRecordingService = trackRecordingServiceConnection.getServiceIfBound(); @@ -435,33 +445,56 @@ public class StatsFragment extends Fragment implements TrackDataListener { if (trackRecordingService == null) { Log.d(STATS_FRAGMENT_TAG, "Cannot get the track recording service."); } else { - //TODO sensorState = trackRecordingService.getSensorState(); sensorDataSet = trackRecordingService.getSensorData(); } setHeartRateSensorData(sensorDataSet, isSelectedTrackRecording()); + setCadenceSensorData(sensorDataSet, isSelectedTrackRecording()); } private void setHeartRateSensorData(SensorDataSet sensorDataSet, boolean isRecording) { // heart rate int isVisible = View.VISIBLE; - if (!isRecording || PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(getContext())) { + if (!isRecording || PreferencesUtils.isBluetoothHeartRateSensorAddressNone(getContext())) { isVisible = View.INVISIBLE; } - sensorContainerView.setVisibility(isVisible); + heartRateContainer.setVisibility(isVisible); if (isRecording) { - String heartRate = getContext().getString(R.string.value_unknown); + String sensorValue = getContext().getString(R.string.value_unknown); String sensorName = getContext().getString(R.string.value_unknown); - if (sensorDataSet != null && sensorDataSet.isRecent(BluetoothRemoteSensorManager.MAX_SENSOR_DATE_SET_AGE_MS)) { - sensorName = sensorDataSet.getSensorName(); - if (sensorDataSet.hasHeartRate()) { - heartRate = StringUtils.formatDecimal(sensorDataSet.getHeartRate(), 0); + if (sensorDataSet != null && sensorDataSet.getHeartRate() != null) { + sensorName = sensorDataSet.getHeartRate().getSensorName(); + if (sensorDataSet.getHeartRate().hasHeartRate_bpm() && sensorDataSet.getHeartRate().isRecent()) { + sensorValue = StringUtils.formatDecimal(sensorDataSet.getHeartRate().getHeartRate_bpm(), 0); } } heartRateSensorView.setText(sensorName); - heartRateValueView.setText(heartRate); + heartRateValueView.setText(sensorValue); + } + } + + private void setCadenceSensorData(SensorDataSet sensorDataSet, boolean isRecording) { + // heart rate + int isVisible = View.VISIBLE; + if (!isRecording || PreferencesUtils.isBluetoothCyclingCadenceSensorAddressNone(getContext())) { + isVisible = View.INVISIBLE; + } + cadenceContainer.setVisibility(isVisible); + + if (isRecording) { + String sensorValue = getContext().getString(R.string.value_unknown); + String sensorName = getContext().getString(R.string.value_unknown); + if (sensorDataSet != null && sensorDataSet.getCyclingCadence() != null) { + sensorName = sensorDataSet.getCyclingCadence().getSensorName(); + if (sensorDataSet.getCyclingCadence().hasCadence_rpm() && sensorDataSet.getCyclingCadence().isRecent()) { + sensorValue = StringUtils.formatDecimal(sensorDataSet.getCyclingCadence().getCadence_rpm(), 0); + } + } + + cadenceSensorView.setText(sensorName); + cadenceValueView.setText(sensorValue); } } diff --git a/src/main/java/de/dennisguse/opentracks/io/file/exporter/KmlTrackWriter.java b/src/main/java/de/dennisguse/opentracks/io/file/exporter/KmlTrackWriter.java index 4ed5314f7..8b2e37bca 100644 --- a/src/main/java/de/dennisguse/opentracks/io/file/exporter/KmlTrackWriter.java +++ b/src/main/java/de/dennisguse/opentracks/io/file/exporter/KmlTrackWriter.java @@ -18,7 +18,6 @@ package de.dennisguse.opentracks.io.file.exporter; import android.content.Context; import android.database.Cursor; import android.location.Location; -import android.net.Uri; import java.io.OutputStream; import java.io.PrintWriter; @@ -29,9 +28,9 @@ import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.content.DescriptionGenerator; import de.dennisguse.opentracks.content.data.Track; import de.dennisguse.opentracks.content.data.TrackPoint; +import de.dennisguse.opentracks.content.data.TrackPointSensorDataSet; import de.dennisguse.opentracks.content.data.Waypoint; import de.dennisguse.opentracks.content.provider.ContentProviderUtils; -import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.util.FileUtils; import de.dennisguse.opentracks.util.StringUtils; @@ -73,11 +72,11 @@ public class KmlTrackWriter implements TrackWriter { private TrackPoint startTrackPoint; /** - * @param context the context - * @param hasMultipleTracks should encode multiple tracks into one file? - * @param exportTrackDetail should detailed information about the track be exported (e.g., title, description, waypoints, timing)? - * @param exportSensorData should {@link SensorDataSet} be exported? - * @param exportPhotos should pictures be exported (if true: exports to KMZ)? + * @param context the context + * @param hasMultipleTracks should encode multiple tracks into one file? + * @param exportTrackDetail should detailed information about the track be exported (e.g., title, description, waypoints, timing)? + * @param exportSensorData should {@link TrackPointSensorDataSet} be exported? + * @param exportPhotos should pictures be exported (if true: exports to KMZ)? */ public KmlTrackWriter(Context context, boolean hasMultipleTracks, boolean exportTrackDetail, boolean exportSensorData, boolean exportPhotos) { this.context = context; @@ -269,13 +268,13 @@ public class KmlTrackWriter implements TrackWriter { printWriter.println("" + getCoordinates(trackPoint.getLocation(), " ") + ""); if (exportSensorData) { - SensorDataSet sensorDataSet = trackPoint.getSensorDataSet(); + TrackPointSensorDataSet sensorDataSet = trackPoint.getSensorDataSet(); if (sensorDataSet != null) { if (sensorDataSet.hasHeartRate()) { - heartRateList.add(sensorDataSet.getHeartRate()); + heartRateList.add(sensorDataSet.getHeartRate_bpm()); } - if (sensorDataSet.hasCadence()) { - cadenceList.add(sensorDataSet.getCadence()); + if (sensorDataSet.hasCyclingCadence()) { + cadenceList.add(sensorDataSet.getCyclingCadence_rpm()); } if (sensorDataSet.hasPower()) { powerList.add(sensorDataSet.getPower()); @@ -453,7 +452,7 @@ public class KmlTrackWriter implements TrackWriter { /** * Writes a sensor style. * - * @param name the name of the sesnor + * @param name the name of the sesnor * @param sensorType the sensor display name */ private void writeSensorStyle(String name, String sensorType) { diff --git a/src/main/java/de/dennisguse/opentracks/io/file/importer/KmlFileTrackImporter.java b/src/main/java/de/dennisguse/opentracks/io/file/importer/KmlFileTrackImporter.java index f19fcd326..46d7aea6a 100644 --- a/src/main/java/de/dennisguse/opentracks/io/file/importer/KmlFileTrackImporter.java +++ b/src/main/java/de/dennisguse/opentracks/io/file/importer/KmlFileTrackImporter.java @@ -27,8 +27,8 @@ import org.xml.sax.SAXException; import java.util.ArrayList; import de.dennisguse.opentracks.content.data.TrackPoint; +import de.dennisguse.opentracks.content.data.TrackPointSensorDataSet; import de.dennisguse.opentracks.content.provider.ContentProviderUtils; -import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.io.file.exporter.KmlTrackWriter; /** @@ -224,29 +224,25 @@ public class KmlFileTrackImporter extends AbstractFileTrackImporter { TrackPoint trackPoint = trackPoints.get(i); boolean hasSensorData = false; - float heartrate = SensorDataSet.DATA_UNAVAILABLE; - float cadence = SensorDataSet.DATA_UNAVAILABLE; - float power = SensorDataSet.DATA_UNAVAILABLE; + TrackPointSensorDataSet sensorDataSet = new TrackPointSensorDataSet(trackPoint.getTime()); if (i < heartRateList.size()) { - heartrate = heartRateList.get(i); + sensorDataSet.setHeartRate_bpm(heartRateList.get(i)); hasSensorData = true; } if (i < cadenceList.size()) { - cadence = cadenceList.get(i); + sensorDataSet.setCyclingCadence(cadenceList.get(i)); hasSensorData = true; } if (i < powerList.size()) { - power = powerList.get(i); + sensorDataSet.setPower(cadenceList.get(i)); hasSensorData = true; } if (!hasSensorData) { - insertTrackPoint(trackPoint); - } else { - TrackPoint sensorDataSetLocation = new TrackPoint(trackPoint, new SensorDataSet(heartrate, cadence, power, SensorDataSet.DATA_UNAVAILABLE, trackPoint.getTime())); - insertTrackPoint(sensorDataSetLocation); + trackPoint.setSensorDataSet(sensorDataSet); } + insertTrackPoint(trackPoint); } } diff --git a/src/main/java/de/dennisguse/opentracks/services/TrackRecordingService.java b/src/main/java/de/dennisguse/opentracks/services/TrackRecordingService.java index 8ce1ec2de..f603cf1d8 100644 --- a/src/main/java/de/dennisguse/opentracks/services/TrackRecordingService.java +++ b/src/main/java/de/dennisguse/opentracks/services/TrackRecordingService.java @@ -45,6 +45,7 @@ import de.dennisguse.opentracks.TrackDetailActivity; import de.dennisguse.opentracks.TrackListActivity; import de.dennisguse.opentracks.content.data.Track; import de.dennisguse.opentracks.content.data.TrackPoint; +import de.dennisguse.opentracks.content.data.TrackPointSensorDataSet; import de.dennisguse.opentracks.content.data.Waypoint; import de.dennisguse.opentracks.content.provider.ContentProviderUtils; import de.dennisguse.opentracks.content.provider.CustomContentProvider; @@ -575,7 +576,7 @@ public class TrackRecordingService extends Service { return; } - TrackPoint trackPoint = new TrackPoint(location, getSensorDataSet()); + TrackPoint trackPoint = new TrackPoint(location, getTrackPointSensorDataSet()); notificationManager.updateTrackPoint(this, trackPoint, recordingGpsAccuracy); if (!TrackPointUtils.fulfillsAccuracy(trackPoint, recordingGpsAccuracy)) { @@ -708,10 +709,20 @@ public class TrackRecordingService extends Service { } SensorDataSet getSensorDataSet() { - if (remoteSensorManager == null || !remoteSensorManager.isEnabled() || !remoteSensorManager.isSensorDataSetValid()) { + if (remoteSensorManager == null || !remoteSensorManager.isEnabled()) { return null; } - return remoteSensorManager.getSensorDataSet(); + + return remoteSensorManager.getSensorData(); + } + + TrackPointSensorDataSet getTrackPointSensorDataSet() { + SensorDataSet sensorData = getSensorDataSet(); + if (sensorData != null) { + return sensorData.createTrackPointSensorDataSet(); + } + + return null; } private void registerLocationListener() { diff --git a/src/main/java/de/dennisguse/opentracks/services/TrackRecordingServiceInterface.java b/src/main/java/de/dennisguse/opentracks/services/TrackRecordingServiceInterface.java index 531206a4a..951288a29 100644 --- a/src/main/java/de/dennisguse/opentracks/services/TrackRecordingServiceInterface.java +++ b/src/main/java/de/dennisguse/opentracks/services/TrackRecordingServiceInterface.java @@ -95,8 +95,13 @@ public interface TrackRecordingServiceInterface { long insertWaypoint(String name, String category, String description, String photoUrl); /** - * ONLY FOR TESTING + * Gets the current sensor data. Returns null if there is no data. * + * @return SensorDataSet object. + */ + SensorDataSet getSensorData(); + + /** * Inserts a location in the current recording track. *

* When recording a track, GPS locations are automatically inserted. @@ -107,13 +112,6 @@ public interface TrackRecordingServiceInterface { @VisibleForTesting void insertLocation(Location location); - /** - * Gets the current sensor data. Returns null if there is no data. - * - * @return SensorDataSet object. - */ - SensorDataSet getSensorData(); - /** * Disables processing of location updates from {@link android.location.LocationManager}. */ 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 bd9b31b3a..fbd4c4240 100644 --- a/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothConnectionManager.java +++ b/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothConnectionManager.java @@ -30,27 +30,24 @@ import androidx.annotation.NonNull; import java.util.UUID; -import de.dennisguse.opentracks.content.sensor.SensorDataSet; -import de.dennisguse.opentracks.content.sensor.SensorState; +import de.dennisguse.opentracks.content.sensor.SensorData; +import de.dennisguse.opentracks.content.sensor.SensorDataCycling; +import de.dennisguse.opentracks.content.sensor.SensorDataHeartRate; import de.dennisguse.opentracks.util.BluetoothUtils; /** - * Manages connection to Bluetooth LE heart rate monitor. + * Manages connection to a Bluetooth LE sensor and subscribes for onChange-notifications. + * Also parses the transferred data into {@link SensorDataObserver}. */ -public class BluetoothConnectionManager { - - private static final UUID HEART_RATE_MEASUREMENT_CHAR_UUID = new UUID(0x2A3700001000L, 0x800000805f9b34fbL); - private static final UUID CLIENT_CHARACTERISTIC_CONFIG_UUID = new UUID(0x290200001000L, 0x800000805f9b34fbL); +public abstract class BluetoothConnectionManager { private static final String TAG = BluetoothConnectionManager.class.getSimpleName(); - private final Context context; - private final EventCallback observer; - - private SensorState sensorState; + private final SensorDataObserver observer; + private final UUID serviceUUUID; + private final UUID measurementUUID; private BluetoothGatt bluetoothGatt; - private final BluetoothDevice bluetoothDevice; private final BluetoothGattCallback connectCallback = new BluetoothGattCallback() { @Override @@ -58,80 +55,70 @@ public class BluetoothConnectionManager { switch (newState) { case BluetoothProfile.STATE_CONNECTING: Log.d(TAG, "Connecting to sensor: " + gatt.getDevice()); - setState(SensorState.CONNECTING); - - observer.connecting(gatt.getDevice().getName()); case BluetoothProfile.STATE_CONNECTED: Log.d(TAG, "Connected to sensor: " + gatt.getDevice()); - setState(SensorState.CONNECTED); gatt.discoverServices(); - - observer.connected(gatt.getDevice().getName()); break; case BluetoothProfile.STATE_DISCONNECTING: Log.d(TAG, "Disconnecting from sensor: " + gatt.getDevice()); - setState(SensorState.DISCONNECTING); case BluetoothProfile.STATE_DISCONNECTED: Log.d(TAG, "Disconnected from sensor: " + gatt.getDevice()); - setState(SensorState.DISCONNECTED); - - observer.disconnected(gatt.getDevice().getName()); } } @Override public void onServicesDiscovered(@NonNull BluetoothGatt gatt, int status) { - BluetoothGattService service = gatt.getService(BluetoothUtils.HEART_RATE_SERVICE_UUID); + BluetoothGattService service = gatt.getService(serviceUUUID); if (service == null) { - Log.e(TAG, "Could not get heart rate service for " + gatt.getDevice().getAddress()); + Log.e(TAG, "Could not get service for address=" + gatt.getDevice().getAddress() + " serviceUUID=" + serviceUUUID); return; } - - BluetoothGattCharacteristic characteristic = service.getCharacteristic(HEART_RATE_MEASUREMENT_CHAR_UUID); + BluetoothGattCharacteristic characteristic = service.getCharacteristic(measurementUUID); if (characteristic == null) { - Log.e(TAG, "Could not get BluetoothCharacteristic for " + gatt.getDevice().getAddress()); + Log.e(TAG, "Could not get BluetoothCharacteristic for address=" + gatt.getDevice().getAddress() + " serviceUUID=" + serviceUUUID + " characteristicUUID=" + measurementUUID); return; } gatt.setCharacteristicNotification(characteristic, true); - BluetoothGattDescriptor descriptor = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID); + // Register for updates. + BluetoothGattDescriptor descriptor = characteristic.getDescriptor(BluetoothUtils.CLIENT_CHARACTERISTIC_CONFIG_UUID); descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); gatt.writeDescriptor(descriptor); } @Override public void onCharacteristicChanged(BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic) { - int heartRate = BluetoothUtils.parseHeartRate(characteristic); - String deviceName = gatt.getDevice().getName(); + String sensorName = gatt.getDevice().getName(); + Log.d(TAG, "Received data from " + sensorName); - Log.d(TAG, "Received heart beat rate " + deviceName + ": " + heartRate); - observer.onSensorDataReceived(new SensorDataSet(heartRate, deviceName, gatt.getDevice().getAddress())); + SensorData sensorData = parsePayload(sensorName, gatt.getDevice().getAddress(), characteristic); + if (sensorData != null) { + observer.onChanged(sensorData); + } } }; - BluetoothConnectionManager(@NonNull Context context, @NonNull BluetoothDevice bluetoothDevice, EventCallback observer) { - this.context = context; - this.bluetoothDevice = bluetoothDevice; - this.sensorState = SensorState.NONE; + BluetoothConnectionManager(UUID serviceUUUID, UUID measurementUUID, SensorDataObserver observer) { + this.serviceUUUID = serviceUUUID; + this.measurementUUID = measurementUUID; this.observer = observer; } - public synchronized void connect() { + synchronized void connect(Context context, @NonNull BluetoothDevice device) { if (bluetoothGatt != null) { Log.w(TAG, "Already connected; ignoring."); } - Log.d(TAG, "Connecting to: " + bluetoothDevice); + Log.d(TAG, "Connecting to: " + device); - bluetoothGatt = bluetoothDevice.connectGatt(this.context, true, this.connectCallback); + bluetoothGatt = device.connectGatt(context, true, this.connectCallback); - setState(SensorState.CONNECTING); } - public synchronized void disconnect() { + synchronized void disconnect() { if (bluetoothGatt == null) { Log.w(TAG, "Cannot disconnect if not connected."); return; @@ -140,27 +127,78 @@ public class BluetoothConnectionManager { bluetoothGatt = null; } - public synchronized boolean isSameBluetoothDevice(String address) { - return this.bluetoothDevice.getAddress().equals(address); + synchronized boolean isSameBluetoothDevice(String address) { + if (bluetoothGatt == null) { + return false; + } + + return address.equals(bluetoothGatt.getDevice().getAddress()); } - synchronized SensorState getSensorState() { - return sensorState; + /** + * @return null if data could not be parsed. + */ + protected abstract de.dennisguse.opentracks.content.sensor.SensorData parsePayload(String sensorName, String address, BluetoothGattCharacteristic characteristic); + + public static class HeartRate extends BluetoothConnectionManager { + + HeartRate(@NonNull SensorDataObserver observer) { + super(BluetoothUtils.HEART_RATE_SERVICE_UUID, BluetoothUtils.HEART_RATE_MEASUREMENT_CHAR_UUID, observer); + } + + @Override + protected SensorDataHeartRate parsePayload(String sensorName, String address, BluetoothGattCharacteristic characteristic) { + Integer heartRate = BluetoothUtils.parseHeartRate(characteristic); + + return heartRate != null ? new SensorDataHeartRate(address, sensorName, heartRate) : null; + } } - private synchronized void setState(SensorState sensorState) { - this.sensorState = sensorState; + public static class CyclingCadence extends BluetoothConnectionManager { + + CyclingCadence(SensorDataObserver observer) { + super(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, BluetoothUtils.CYCLING_SPPED_CADENCE_MEASUREMENT_CHAR_UUID, observer); + } + + @Override + protected SensorDataCycling.Cadence parsePayload(String sensorName, String address, BluetoothGattCharacteristic characteristic) { + SensorDataCycling.CadenceAndSpeed cadenceAndSpeed = BluetoothUtils.parseCyclingCrankAndWheel(address, sensorName, characteristic); + if (cadenceAndSpeed == null) { + return null; + } + + if (cadenceAndSpeed.getCadence() != null) { + return cadenceAndSpeed.getCadence(); + } + + //Workaround for Wahoo CADENCE: this sensor reports speed (instead of cadence) + if (cadenceAndSpeed.getSpeed() != null) { + return new SensorDataCycling.Cadence(cadenceAndSpeed.getSpeed()); + } + + return null; + } } + public static class CyclingSpeed extends BluetoothConnectionManager { - interface EventCallback { + CyclingSpeed(SensorDataObserver observer) { + super(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, BluetoothUtils.CYCLING_SPPED_CADENCE_MEASUREMENT_CHAR_UUID, observer); + } - void connecting(String sensorName); + @Override + protected SensorDataCycling.Speed parsePayload(String sensorName, String address, BluetoothGattCharacteristic characteristic) { + SensorDataCycling.CadenceAndSpeed cadenceAndSpeed = BluetoothUtils.parseCyclingCrankAndWheel(address, sensorName, characteristic); + if (cadenceAndSpeed != null) { + return cadenceAndSpeed.getSpeed(); + } + return null; + } + } - void connected(String sensorName); + interface SensorDataObserver { - void onSensorDataReceived(SensorDataSet sensorDataSet); + void onChanged(SensorData sensorData); - void disconnected(String sensorName); } } 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 702cddda6..74f06457d 100644 --- a/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothRemoteSensorManager.java +++ b/src/main/java/de/dennisguse/opentracks/services/sensors/BluetoothRemoteSensorManager.java @@ -21,53 +21,72 @@ import android.bluetooth.BluetoothDevice; import android.content.Context; import android.content.SharedPreferences; import android.util.Log; -import android.widget.Toast; import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.content.sensor.SensorDataCycling; import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.util.BluetoothUtils; import de.dennisguse.opentracks.util.PreferencesUtils; +import de.dennisguse.opentracks.util.UnitConversions; /** - * Bluetooth LE sensor manager. - * Should only be instantiated once! + * Bluetooth LE sensor manager: manages connections to Bluetooth LE sensors. + * + * Note: should only be instantiated once. + * + * TODO: listen for Bluetooth enabled/disabled events. + * + * TODO: In case, a cycling (Cadence and Speed) sensor reports both values, testing is required. + * We establish two GATT separate GATT connections (as if two different sensors were used). + * However, it is not clear if this is allowed. + * Even if this works, it is not clear what happens if a user (while recording) changes one of the sensors in the settings as this will trigger a disconnect of one GATT. * * @author Sandor Dornbush */ -public class BluetoothRemoteSensorManager implements BluetoothConnectionManager.EventCallback { +public class BluetoothRemoteSensorManager implements BluetoothConnectionManager.SensorDataObserver { - public static final long MAX_SENSOR_DATE_SET_AGE_MS = 5000; + private static final String TAG = BluetoothRemoteSensorManager.class.getSimpleName(); - private static final String TAG = BluetoothConnectionManager.class.getSimpleName(); + public static final long MAX_SENSOR_DATE_SET_AGE_MS = 5 * UnitConversions.S_TO_MS; private static final BluetoothAdapter bluetoothAdapter = BluetoothUtils.getDefaultBluetoothAdapter(TAG); private final Context context; - private final SharedPreferences sharedPreferences; + private boolean started = false; + + private final BluetoothConnectionManager heartRate = new BluetoothConnectionManager.HeartRate(this); + private final BluetoothConnectionManager cyclingCadence = new BluetoothConnectionManager.CyclingCadence(this); + + private final SensorDataSet sensorDataSet = new SensorDataSet(); + private final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = new SharedPreferences.OnSharedPreferenceChangeListener() { @Override public void onSharedPreferenceChanged(SharedPreferences preferences, String key) { - if (bluetoothConnectionManager != null && PreferencesUtils.isKey(context, R.string.settings_sensor_bluetooth_heart_rate_key, key)) { - if (PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(context)) { + if (!started) return; + + if (PreferencesUtils.isKey(context, R.string.settings_sensor_bluetooth_heart_rate_key, key)) { + if (PreferencesUtils.isBluetoothHeartRateSensorAddressNone(context)) { stop(); return; } String address = PreferencesUtils.getBluetoothHeartRateSensorAddress(context); - if (bluetoothConnectionManager.isSameBluetoothDevice(address)) { + connect(heartRate, address); + } + + if (PreferencesUtils.isKey(context, R.string.settings_sensor_bluetooth_cycling_cadence_key, key)) { + if (PreferencesUtils.isBluetoothCyclingCadenceSensorAddressNone(context)) { + stop(); return; } + String address = PreferencesUtils.getBluetoothCyclingCadenceSensorAddress(context); - disconnect(); - startCurrentSensor(); + connect(cyclingCadence, address); } } }; - private SensorDataSet sensorDataSet = null; - private BluetoothConnectionManager bluetoothConnectionManager; - /** * @param context the context */ @@ -77,84 +96,76 @@ public class BluetoothRemoteSensorManager implements BluetoothConnectionManager. } public void start() { + started = true; sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); - startCurrentSensor(); + + //Trigger connection startup + sharedPreferenceChangeListener.onSharedPreferenceChanged(null, null); } - public void stop() { - disconnect(); + public synchronized void stop() { + // Disconnecting + heartRate.disconnect(); + cyclingCadence.disconnect(); + + sensorDataSet.clear(); + sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); + started = false; } - public boolean isEnabled() { return bluetoothAdapter != null && bluetoothAdapter.isEnabled(); } - public SensorDataSet getSensorDataSet() { - return sensorDataSet; - } - - public boolean isSensorDataSetValid() { - SensorDataSet sensorDataSet = getSensorDataSet(); - if (sensorDataSet == null) { - return false; - } - return sensorDataSet.isRecent(MAX_SENSOR_DATE_SET_AGE_MS); - } - - private void startCurrentSensor() { + private synchronized void connect(BluetoothConnectionManager connectionManager, String address) { if (!isEnabled()) { Log.w(TAG, "Bluetooth not enabled."); return; } - if (PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(context)) { - Log.w(TAG, "No bluetooth address."); + if (PreferencesUtils.isBluetoothSensorAddressNone(context, address)) { + Log.w(TAG, "No Bluetooth address."); return; } - String address = PreferencesUtils.getBluetoothHeartRateSensorAddress(context); + + // Check if there is an ongoing connection; if yes, check if the address changed. + if (connectionManager.isSameBluetoothDevice(address)) { + return; + } else { + connectionManager.disconnect(); + } + Log.i(TAG, "Connecting to bluetooth address: " + address); - - BluetoothDevice device; try { - device = bluetoothAdapter.getRemoteDevice(address); + BluetoothDevice device = bluetoothAdapter.getRemoteDevice(address); + connectionManager.connect(context, device); } catch (IllegalArgumentException e) { - Log.w(TAG, "Unable to get remote device for: " + address, e); - - String toastMessage = context.getString(R.string.sensor_not_known, address); - Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show(); - - return; - } - - disconnect(); - - bluetoothConnectionManager = new BluetoothConnectionManager(context, device, this); - bluetoothConnectionManager.connect(); - } - - private void disconnect() { - if (bluetoothConnectionManager != null) { - bluetoothConnectionManager.disconnect(); - bluetoothConnectionManager = null; + Log.e(TAG, "Unable to get remote device for: " + address, e); } } - @Override - public void connecting(String sensorName) { + public SensorDataSet getSensorData() { + return sensorDataSet; } @Override - public void connected(String sensorName) { - } + public synchronized void onChanged(de.dennisguse.opentracks.content.sensor.SensorData sensorData) { + if (sensorData instanceof SensorDataCycling.Cadence) { + if (sensorData.equals(sensorDataSet.getCyclingCadence())) { + Log.d(TAG, "onChanged: cadence data repeated."); + return; + } + ((SensorDataCycling.Cadence) sensorData).compute(sensorDataSet.getCyclingCadence()); + } + if (sensorData instanceof SensorDataCycling.Speed) { + if (sensorData.equals(sensorDataSet.getCyclingSpeed())) { + Log.d(TAG, "onChanged: speed data repeated."); + return; + } + ((SensorDataCycling.Speed) sensorData).compute(sensorDataSet.getCyclingSpeed()); + } - @Override - public void onSensorDataReceived(SensorDataSet sensorDataSet) { - this.sensorDataSet = sensorDataSet; - } - - @Override - public void disconnected(String sensorName) { + sensorDataSet.set(sensorData); } } diff --git a/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java b/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java index e904e5f1c..5eccca161 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java +++ b/src/main/java/de/dennisguse/opentracks/settings/SettingsActivity.java @@ -13,6 +13,9 @@ import androidx.preference.PreferenceFragmentCompat; import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment; +import de.dennisguse.opentracks.settings.bluetooth.BluetoothLeCyclingCadenceAndSpeedPreference; +import de.dennisguse.opentracks.settings.bluetooth.BluetoothLeHeartRatePreference; +import de.dennisguse.opentracks.settings.bluetooth.BluetoothLeSensorPreference; import de.dennisguse.opentracks.util.BluetoothUtils; import de.dennisguse.opentracks.util.HackUtils; import de.dennisguse.opentracks.util.PreferencesUtils; @@ -111,8 +114,10 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit } else if (preference instanceof ActivityTypePreference) { activityPreferenceDialog = ActivityTypePreference.ActivityPreferenceDialog.newInstance(preference.getKey()); dialogFragment = activityPreferenceDialog; - } else if (preference instanceof BluetoothLePreference) { - dialogFragment = BluetoothLePreference.BluetoothLePreferenceDialog.newInstance(preference.getKey()); + } else if (preference instanceof BluetoothLeHeartRatePreference) { + 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); } if (dialogFragment != null) { @@ -139,7 +144,7 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit private void updateBluetooth() { // Disable Bluetooth preference if device does not have Bluetooth - BluetoothLePreference bluetoothPreference = findPreference(getString(R.string.settings_sensor_bluetooth_heart_rate_key)); + BluetoothLeSensorPreference bluetoothPreference = findPreference(getString(R.string.settings_sensor_bluetooth_heart_rate_key)); bluetoothPreference.setVisible(BluetoothUtils.hasBluetooth(TAG)); } diff --git a/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingCadenceAndSpeedPreference.java b/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingCadenceAndSpeedPreference.java new file mode 100644 index 000000000..aba91ba79 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeCyclingCadenceAndSpeedPreference.java @@ -0,0 +1,23 @@ +package de.dennisguse.opentracks.settings.bluetooth; + +import android.content.Context; +import android.util.AttributeSet; + +public class BluetoothLeCyclingCadenceAndSpeedPreference extends BluetoothLeSensorPreference { + + public BluetoothLeCyclingCadenceAndSpeedPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public BluetoothLeCyclingCadenceAndSpeedPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public BluetoothLeCyclingCadenceAndSpeedPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public BluetoothLeCyclingCadenceAndSpeedPreference(Context context) { + super(context); + } +} diff --git a/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeHeartRatePreference.java b/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeHeartRatePreference.java new file mode 100644 index 000000000..8021228a9 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeHeartRatePreference.java @@ -0,0 +1,23 @@ +package de.dennisguse.opentracks.settings.bluetooth; + +import android.content.Context; +import android.util.AttributeSet; + +public class BluetoothLeHeartRatePreference extends BluetoothLeSensorPreference { + + public BluetoothLeHeartRatePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public BluetoothLeHeartRatePreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public BluetoothLeHeartRatePreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public BluetoothLeHeartRatePreference(Context context) { + super(context); + } +} \ No newline at end of file diff --git a/src/main/java/de/dennisguse/opentracks/settings/BluetoothLePreference.java b/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeSensorPreference.java similarity index 78% rename from src/main/java/de/dennisguse/opentracks/settings/BluetoothLePreference.java rename to src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeSensorPreference.java index 4f8468408..275faf4c5 100644 --- a/src/main/java/de/dennisguse/opentracks/settings/BluetoothLePreference.java +++ b/src/main/java/de/dennisguse/opentracks/settings/bluetooth/BluetoothLeSensorPreference.java @@ -1,4 +1,4 @@ -package de.dennisguse.opentracks.settings; +package de.dennisguse.opentracks.settings.bluetooth; import android.bluetooth.BluetoothAdapter; import android.bluetooth.le.BluetoothLeScanner; @@ -21,8 +21,10 @@ import androidx.preference.PreferenceDialogFragmentCompat; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.settings.BluetoothLeAdapter; import de.dennisguse.opentracks.util.BluetoothUtils; import de.dennisguse.opentracks.util.PreferencesUtils; @@ -30,25 +32,27 @@ import de.dennisguse.opentracks.util.PreferencesUtils; * Preference to select a discoverable Bluetooth LE device. * Based upon ListPreference. */ -public class BluetoothLePreference extends DialogPreference { +public abstract class BluetoothLeSensorPreference extends DialogPreference { - private static final String TAG = BluetoothLePreference.class.getSimpleName(); + private static final String TAG = BluetoothLeSensorPreference.class.getSimpleName(); + + private static final String ARG_BLUETOOTH_UUID = "bluetoothUUID"; private static final int DEVICE_NONE_RESOURCEID = R.string.value_none; - public BluetoothLePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + public BluetoothLeSensorPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } - public BluetoothLePreference(Context context, AttributeSet attrs, int defStyleAttr) { + public BluetoothLeSensorPreference(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } - public BluetoothLePreference(Context context, AttributeSet attrs) { + public BluetoothLeSensorPreference(Context context, AttributeSet attrs) { super(context, attrs); } - public BluetoothLePreference(Context context) { + public BluetoothLeSensorPreference(Context context) { super(context); } @@ -78,18 +82,20 @@ public class BluetoothLePreference extends DialogPreference { @Override public CharSequence getSummary() { - if (getValue() == null || PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(getContext(), getValue())) { + if (getValue() == null || PreferencesUtils.isBluetoothSensorAddressNone(getContext(), getValue())) { return getContext().getString(DEVICE_NONE_RESOURCEID); } return getValue(); } - public static class BluetoothLePreferenceDialog extends PreferenceDialogFragmentCompat { + public static class BluetoothLeSensorPreferenceDialog extends PreferenceDialogFragmentCompat { private int selectedEntryIndex; private final BluetoothLeAdapter listAdapter = new BluetoothLeAdapter(); + private UUID sensorUUID; + private BluetoothLeScanner scanner = null; private final ScanCallback scanCallback = new ScanCallback() { @Override @@ -116,10 +122,11 @@ public class BluetoothLePreference extends DialogPreference { } }; - static BluetoothLePreferenceDialog newInstance(String key) { - final BluetoothLePreferenceDialog fragment = new BluetoothLePreferenceDialog(); + public static BluetoothLeSensorPreferenceDialog newInstance(String preferenceKey, UUID sensorUUID) { + final BluetoothLeSensorPreferenceDialog fragment = new BluetoothLeSensorPreferenceDialog(); final Bundle b = new Bundle(1); - b.putString(ARG_KEY, key); + b.putString(ARG_KEY, preferenceKey); + b.putSerializable(ARG_BLUETOOTH_UUID, sensorUUID); fragment.setArguments(b); return fragment; } @@ -128,6 +135,9 @@ public class BluetoothLePreference extends DialogPreference { public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + sensorUUID = (UUID) getArguments().getSerializable(ARG_BLUETOOTH_UUID); + Log.i(TAG, "UUID: " + sensorUUID); + BluetoothAdapter bluetoothAdapter = BluetoothUtils.getDefaultBluetoothAdapter(TAG); if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) { Log.w(TAG, "Bluetooth adapter is present or not enabled."); @@ -152,14 +162,14 @@ public class BluetoothLePreference extends DialogPreference { listAdapter.add(getContext().getString(DEVICE_NONE_RESOURCEID), deviceNone); selectedEntryIndex = 0; - BluetoothLePreference preference = (BluetoothLePreference) getPreference(); + BluetoothLeSensorPreference preference = (BluetoothLeSensorPreference) getPreference(); String deviceSelected = preference.getValue(); if (deviceSelected != null && !deviceNone.equals(deviceSelected)) { listAdapter.add(preference.getValue(), preference.getValue()); selectedEntryIndex = 1; } - ScanFilter.Builder scanFilterBuilder = new ScanFilter.Builder().setServiceUuid(new ParcelUuid(BluetoothUtils.HEART_RATE_SERVICE_UUID)); + ScanFilter.Builder scanFilterBuilder = new ScanFilter.Builder().setServiceUuid(new ParcelUuid(sensorUUID)); List scanFilter = new ArrayList<>(); scanFilter.add(scanFilterBuilder.build()); @@ -180,7 +190,7 @@ public class BluetoothLePreference extends DialogPreference { public void onClick(DialogInterface dialog, int which) { selectedEntryIndex = which; - BluetoothLePreferenceDialog.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE); + BluetoothLeSensorPreferenceDialog.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE); dialog.dismiss(); } }); @@ -196,7 +206,7 @@ public class BluetoothLePreference extends DialogPreference { if (positiveResult && selectedEntryIndex >= 0) { String value = listAdapter.get(selectedEntryIndex).getAddress(); - BluetoothLePreference preference = (BluetoothLePreference) getPreference(); + BluetoothLeSensorPreference preference = (BluetoothLeSensorPreference) getPreference(); if (preference.callChangeListener(value)) { preference.setValue(value); } diff --git a/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java b/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java index d6a3df028..ed32d1c9f 100644 --- a/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java +++ b/src/main/java/de/dennisguse/opentracks/util/BluetoothUtils.java @@ -21,9 +21,13 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; +import androidx.annotation.NonNull; + import java.util.ArrayList; import java.util.UUID; +import de.dennisguse.opentracks.content.sensor.SensorDataCycling; + /** * Utilities for dealing with bluetooth devices. * @@ -31,11 +35,21 @@ import java.util.UUID; */ public class BluetoothUtils { + public static final UUID CLIENT_CHARACTERISTIC_CONFIG_UUID = new UUID(0x290200001000L, 0x800000805f9b34fbL); + 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_SPEED_CADENCE_SERVICE_UUID = new UUID(0x181600001000L, 0x800000805f9b34fbL); + public static final UUID CYCLING_SPPED_CADENCE_MEASUREMENT_CHAR_UUID = new UUID(0x2A5B00001000L, 0x800000805f9b34fbL); private BluetoothUtils() { } + /** + * If called from UI: use a background thread to get the default Bluetooth adapter. + * TODO Check if this is necessary. + */ public static BluetoothAdapter getDefaultBluetoothAdapter(final String TAG) { // If from the main application thread, return directly if (Thread.currentThread().equals(Looper.getMainLooper().getThread())) { @@ -77,12 +91,56 @@ public class BluetoothUtils { return BluetoothUtils.getDefaultBluetoothAdapter(TAG) != null; } - public static int parseHeartRate(BluetoothGattCharacteristic characteristic) { + public static Integer parseHeartRate(BluetoothGattCharacteristic characteristic) { //DOCUMENTATION https://www.bluetooth.com/specifications/gatt/characteristics/ byte[] raw = characteristic.getValue(); - int index = ((raw[0] & 0x1) == 1) ? 2 : 1; - int format = (index == 1) ? BluetoothGattCharacteristic.FORMAT_UINT8 : BluetoothGattCharacteristic.FORMAT_UINT16; - return characteristic.getIntValue(format, index); + if (raw.length == 0) { + return null; + } + + boolean formatUINT16 = ((raw[0] & 0x1) == 1); + if (formatUINT16 && raw.length >= 3) { + return characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, 1); + } + if (!formatUINT16 && raw.length >= 2) { + return characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 1); + } + + return null; } + /** + * Documentation: https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=261449 + */ + public static SensorDataCycling.CadenceAndSpeed parseCyclingCrankAndWheel(String address, String sensorName, @NonNull BluetoothGattCharacteristic characteristic) { + int valueLength = characteristic.getValue().length; + if (valueLength == 0) { + return null; + } + + int flags = characteristic.getValue()[0]; + boolean hasCrank = (flags & 0x01) > 0; + boolean hasWheel = (flags & 0x02) > 0; + + SensorDataCycling.Cadence cadence = null; + int index = 1; + if (hasCrank && valueLength - index >= 6) { + long crankCount = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT32, index); + index += 4; + + int crankTime = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); // 1/1024s + index += 2; + cadence = new SensorDataCycling.Cadence(address, sensorName, crankCount, crankTime); + } + + SensorDataCycling.Speed speed = null; + if (hasWheel && valueLength - index >= 4) { + int wheelCount = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); + index += 2; + int wheelTime = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); // 1/1024s + speed = new SensorDataCycling.Speed(address, sensorName, wheelCount, wheelTime); + } + + return new SensorDataCycling.CadenceAndSpeed(address, sensorName, cadence, speed); + } } diff --git a/src/main/java/de/dennisguse/opentracks/util/PreferencesUtils.java b/src/main/java/de/dennisguse/opentracks/util/PreferencesUtils.java index eb3d1c7e0..ed2b45443 100644 --- a/src/main/java/de/dennisguse/opentracks/util/PreferencesUtils.java +++ b/src/main/java/de/dennisguse/opentracks/util/PreferencesUtils.java @@ -215,22 +215,42 @@ public class PreferencesUtils { setBoolean(context, R.string.recording_track_paused_key, RECORDING_TRACK_PAUSED); } - private static String getBluetoothHeartRateSensorAddressNone(Context context) { + private static String getBluetoothSensorAddressNone(Context context) { return context.getString(R.string.sensor_type_value_none); } - public static boolean isBluetoothHeartRateSensorAddressDefault(Context context) { - return getBluetoothHeartRateSensorAddressNone(context).equals(getBluetoothHeartRateSensorAddress(context)); + public static boolean isBluetoothSensorAddressNone(Context context, String currentValue) { + return getBluetoothSensorAddressNone(context).equals(currentValue); } - public static boolean isBluetoothHeartRateSensorAddressDefault(Context context, String currentValue) { - return getBluetoothHeartRateSensorAddressNone(context).equals(currentValue); + + public static boolean isBluetoothHeartRateSensorAddressNone(Context context) { + return isBluetoothSensorAddressNone(context, getBluetoothHeartRateSensorAddress(context)); } public static String getBluetoothHeartRateSensorAddress(Context context) { - return getString(context, R.string.settings_sensor_bluetooth_heart_rate_key, getBluetoothHeartRateSensorAddressNone(context)); + return getString(context, R.string.settings_sensor_bluetooth_heart_rate_key, getBluetoothSensorAddressNone(context)); } + + public static boolean isBluetoothCyclingCadenceSensorAddressNone(Context context) { + return isBluetoothSensorAddressNone(context, getBluetoothCyclingCadenceSensorAddress(context)); + } + + public static String getBluetoothCyclingCadenceSensorAddress(Context context) { + return getString(context, R.string.settings_sensor_bluetooth_cycling_cadence_key, getBluetoothSensorAddressNone(context)); + } + + + public static boolean isBluetoothCyclingSpeedSensorAddressNone(Context context) { + return isBluetoothSensorAddressNone(context, getBluetoothCyclingSpeedSensorAddress(context)); + } + + public static String getBluetoothCyclingSpeedSensorAddress(Context context) { + return getString(context, R.string.settings_sensor_bluetooth_cycling_speed_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/java/de/dennisguse/opentracks/util/UintUtils.java b/src/main/java/de/dennisguse/opentracks/util/UintUtils.java new file mode 100644 index 000000000..3778c3255 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/util/UintUtils.java @@ -0,0 +1,30 @@ +package de.dennisguse.opentracks.util; + +public class UintUtils { + + public static int UINT16_MAX = 0xFFFF; + public static long UINT32_MAX = 0xFFFFFFFFL; + + private UintUtils() { + } + + /** + * Computes a - b for UINT with overflow (b < a). + * + * @return diff or -1 (invalid) + */ + public static long diff(long a, long b, final long UINT_MAX) { + if (a < 0 || b < 0) { + return -1; + } + if (a > UINT_MAX && b > UINT_MAX) { + return -1; + } + + if (a >= b) { + return a - b; + } + + return (UINT_MAX - b) + a; + } +} diff --git a/src/main/res/layout/stats.xml b/src/main/res/layout/stats.xml index a908a8208..dcd753d99 100644 --- a/src/main/res/layout/stats.xml +++ b/src/main/res/layout/stats.xml @@ -267,18 +267,24 @@ limitations under the License. android:id="@+id/stats_sensor_separator" style="@style/StatsHorizontalLine" /> - + - + + + + - + + style="@style/StatsLargeLabel" + android:text="@string/sensor_state_cadence" /> + android:text="@string/value_unknown" /> + + + + + + + + \ No newline at end of file diff --git a/src/main/res/values/do_not_translate.xml b/src/main/res/values/do_not_translate.xml index 014d775d0..bb53a8171 100644 --- a/src/main/res/values/do_not_translate.xml +++ b/src/main/res/values/do_not_translate.xml @@ -25,6 +25,7 @@ limitations under the License. http://maps.google.com/mapfiles/ms/micons/blue-pushpin.png BPM + RPM - diff --git a/src/main/res/values/settings.xml b/src/main/res/values/settings.xml index 5c9693d6f..aa27838a6 100644 --- a/src/main/res/values/settings.xml +++ b/src/main/res/values/settings.xml @@ -16,6 +16,8 @@ settingsReset bluetoothSensor + bluetoothCyclingCadenceSensor + bluetoothCyclingSpeedSensor NONE defaultActivity diff --git a/src/main/res/xml/settings.xml b/src/main/res/xml/settings.xml index d86b8dffe..c1ee6185f 100644 --- a/src/main/res/xml/settings.xml +++ b/src/main/res/xml/settings.xml @@ -111,10 +111,18 @@ limitations under the License. - + + + + +