Merge pull request #193 from OpenTracksApp/bluetooth

Bluetooth LE: add cycling cadence and speed sensor(s).
This commit is contained in:
Dennis Guse
2020-04-27 18:14:39 +02:00
committed by GitHub
36 changed files with 1173 additions and 473 deletions
@@ -19,7 +19,6 @@ package de.dennisguse.opentracks;
import android.location.Location; import android.location.Location;
import de.dennisguse.opentracks.content.data.TrackPoint; import de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
/** /**
* Commons utilities for creating stubs of track, location. * Commons utilities for creating stubs of track, location.
@@ -63,6 +62,6 @@ public class TrackStubUtils {
location.setTime(INITIAL_TIME); location.setTime(INITIAL_TIME);
location.setBearing(INITIAL_BEARING); location.setBearing(INITIAL_BEARING);
return new TrackPoint(location, new SensorDataSet(Float.NaN, Float.NaN)); return new TrackPoint(location);
} }
} }
@@ -514,7 +514,7 @@ public class CustomContentProviderUtilsTest {
} }
/** /**
* Tests the method {@link ContentProviderUtils#bulkInsertTrackPoint(TrackPoint[], int, long)}. * Tests the method {@link ContentProviderUtils#bulkInsertTrackPoint(TrackPoint[], long)}.
*/ */
@Test @Test
public void testBulkInsertTrackPoint() { public void testBulkInsertTrackPoint() {
@@ -533,44 +533,44 @@ public class CustomContentProviderUtilsTest {
/** /**
* Tests the method {@link ContentProviderUtils#createTrackPoint(Cursor)}. * Tests the method {@link ContentProviderUtils#createTrackPoint(Cursor)}.
*/ */
//TODO incomplete
@Test @Test
public void testCreateTrackPoint() { public void testCreateTrackPoint() {
// Set index. // given
int index = 1; when(cursorMock.getColumnIndex(TrackPointsColumns._ID)).thenReturn(1);
when(cursorMock.getColumnIndex(TrackPointsColumns._ID)).thenReturn(index++);
when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.LONGITUDE)).thenReturn(index++);
when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.LATITUDE)).thenReturn(index++);
when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.TIME)).thenReturn(index++);
when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.SPEED)).thenReturn(index++);
when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.SENSOR_HEARTRATE)).thenReturn(index++);
// Set return value of isNull(). when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.LONGITUDE)).thenReturn(2);
index = 2; when(cursorMock.isNull(2)).thenReturn(false);
when(cursorMock.isNull(index++)).thenReturn(false);
when(cursorMock.isNull(index++)).thenReturn(false);
when(cursorMock.isNull(index++)).thenReturn(false);
when(cursorMock.isNull(index++)).thenReturn(false);
when(cursorMock.isNull(index++)).thenReturn(false);
// Set return value of getInt().
index = 2;
int longitude = 11; int longitude = 11;
when(cursorMock.getInt(index++)).thenReturn(longitude * 1000000); when(cursorMock.getInt(2)).thenReturn(longitude * 1000000);
when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.LATITUDE)).thenReturn(3);
when(cursorMock.isNull(3)).thenReturn(false);
int latitude = 22; int latitude = 22;
when(cursorMock.getInt(index++)).thenReturn(latitude * 1000000); when(cursorMock.getInt(3)).thenReturn(latitude * 1000000);
when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.TIME)).thenReturn(4);
when(cursorMock.isNull(4)).thenReturn(false);
long time = System.currentTimeMillis(); long time = System.currentTimeMillis();
when(cursorMock.getLong(index++)).thenReturn(time); when(cursorMock.getLong(4)).thenReturn(time);
when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.SPEED)).thenReturn(5);
when(cursorMock.isNull(5)).thenReturn(false);
float speed = 2.2f; float speed = 2.2f;
when(cursorMock.getFloat(index++)).thenReturn(speed); when(cursorMock.getFloat(5)).thenReturn(speed);
byte[] sensor = "Sensor state".getBytes(); when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.SENSOR_HEARTRATE)).thenReturn(6);
when(cursorMock.getBlob(index++)).thenReturn(sensor); when(cursorMock.isNull(6)).thenReturn(false);
TrackPoint location = contentProviderUtils.createTrackPoint(cursorMock); // when
Assert.assertEquals(longitude, location.getLongitude(), 0.01); TrackPoint trackPoint = contentProviderUtils.createTrackPoint(cursorMock);
Assert.assertEquals(latitude, location.getLatitude(), 0.01);
Assert.assertEquals(time, location.getTime(), 0.01); // then
Assert.assertEquals(speed, location.getSpeed(), 0.01); Assert.assertEquals(longitude, trackPoint.getLongitude(), 0.01);
Assert.assertEquals(latitude, trackPoint.getLatitude(), 0.01);
Assert.assertEquals(time, trackPoint.getTime(), 0.01);
Assert.assertEquals(speed, trackPoint.getSpeed(), 0.01);
Assert.assertFalse(trackPoint.hasHeartRate());
} }
/** /**
@@ -584,7 +584,29 @@ public class CustomContentProviderUtilsTest {
Track track = TestDataUtil.createTrackAndInsert(contentProviderUtils, trackId, 10); Track track = TestDataUtil.createTrackAndInsert(contentProviderUtils, trackId, 10);
contentProviderUtils.insertTrackPoint(TestDataUtil.createTrackPoint(22), trackId); contentProviderUtils.insertTrackPoint(TestDataUtil.createTrackPoint(22), trackId);
Assert.assertEquals(11, contentProviderUtils.getTrackPointCursor(trackId, -1L, 1000, false).getCount()); Assert.assertEquals(11, contentProviderUtils.getTrackPoints(trackId).size());
}
@Test
public void testInsertAndLoadTrackPoint() {
// given
long trackId = System.currentTimeMillis();
Track track = TestDataUtil.createTrackAndInsert(contentProviderUtils, trackId, 10);
TrackPoint trackPoint = TestDataUtil.createTrackPoint(5);
trackPoint.setHeartRate_bpm(1F);
trackPoint.setCyclingCadence_rpm(2F);
trackPoint.setPower(3F);
// when
contentProviderUtils.insertTrackPoint(trackPoint, trackId);
// then
List<TrackPoint> trackPoints = contentProviderUtils.getTrackPoints(trackId);
Assert.assertTrue(trackPoints.get(10).hasHeartRate());
Assert.assertEquals(trackPoint.getHeartRate_bpm(), trackPoints.get(10).getHeartRate_bpm(), 0.01);
Assert.assertEquals(trackPoint.getCyclingCadence_rpm(), trackPoints.get(10).getCyclingCadence_rpm(), 0.01);
Assert.assertEquals(trackPoint.getPower(), trackPoints.get(10).getPower(), 0.01);
} }
/** /**
@@ -0,0 +1,92 @@
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("sensorAddress", "sensorName", 1, 1024); // 1s
SensorDataCycling.Cadence current = new SensorDataCycling.Cadence("sensorAddress", "sensorName", 2, 2048); // 2s
// when
current.compute(previous);
// then
Assert.assertEquals(60, current.getCadence_rpm(), 0.01);
}
@Test
public void compute_cadence_sameCount() {
// given
SensorDataCycling.Cadence previous = new SensorDataCycling.Cadence("sensorAddress", "sensorName", 1, 1024);
SensorDataCycling.Cadence current = new SensorDataCycling.Cadence("sensorAddress", "sensorName", 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("sensorAddress", "sensorName", 1, 1024);
SensorDataCycling.Cadence current = new SensorDataCycling.Cadence("sensorAddress", "sensorName", 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("sensorAddress", "sensorName", 1, UintUtils.UINT16_MAX - 1024);
SensorDataCycling.Cadence current = new SensorDataCycling.Cadence("sensorAddress", "sensorName", 2, 0);
// when
current.compute(previous);
// then
Assert.assertEquals(60, current.getCadence_rpm(), 0.01);
}
@Test
public void compute_cadence_rollOverCount() {
// given
SensorDataCycling.Cadence previous = new SensorDataCycling.Cadence("sensorAddress", "sensorName", UintUtils.UINT32_MAX - 1, 1024);
SensorDataCycling.Cadence current = new SensorDataCycling.Cadence("sensorAddress", "sensorName", 0, 2048);
// when
current.compute(previous);
// then
Assert.assertEquals(60, current.getCadence_rpm(), 0.01);
}
@Test
public void compute_speed_rollOverCount() {
// given
SensorDataCycling.Speed previous = new SensorDataCycling.Speed("sensorAddress", "sensorName", UintUtils.UINT16_MAX - 1, 1024);
SensorDataCycling.Speed current = new SensorDataCycling.Speed("sensorAddress", "sensorName", 0, 2048);
// when
current.compute(previous, 2000);
// then
Assert.assertEquals(2, current.getSpeed_mps(), 0.01);
}
}
@@ -33,7 +33,6 @@ import de.dennisguse.opentracks.TrackStubUtils;
import de.dennisguse.opentracks.chart.ChartPoint; import de.dennisguse.opentracks.chart.ChartPoint;
import de.dennisguse.opentracks.chart.ChartView; import de.dennisguse.opentracks.chart.ChartView;
import de.dennisguse.opentracks.content.data.TrackPoint; import de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.util.UnitConversions; import de.dennisguse.opentracks.util.UnitConversions;
/** /**
@@ -72,20 +71,13 @@ public class ChartFragmentTest {
*/ */
@Test @Test
public void testCreatePendingPoint_sensorIncorrect() { public void testCreatePendingPoint_sensorIncorrect() {
// given
TrackPoint trackPoint = TrackStubUtils.createDefaultTrackPoint(); TrackPoint trackPoint = TrackStubUtils.createDefaultTrackPoint();
// No input. // when
ChartPoint point = chartFragment.createPendingPoint(trackPoint); ChartPoint point = chartFragment.createPendingPoint(trackPoint);
Assert.assertEquals(Float.NaN, point.getHeartRate(), 0.01);
Assert.assertEquals(Float.NaN, point.getCadence(), 0.01);
Assert.assertEquals(Float.NaN, point.getPower(), 0.01);
// Input incorrect state. // then
// Creates SensorData.
SensorDataSet sensorDataSet = new SensorDataSet(SensorDataSet.DATA_UNAVAILABLE, SensorDataSet.DATA_UNAVAILABLE);
trackPoint.setSensorDataSet(sensorDataSet);
// Test.
point = chartFragment.createPendingPoint(trackPoint);
Assert.assertEquals(Float.NaN, point.getHeartRate(), 0.01); Assert.assertEquals(Float.NaN, point.getHeartRate(), 0.01);
Assert.assertEquals(Float.NaN, point.getCadence(), 0.01); Assert.assertEquals(Float.NaN, point.getCadence(), 0.01);
Assert.assertEquals(Float.NaN, point.getPower(), 0.01); Assert.assertEquals(Float.NaN, point.getPower(), 0.01);
@@ -96,20 +88,16 @@ public class ChartFragmentTest {
*/ */
@Test @Test
public void testCreatePendingPoint_sensorCorrect() { public void testCreatePendingPoint_sensorCorrect() {
// given
TrackPoint trackPoint = TrackStubUtils.createDefaultTrackPoint(); TrackPoint trackPoint = TrackStubUtils.createDefaultTrackPoint();
// No input. trackPoint.setHeartRate_bpm(100f);
trackPoint.setCyclingCadence_rpm(101f);
trackPoint.setPower(102f);
// when
ChartPoint point = chartFragment.createPendingPoint(trackPoint); ChartPoint point = chartFragment.createPendingPoint(trackPoint);
Assert.assertEquals(Float.NaN, point.getHeartRate(), 0.01);
Assert.assertEquals(Float.NaN, point.getCadence(), 0.01);
Assert.assertEquals(Float.NaN, point.getPower(), 0.01);
// Creates SensorData. // then
SensorDataSet sensorDataSet = new SensorDataSet(100, 101, 102);
// Creates SensorDataSet.
trackPoint.setSensorDataSet(sensorDataSet);
// Test.
point = chartFragment.createPendingPoint(trackPoint);
Assert.assertEquals(100.0, point.getHeartRate(), 0.01); Assert.assertEquals(100.0, point.getHeartRate(), 0.01);
Assert.assertEquals(101.0, point.getCadence(), 0.01); Assert.assertEquals(101.0, point.getCadence(), 0.01);
Assert.assertEquals(102.0, point.getPower(), 0.01); Assert.assertEquals(102.0, point.getPower(), 0.01);
@@ -331,7 +331,7 @@ public class TrackRecordingServiceTest {
addTrack(dummyTrack, isRecording); addTrack(dummyTrack, isRecording);
} }
static void insertLocation(TrackRecordingServiceInterface trackRecordingService) throws InterruptedException { private static void insertLocation(TrackRecordingServiceInterface trackRecordingService) throws InterruptedException {
insertLocation(trackRecordingService, 45.0f, 35f, 5, 10, System.currentTimeMillis()); insertLocation(trackRecordingService, 45.0f, 35f, 5, 10, System.currentTimeMillis());
} }
@@ -342,7 +342,7 @@ public class TrackRecordingServiceTest {
/** /**
* Inserts a location and waits for 200ms. * Inserts a location and waits for 200ms.
*/ */
static void insertLocation(TrackRecordingServiceInterface trackRecordingService, double latitude, double longitude, float accuracy, long speed, long time) throws InterruptedException { private static void insertLocation(TrackRecordingServiceInterface trackRecordingService, double latitude, double longitude, float accuracy, long speed, long time) throws InterruptedException {
Location location = new Location("gps"); Location location = new Location("gps");
location.setLongitude(longitude); location.setLongitude(longitude);
location.setLatitude(latitude); location.setLatitude(latitude);
@@ -27,6 +27,7 @@ import de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.content.data.TrackPointsColumns; import de.dennisguse.opentracks.content.data.TrackPointsColumns;
import de.dennisguse.opentracks.content.provider.ContentProviderUtils; import de.dennisguse.opentracks.content.provider.ContentProviderUtils;
import de.dennisguse.opentracks.content.provider.CustomContentProvider; import de.dennisguse.opentracks.content.provider.CustomContentProvider;
import de.dennisguse.opentracks.content.sensor.SensorDataHeartRate;
import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.services.sensors.BluetoothRemoteSensorManager; import de.dennisguse.opentracks.services.sensors.BluetoothRemoteSensorManager;
import de.dennisguse.opentracks.util.PreferencesUtils; import de.dennisguse.opentracks.util.PreferencesUtils;
@@ -244,13 +245,10 @@ public class TrackRecordingServiceTestLocation {
} }
@Override @Override
public boolean isSensorDataSetValid() { public SensorDataSet getSensorData() {
return true; SensorDataSet sensorDataSet = new SensorDataSet();
} sensorDataSet.set(new SensorDataHeartRate("sensorName", "sensorAddress", 5f));
return sensorDataSet;
@Override
public SensorDataSet getSensorDataSet() {
return new SensorDataSet(1, 2);
} }
}); });
@@ -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());
}
}
@@ -3,7 +3,7 @@ package de.dennisguse.opentracks.chart;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.stats.TrackStatistics; import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.stats.TrackStatisticsUpdater; import de.dennisguse.opentracks.stats.TrackStatisticsUpdater;
import de.dennisguse.opentracks.util.UnitConversions; import de.dennisguse.opentracks.util.UnitConversions;
@@ -25,7 +25,7 @@ public class ChartPoint {
this.elevation = elevation; this.elevation = elevation;
} }
public ChartPoint(@NonNull TrackStatisticsUpdater trackStatisticsUpdater, SensorDataSet sensorDataSet, boolean chartByDistance, boolean metricUnits) { public ChartPoint(@NonNull TrackStatisticsUpdater trackStatisticsUpdater, TrackPoint trackPoint, boolean chartByDistance, boolean metricUnits) {
TrackStatistics trackStatistics = trackStatisticsUpdater.getTrackStatistics(); TrackStatistics trackStatistics = trackStatisticsUpdater.getTrackStatistics();
if (chartByDistance) { if (chartByDistance) {
@@ -48,15 +48,15 @@ public class ChartPoint {
speed *= UnitConversions.KM_TO_MI; speed *= UnitConversions.KM_TO_MI;
} }
pace = speed == 0 ? 0.0 : 60.0 / speed; pace = speed == 0 ? 0.0 : 60.0 / speed;
if (sensorDataSet != null) { if (trackPoint != null) {
if (sensorDataSet.hasHeartRate()) { if (trackPoint.hasHeartRate()) {
heartRate = sensorDataSet.getHeartRate(); heartRate = trackPoint.getHeartRate_bpm();
} }
if (sensorDataSet.hasCadence()) { if (trackPoint.hasCyclingCadence()) {
cadence = sensorDataSet.getCadence(); cadence = trackPoint.getCyclingCadence_rpm();
} }
if (sensorDataSet.hasPower()) { if (trackPoint.hasPower()) {
power = sensorDataSet.getPower(); power = trackPoint.getPower();
} }
} }
} }
@@ -20,8 +20,6 @@ import android.location.LocationManager;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
/** /**
* This class extends the standard Android location with extra information. * This class extends the standard Android location with extra information.
* *
@@ -31,6 +29,10 @@ public class TrackPoint {
private final Location location; private final Location location;
private Float heartRate_bpm = null;
private Float cyclingCadence_rpm = null;
private Float power = null;
public TrackPoint() { public TrackPoint() {
this.location = new Location(""); this.location = new Location("");
} }
@@ -39,17 +41,13 @@ public class TrackPoint {
this.location = location; this.location = location;
} }
public TrackPoint(@NonNull Location location, SensorDataSet sensorDataSet) { public TrackPoint(@NonNull TrackPoint trackPoint) {
this.location = location;
this.sensorDataSet = sensorDataSet;
}
public TrackPoint(@NonNull TrackPoint trackPoint, SensorDataSet sensorDataSet) {
this.location = trackPoint.getLocation(); this.location = trackPoint.getLocation();
this.sensorDataSet = sensorDataSet;
}
private SensorDataSet sensorDataSet = null; this.heartRate_bpm = trackPoint.getHeartRate_bpm();
this.cyclingCadence_rpm = trackPoint.getCyclingCadence_rpm();
this.power = trackPoint.getPower();
}
public TrackPoint(double latitude, double longitude, Double altitude, long time) { public TrackPoint(double latitude, double longitude, Double altitude, long time) {
location = new Location(LocationManager.GPS_PROVIDER); location = new Location(LocationManager.GPS_PROVIDER);
@@ -90,14 +88,6 @@ public class TrackPoint {
return location; return location;
} }
public SensorDataSet getSensorDataSet() {
return sensorDataSet;
}
public void setSensorDataSet(SensorDataSet sensorDataSet) {
this.sensorDataSet = sensorDataSet;
}
public double getLatitude() { public double getLatitude() {
return location.getLatitude(); return location.getLatitude();
} }
@@ -182,6 +172,47 @@ public class TrackPoint {
return location.bearingTo(dest); return location.bearingTo(dest);
} }
// Sensor data
public boolean hasSensorData() {
return hasHeartRate() || hasCyclingCadence() || hasPower();
}
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_rpm != null;
}
public Float getCyclingCadence_rpm() {
return cyclingCadence_rpm;
}
public void setCyclingCadence_rpm(Float cyclingCadence_rpm) {
this.cyclingCadence_rpm = cyclingCadence_rpm;
}
public boolean hasPower() {
return power != null;
}
public Float getPower() {
return power;
}
public void setPower(Float power) {
this.power = power;
}
@NonNull @NonNull
@Override @Override
public String toString() { public String toString() {
@@ -40,7 +40,6 @@ import de.dennisguse.opentracks.content.data.TrackPointsColumns;
import de.dennisguse.opentracks.content.data.TracksColumns; import de.dennisguse.opentracks.content.data.TracksColumns;
import de.dennisguse.opentracks.content.data.Waypoint; import de.dennisguse.opentracks.content.data.Waypoint;
import de.dennisguse.opentracks.content.data.WaypointsColumns; import de.dennisguse.opentracks.content.data.WaypointsColumns;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.stats.TrackStatistics; import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.util.FileUtils; import de.dennisguse.opentracks.util.FileUtils;
@@ -621,11 +620,15 @@ public class ContentProviderUtils {
trackPoint.setBearing(cursor.getFloat(indexes.bearingIndex)); trackPoint.setBearing(cursor.getFloat(indexes.bearingIndex));
} }
float heartRate = cursor.isNull(indexes.sensorHeartRateIndex) ? SensorDataSet.DATA_UNAVAILABLE : cursor.getFloat(indexes.sensorHeartRateIndex); if (!cursor.isNull(indexes.sensorHeartRateIndex)) {
float cadence = cursor.isNull(indexes.sensorCadenceIndex) ? SensorDataSet.DATA_UNAVAILABLE : cursor.getFloat(indexes.sensorCadenceIndex); trackPoint.setHeartRate_bpm(cursor.getFloat(indexes.sensorHeartRateIndex));
float power = cursor.isNull(indexes.sensorPowerIndex) ? SensorDataSet.DATA_UNAVAILABLE : cursor.getFloat(indexes.sensorPowerIndex); }
if (!cursor.isNull(indexes.sensorCadenceIndex)) {
trackPoint.setSensorDataSet(new SensorDataSet(heartRate, cadence, power, SensorDataSet.DATA_UNAVAILABLE, trackPoint.getTime())); trackPoint.setCyclingCadence_rpm(cursor.getFloat(indexes.sensorCadenceIndex));
}
if (!cursor.isNull(indexes.sensorPowerIndex)) {
trackPoint.setPower(cursor.getFloat(indexes.sensorPowerIndex));
}
return trackPoint; return trackPoint;
} }
@@ -805,16 +808,14 @@ public class ContentProviderUtils {
values.put(TrackPointsColumns.BEARING, trackPoint.getBearing()); values.put(TrackPointsColumns.BEARING, trackPoint.getBearing());
} }
//SensorData if (trackPoint.hasHeartRate()) {
SensorDataSet sensorDataSet = trackPoint.getSensorDataSet(); values.put(TrackPointsColumns.SENSOR_HEARTRATE, trackPoint.getHeartRate_bpm());
if (sensorDataSet != null && sensorDataSet.hasHeartRate()) {
values.put(TrackPointsColumns.SENSOR_HEARTRATE, trackPoint.getSensorDataSet().getHeartRate());
} }
if (sensorDataSet != null && sensorDataSet.hasCadence()) { if (trackPoint.hasCyclingCadence()) {
values.put(TrackPointsColumns.SENSOR_CADENCE, trackPoint.getSensorDataSet().getCadence()); values.put(TrackPointsColumns.SENSOR_CADENCE, trackPoint.getCyclingCadence_rpm());
} }
if (sensorDataSet != null && sensorDataSet.hasPower()) { if (trackPoint.hasPower()) {
values.put(TrackPointsColumns.SENSOR_POWER, trackPoint.getSensorDataSet().getPower()); values.put(TrackPointsColumns.SENSOR_POWER, trackPoint.getPower());
} }
return values; return values;
} }
@@ -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();
}
}
@@ -0,0 +1,175 @@
package de.dennisguse.opentracks.content.sensor;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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.
* <p>
* 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());
}
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()) {
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();
}
}
public static class Speed extends SensorData {
private int wheelRevolutionsCount; // UINT16
private int wheelRevolutionsTime; // UINT16; 1/1024s
private float speed_mps = 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_mps() {
return !Float.isNaN(speed_mps);
}
public float getSpeed_mps() {
return speed_mps;
}
public void compute(Speed previous, int wheel_circumference_mm) {
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_mps = INVALID_VALUE_FLOAT;
} else {
long wheelDiff = UintUtils.diff(wheelRevolutionsCount, previous.wheelRevolutionsCount, UintUtils.UINT16_MAX);
double timeDiff_s = timeDiff_ms * UnitConversions.MS_TO_S;
speed_mps = (float) (wheelDiff * wheel_circumference_mm * UnitConversions.MM_TO_M / timeDiff_s);
}
}
}
@NonNull
@Override
public String toString() {
return "speed=" + getSpeed_mps() + "_" + 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;
}
}
}
@@ -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;
}
}
@@ -2,101 +2,85 @@ package de.dennisguse.opentracks.content.sensor;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import de.dennisguse.opentracks.content.data.TrackPoint;
/**
*
*/
public final class SensorDataSet { 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 SensorDataCycling.Cadence cyclingCadence;
private String sensorName;
private String sensorAddress;
private float heartRate;
private float cadence;
private float power;
private float batteryLevel;
private long time;
public SensorDataSet(float heartRate, float cadence, float power, float batteryLevel, long time) { private SensorDataCycling.Speed cyclingSpeed;
this.heartRate = heartRate;
this.cadence = cadence; public SensorDataSet() {
this.power = power;
this.batteryLevel = batteryLevel;
this.time = time;
} }
public SensorDataSet(float heartRate, float cadence, float power, float batteryLevel) { public SensorDataHeartRate getHeartRate() {
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() {
return heartRate; return heartRate;
} }
public boolean hasCadence() { public SensorDataCycling.Cadence getCyclingCadence() {
return !Float.isNaN(cadence); return cyclingCadence;
} }
public float getCadence() { public SensorDataCycling.Speed getCyclingSpeed() {
return cadence; return cyclingSpeed;
} }
public boolean hasPower() { public void set(SensorData data) {
return !Float.isNaN(power); 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() { public void clear() {
return power; this.heartRate = null;
this.cyclingCadence = null;
this.cyclingSpeed = null;
} }
public long getTime() { public void fillTrackPoint(TrackPoint trackPoint) {
return time; if (heartRate != null) {
} trackPoint.setHeartRate_bpm(heartRate.getHeartRate_bpm());
}
/** if (cyclingCadence != null && cyclingCadence.hasCadence_rpm()) {
* Is the data recent considering the current time. trackPoint.setCyclingCadence_rpm(cyclingCadence.getCadence_rpm());
* }
* @param maxAge the maximal age in milliseconds.
*/
public boolean isRecent(long maxAge) {
return time + maxAge > System.currentTimeMillis();
}
public boolean hasBatteryLevel() { if (cyclingSpeed != null && cyclingSpeed.hasSpeed_mps()) {
return !Float.isNaN(batteryLevel); trackPoint.setSpeed(cyclingSpeed.getSpeed_mps());
} }
public float getBatteryLevel() {
return batteryLevel;
}
public String getSensorName() {
return sensorName;
}
public String getSensorAddress() {
return sensorAddress;
} }
@NonNull @NonNull
@Override @Override
public String toString() { public String toString() {
return "time=" + getTime() + " sensor=" + getSensorAddress() + " heart=" + getHeartRate(); return (getHeartRate() != null ? "" + getHeartRate() : "")
+ (getCyclingCadence() != null ? " " + getCyclingCadence() : "")
+ (getCyclingSpeed() != null ? " " + getCyclingSpeed() : "");
} }
} }
@@ -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 "";
}
}
}
@@ -313,7 +313,7 @@ public class ChartFragment extends Fragment implements TrackDataListener {
@VisibleForTesting @VisibleForTesting
ChartPoint createPendingPoint(@NonNull TrackPoint trackPoint) { ChartPoint createPendingPoint(@NonNull TrackPoint trackPoint) {
trackStatisticsUpdater.addTrackPoint(trackPoint, recordingDistanceInterval); trackStatisticsUpdater.addTrackPoint(trackPoint, recordingDistanceInterval);
return new ChartPoint(trackStatisticsUpdater, trackPoint.getSensorDataSet(), chartByDistance, chartView.getMetricUnits()); return new ChartPoint(trackStatisticsUpdater, trackPoint, chartByDistance, chartView.getMetricUnits());
} }
@VisibleForTesting @VisibleForTesting
@@ -43,7 +43,6 @@ import de.dennisguse.opentracks.content.data.Waypoint;
import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection; import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.services.TrackRecordingServiceInterface; import de.dennisguse.opentracks.services.TrackRecordingServiceInterface;
import de.dennisguse.opentracks.services.sensors.BluetoothRemoteSensorManager;
import de.dennisguse.opentracks.stats.TrackStatistics; import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.util.LocationUtils; import de.dennisguse.opentracks.util.LocationUtils;
import de.dennisguse.opentracks.util.PreferencesUtils; import de.dennisguse.opentracks.util.PreferencesUtils;
@@ -114,9 +113,12 @@ public class StatsFragment extends Fragment implements TrackDataListener {
} }
/* Views */ /* Views */
private View sensorContainerView; private View heartRateContainer;
private TextView heartRateValueView; private TextView heartRateValueView;
private TextView heartRateSensorView; private TextView heartRateSensorView;
private View cadenceContainer;
private TextView cadenceValueView;
private TextView cadenceSensorView;
private TextView totalTimeValueView; private TextView totalTimeValueView;
private final Runnable updateUIeachSecond = new Runnable() { private final Runnable updateUIeachSecond = new Runnable() {
@@ -162,10 +164,14 @@ public class StatsFragment extends Fragment implements TrackDataListener {
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, 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); heartRateValueView = view.findViewById(R.id.stats_sensor_heart_rate_value);
heartRateSensorView = view.findViewById(R.id.stats_sensor_heart_rate_sensor_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); totalTimeValueView = view.findViewById(R.id.stats_total_time_value);
distanceValue = view.findViewById(R.id.stats_distance_value); distanceValue = view.findViewById(R.id.stats_distance_value);
@@ -269,9 +275,12 @@ public class StatsFragment extends Fragment implements TrackDataListener {
@Override @Override
public void onDestroyView() { public void onDestroyView() {
super.onDestroyView(); super.onDestroyView();
sensorContainerView = null; heartRateContainer = null;
heartRateValueView = null; heartRateValueView = null;
heartRateSensorView = null; heartRateSensorView = null;
cadenceContainer = null;
cadenceValueView = null;
cadenceSensorView = null;
totalTimeValueView = null; totalTimeValueView = null;
@@ -426,7 +435,7 @@ public class StatsFragment extends Fragment implements TrackDataListener {
} }
/** /**
* Tries to fetch most recent {@link SensorDataSet} {@link de.dennisguse.opentracks.services.sensors.BluetoothRemoteSensorManager}. * Tries to fetch most recent {@link SensorDataSet} from {@link de.dennisguse.opentracks.services.TrackRecordingService}.
*/ */
private void updateSensorDataUI() { private void updateSensorDataUI() {
TrackRecordingServiceInterface trackRecordingService = trackRecordingServiceConnection.getServiceIfBound(); TrackRecordingServiceInterface trackRecordingService = trackRecordingServiceConnection.getServiceIfBound();
@@ -435,33 +444,65 @@ public class StatsFragment extends Fragment implements TrackDataListener {
if (trackRecordingService == null) { if (trackRecordingService == null) {
Log.d(STATS_FRAGMENT_TAG, "Cannot get the track recording service."); Log.d(STATS_FRAGMENT_TAG, "Cannot get the track recording service.");
} else { } else {
//TODO sensorState = trackRecordingService.getSensorState();
sensorDataSet = trackRecordingService.getSensorData(); sensorDataSet = trackRecordingService.getSensorData();
} }
setHeartRateSensorData(sensorDataSet, isSelectedTrackRecording()); setHeartRateSensorData(sensorDataSet, isSelectedTrackRecording());
setCadenceSensorData(sensorDataSet, isSelectedTrackRecording());
setSpeedSensorData(sensorDataSet, isSelectedTrackRecording());
} }
private void setHeartRateSensorData(SensorDataSet sensorDataSet, boolean isRecording) { private void setHeartRateSensorData(SensorDataSet sensorDataSet, boolean isRecording) {
// heart rate
int isVisible = View.VISIBLE; int isVisible = View.VISIBLE;
if (!isRecording || PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(getContext())) { if (!isRecording || PreferencesUtils.isBluetoothHeartRateSensorAddressNone(getContext())) {
isVisible = View.INVISIBLE; isVisible = View.INVISIBLE;
} }
sensorContainerView.setVisibility(isVisible); heartRateContainer.setVisibility(isVisible);
if (isRecording) { 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); String sensorName = getContext().getString(R.string.value_unknown);
if (sensorDataSet != null && sensorDataSet.isRecent(BluetoothRemoteSensorManager.MAX_SENSOR_DATE_SET_AGE_MS)) { if (sensorDataSet != null && sensorDataSet.getHeartRate() != null) {
sensorName = sensorDataSet.getSensorName(); sensorName = sensorDataSet.getHeartRate().getSensorName();
if (sensorDataSet.hasHeartRate()) { if (sensorDataSet.getHeartRate().hasHeartRate_bpm() && sensorDataSet.getHeartRate().isRecent()) {
heartRate = StringUtils.formatDecimal(sensorDataSet.getHeartRate(), 0); sensorValue = StringUtils.formatDecimal(sensorDataSet.getHeartRate().getHeartRate_bpm(), 0);
} }
} }
heartRateSensorView.setText(sensorName); heartRateSensorView.setText(sensorName);
heartRateValueView.setText(heartRate); heartRateValueView.setText(sensorValue);
}
}
private void setCadenceSensorData(SensorDataSet sensorDataSet, boolean isRecording) {
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);
}
}
private void setSpeedSensorData(SensorDataSet sensorDataSet, boolean isRecording) {
if (isRecording) {
if (sensorDataSet != null && sensorDataSet.getCyclingSpeed() != null) {
if (sensorDataSet.getCyclingSpeed().hasSpeed_mps() && sensorDataSet.getCyclingSpeed().isRecent()) {
setSpeed(sensorDataSet.getCyclingSpeed().getSpeed_mps());
}
}
} }
} }
@@ -568,12 +609,8 @@ public class StatsFragment extends Fragment implements TrackDataListener {
// Set speed/pace // Set speed/pace
speedContainer.setVisibility(isRecording ? View.VISIBLE : View.INVISIBLE); speedContainer.setVisibility(isRecording ? View.VISIBLE : View.INVISIBLE);
if (isRecording) { if (isRecording) {
speedLabel.setText(reportSpeed ? R.string.stats_speed : R.string.stats_pace);
double speed = lastTrackPoint != null && lastTrackPoint.hasSpeed() ? lastTrackPoint.getSpeed() : Double.NaN; double speed = lastTrackPoint != null && lastTrackPoint.hasSpeed() ? lastTrackPoint.getSpeed() : Double.NaN;
Pair<String, String> parts = StringUtils.getSpeedParts(getContext(), speed, metricUnits, reportSpeed); setSpeed(speed);
speedValue.setText(parts.first);
speedUnit.setText(parts.second);
} }
// Set elevation // Set elevation
@@ -603,4 +640,15 @@ public class StatsFragment extends Fragment implements TrackDataListener {
longitudeValue.setText(longitudeText); longitudeValue.setText(longitudeText);
} }
} }
private void setSpeed(double speed) {
boolean metricUnits = PreferencesUtils.isMetricUnits(getContext());
boolean reportSpeed = PreferencesUtils.isReportSpeed(getContext());
speedLabel.setText(reportSpeed ? R.string.stats_speed : R.string.stats_pace);
Pair<String, String> parts = StringUtils.getSpeedParts(getContext(), speed, metricUnits, reportSpeed);
speedValue.setText(parts.first);
speedUnit.setText(parts.second);
}
} }
@@ -18,7 +18,6 @@ package de.dennisguse.opentracks.io.file.exporter;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.location.Location; import android.location.Location;
import android.net.Uri;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.PrintWriter; import java.io.PrintWriter;
@@ -31,7 +30,6 @@ import de.dennisguse.opentracks.content.data.Track;
import de.dennisguse.opentracks.content.data.TrackPoint; import de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.content.data.Waypoint; import de.dennisguse.opentracks.content.data.Waypoint;
import de.dennisguse.opentracks.content.provider.ContentProviderUtils; 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.FileUtils;
import de.dennisguse.opentracks.util.StringUtils; import de.dennisguse.opentracks.util.StringUtils;
@@ -73,11 +71,11 @@ public class KmlTrackWriter implements TrackWriter {
private TrackPoint startTrackPoint; private TrackPoint startTrackPoint;
/** /**
* @param context the context * @param context the context
* @param hasMultipleTracks should encode multiple tracks into one file? * @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 exportTrackDetail should detailed information about the track be exported (e.g., title, description, waypoints, timing)?
* @param exportSensorData should {@link SensorDataSet} be exported? * @param exportSensorData should {@link TrackPointSensorDataSet} be exported?
* @param exportPhotos should pictures be exported (if true: exports to KMZ)? * @param exportPhotos should pictures be exported (if true: exports to KMZ)?
*/ */
public KmlTrackWriter(Context context, boolean hasMultipleTracks, boolean exportTrackDetail, boolean exportSensorData, boolean exportPhotos) { public KmlTrackWriter(Context context, boolean hasMultipleTracks, boolean exportTrackDetail, boolean exportSensorData, boolean exportPhotos) {
this.context = context; this.context = context;
@@ -269,17 +267,14 @@ public class KmlTrackWriter implements TrackWriter {
printWriter.println("<gx:coord>" + getCoordinates(trackPoint.getLocation(), " ") + "</gx:coord>"); printWriter.println("<gx:coord>" + getCoordinates(trackPoint.getLocation(), " ") + "</gx:coord>");
if (exportSensorData) { if (exportSensorData) {
SensorDataSet sensorDataSet = trackPoint.getSensorDataSet(); if (trackPoint.hasHeartRate()) {
if (sensorDataSet != null) { heartRateList.add(trackPoint.getHeartRate_bpm());
if (sensorDataSet.hasHeartRate()) { }
heartRateList.add(sensorDataSet.getHeartRate()); if (trackPoint.hasCyclingCadence()) {
} cadenceList.add(trackPoint.getCyclingCadence_rpm());
if (sensorDataSet.hasCadence()) { }
cadenceList.add(sensorDataSet.getCadence()); if (trackPoint.hasPower()) {
} powerList.add(trackPoint.getPower());
if (sensorDataSet.hasPower()) {
powerList.add(sensorDataSet.getPower());
}
} }
} }
} }
@@ -453,7 +448,7 @@ public class KmlTrackWriter implements TrackWriter {
/** /**
* Writes a sensor style. * Writes a sensor style.
* *
* @param name the name of the sesnor * @param name the name of the sesnor
* @param sensorType the sensor display name * @param sensorType the sensor display name
*/ */
private void writeSensorStyle(String name, String sensorType) { private void writeSensorStyle(String name, String sensorType) {
@@ -28,7 +28,6 @@ import java.util.ArrayList;
import de.dennisguse.opentracks.content.data.TrackPoint; import de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.content.provider.ContentProviderUtils; import de.dennisguse.opentracks.content.provider.ContentProviderUtils;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.io.file.exporter.KmlTrackWriter; import de.dennisguse.opentracks.io.file.exporter.KmlTrackWriter;
/** /**
@@ -223,30 +222,17 @@ public class KmlFileTrackImporter extends AbstractFileTrackImporter {
for (int i = 0; i < trackPoints.size(); i++) { for (int i = 0; i < trackPoints.size(); i++) {
TrackPoint trackPoint = trackPoints.get(i); TrackPoint trackPoint = trackPoints.get(i);
boolean hasSensorData = false;
float heartrate = SensorDataSet.DATA_UNAVAILABLE;
float cadence = SensorDataSet.DATA_UNAVAILABLE;
float power = SensorDataSet.DATA_UNAVAILABLE;
if (i < heartRateList.size()) { if (i < heartRateList.size()) {
heartrate = heartRateList.get(i); trackPoint.setHeartRate_bpm(heartRateList.get(i));
hasSensorData = true;
} }
if (i < cadenceList.size()) { if (i < cadenceList.size()) {
cadence = cadenceList.get(i); trackPoint.setCyclingCadence_rpm(cadenceList.get(i));
hasSensorData = true;
} }
if (i < powerList.size()) { if (i < powerList.size()) {
power = powerList.get(i); trackPoint.setPower(cadenceList.get(i));
hasSensorData = true;
} }
if (!hasSensorData) { insertTrackPoint(trackPoint);
insertTrackPoint(trackPoint);
} else {
TrackPoint sensorDataSetLocation = new TrackPoint(trackPoint, new SensorDataSet(heartrate, cadence, power, SensorDataSet.DATA_UNAVAILABLE, trackPoint.getTime()));
insertTrackPoint(sensorDataSetLocation);
}
} }
} }
@@ -575,7 +575,9 @@ public class TrackRecordingService extends Service {
return; return;
} }
TrackPoint trackPoint = new TrackPoint(location, getSensorDataSet()); TrackPoint trackPoint = new TrackPoint(location);
fillWithSensorDataSet(trackPoint);
notificationManager.updateTrackPoint(this, trackPoint, recordingGpsAccuracy); notificationManager.updateTrackPoint(this, trackPoint, recordingGpsAccuracy);
if (!TrackPointUtils.fulfillsAccuracy(trackPoint, recordingGpsAccuracy)) { if (!TrackPointUtils.fulfillsAccuracy(trackPoint, recordingGpsAccuracy)) {
@@ -626,7 +628,7 @@ public class TrackRecordingService extends Service {
return; return;
} }
if (trackPoint.getSensorDataSet() != null || distanceToLastTrackLocation >= recordingDistanceInterval) { if (trackPoint.hasSensorData() || distanceToLastTrackLocation >= recordingDistanceInterval) {
insertTrackPointIfNewer(track, lastTrackPoint); insertTrackPointIfNewer(track, lastTrackPoint);
insertTrackPoint(track, trackPoint); insertTrackPoint(track, trackPoint);
isIdle = false; isIdle = false;
@@ -708,10 +710,18 @@ public class TrackRecordingService extends Service {
} }
SensorDataSet getSensorDataSet() { SensorDataSet getSensorDataSet() {
if (remoteSensorManager == null || !remoteSensorManager.isEnabled() || !remoteSensorManager.isSensorDataSetValid()) { if (remoteSensorManager == null || !remoteSensorManager.isEnabled()) {
return null; return null;
} }
return remoteSensorManager.getSensorDataSet();
return remoteSensorManager.getSensorData();
}
void fillWithSensorDataSet(TrackPoint trackPoint) {
SensorDataSet sensorData = getSensorDataSet();
if (sensorData != null) {
sensorData.fillTrackPoint(trackPoint);
}
} }
private void registerLocationListener() { private void registerLocationListener() {
@@ -95,8 +95,13 @@ public interface TrackRecordingServiceInterface {
long insertWaypoint(String name, String category, String description, String photoUrl); 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. * Inserts a location in the current recording track.
* <p> * <p>
* When recording a track, GPS locations are automatically inserted. * When recording a track, GPS locations are automatically inserted.
@@ -107,13 +112,6 @@ public interface TrackRecordingServiceInterface {
@VisibleForTesting @VisibleForTesting
void insertLocation(Location location); 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}. * Disables processing of location updates from {@link android.location.LocationManager}.
*/ */
@@ -24,40 +24,30 @@ import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService; import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothProfile; import android.bluetooth.BluetoothProfile;
import android.content.Context; import android.content.Context;
import android.os.Handler;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import java.util.UUID; import java.util.UUID;
import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.content.sensor.SensorData;
import de.dennisguse.opentracks.content.sensor.SensorState; import de.dennisguse.opentracks.content.sensor.SensorDataCycling;
import de.dennisguse.opentracks.content.sensor.SensorDataHeartRate;
import de.dennisguse.opentracks.util.BluetoothUtils; 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 { public abstract class BluetoothConnectionManager {
// Message types sent to handler
static final int MESSAGE_CONNECTING = 1;
static final int MESSAGE_CONNECTED = 2;
static final int MESSAGE_READ = 3;
static final int MESSAGE_DISCONNECTED = 4;
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);
private static final String TAG = BluetoothConnectionManager.class.getSimpleName(); private static final String TAG = BluetoothConnectionManager.class.getSimpleName();
private final Context context; private final SensorDataObserver observer;
private final Handler handler;
private SensorState sensorState;
private final UUID serviceUUUID;
private final UUID measurementUUID;
private BluetoothGatt bluetoothGatt; private BluetoothGatt bluetoothGatt;
private final BluetoothDevice bluetoothDevice;
private final BluetoothGattCallback connectCallback = new BluetoothGattCallback() { private final BluetoothGattCallback connectCallback = new BluetoothGattCallback() {
@Override @Override
@@ -65,86 +55,70 @@ public class BluetoothConnectionManager {
switch (newState) { switch (newState) {
case BluetoothProfile.STATE_CONNECTING: case BluetoothProfile.STATE_CONNECTING:
Log.d(TAG, "Connecting to sensor: " + gatt.getDevice()); Log.d(TAG, "Connecting to sensor: " + gatt.getDevice());
setState(SensorState.CONNECTING);
handler.obtainMessage(MESSAGE_CONNECTING, gatt.getDevice().getName()).sendToTarget();
case BluetoothProfile.STATE_CONNECTED: case BluetoothProfile.STATE_CONNECTED:
Log.d(TAG, "Connected to sensor: " + gatt.getDevice()); Log.d(TAG, "Connected to sensor: " + gatt.getDevice());
setState(SensorState.CONNECTED);
gatt.discoverServices(); gatt.discoverServices();
handler.obtainMessage(MESSAGE_CONNECTED, gatt.getDevice().getName()).sendToTarget();
break; break;
case BluetoothProfile.STATE_DISCONNECTING: case BluetoothProfile.STATE_DISCONNECTING:
Log.d(TAG, "Disconnecting from sensor: " + gatt.getDevice()); Log.d(TAG, "Disconnecting from sensor: " + gatt.getDevice());
setState(SensorState.DISCONNECTING);
case BluetoothProfile.STATE_DISCONNECTED: case BluetoothProfile.STATE_DISCONNECTED:
Log.d(TAG, "Disconnected from sensor: " + gatt.getDevice()); Log.d(TAG, "Disconnected from sensor: " + gatt.getDevice());
setState(SensorState.DISCONNECTED);
handler.obtainMessage(MESSAGE_DISCONNECTED, gatt.getDevice().getName()).sendToTarget();
} }
} }
@Override @Override
public void onServicesDiscovered(@NonNull BluetoothGatt gatt, int status) { 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) { 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; return;
} }
BluetoothGattCharacteristic characteristic = service.getCharacteristic(measurementUUID);
BluetoothGattCharacteristic characteristic = service.getCharacteristic(HEART_RATE_MEASUREMENT_CHAR_UUID);
if (characteristic == null) { 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; return;
} }
gatt.setCharacteristicNotification(characteristic, true); 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); descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(descriptor); gatt.writeDescriptor(descriptor);
} }
@Override @Override
public void onCharacteristicChanged(BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic) { public void onCharacteristicChanged(BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic) {
int heartRate = BluetoothUtils.parseHeartRate(characteristic); String sensorName = gatt.getDevice().getName();
String deviceName = gatt.getDevice().getName(); Log.d(TAG, "Received data from " + sensorName);
Log.d(TAG, "Received heart beat rate " + deviceName + ": " + heartRate); SensorData sensorData = parsePayload(sensorName, gatt.getDevice().getAddress(), characteristic);
SensorDataSet sensorDataSet = new SensorDataSet(heartRate, deviceName, gatt.getDevice().getAddress()); if (sensorData != null) {
handler.obtainMessage(MESSAGE_READ, sensorDataSet).sendToTarget(); observer.onChanged(sensorData);
}
} }
}; };
/** BluetoothConnectionManager(UUID serviceUUUID, UUID measurementUUID, SensorDataObserver observer) {
* Constructor. this.serviceUUUID = serviceUUUID;
* this.measurementUUID = measurementUUID;
* @param handler a handler for sending messages back to the UI activity this.observer = observer;
*/
BluetoothConnectionManager(@NonNull Context context, @NonNull BluetoothDevice bluetoothDevice, @NonNull Handler handler) {
this.context = context;
this.bluetoothDevice = bluetoothDevice;
this.handler = handler;
this.sensorState = SensorState.NONE;
} }
public synchronized void connect() { synchronized void connect(Context context, @NonNull BluetoothDevice device) {
if (bluetoothGatt != null) { if (bluetoothGatt != null) {
Log.w(TAG, "Already connected; ignoring."); 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) { if (bluetoothGatt == null) {
Log.w(TAG, "Cannot disconnect if not connected."); Log.w(TAG, "Cannot disconnect if not connected.");
return; return;
@@ -153,15 +127,78 @@ public class BluetoothConnectionManager {
bluetoothGatt = null; bluetoothGatt = null;
} }
public synchronized boolean isSameBluetoothDevice(String address) { synchronized boolean isSameBluetoothDevice(String address) {
return this.bluetoothDevice.getAddress().equals(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) { public static class CyclingCadence extends BluetoothConnectionManager {
this.sensorState = sensorState;
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 {
CyclingSpeed(SensorDataObserver observer) {
super(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, BluetoothUtils.CYCLING_SPPED_CADENCE_MEASUREMENT_CHAR_UUID, observer);
}
@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;
}
}
interface SensorDataObserver {
void onChanged(SensorData sensorData);
} }
} }
@@ -20,90 +20,71 @@ import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothDevice;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log; import android.util.Log;
import android.widget.Toast;
import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.content.sensor.SensorDataCycling;
import de.dennisguse.opentracks.content.sensor.SensorDataSet; import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.util.BluetoothUtils; import de.dennisguse.opentracks.util.BluetoothUtils;
import de.dennisguse.opentracks.util.PreferencesUtils; import de.dennisguse.opentracks.util.PreferencesUtils;
import de.dennisguse.opentracks.util.UnitConversions;
/** /**
* Bluetooth LE sensor manager. * Bluetooth LE sensor manager: manages connections to Bluetooth LE sensors.
* Should only be instantiated once! *
* 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 * @author Sandor Dornbush
*/ */
public class BluetoothRemoteSensorManager { 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 static final BluetoothAdapter bluetoothAdapter = BluetoothUtils.getDefaultBluetoothAdapter(TAG);
private final Context context; private final Context context;
private final SharedPreferences sharedPreferences; private final SharedPreferences sharedPreferences;
// Handler that gets information back from the bluetoothConnectionManager private boolean started = false;
private final Handler messageHandler = new Handler(Looper.getMainLooper()) {
@Override private final BluetoothConnectionManager.HeartRate heartRate = new BluetoothConnectionManager.HeartRate(this);
public void handleMessage(Message message) { private final BluetoothConnectionManager.CyclingCadence cyclingCadence = new BluetoothConnectionManager.CyclingCadence(this);
String toastMessage; private final BluetoothConnectionManager.CyclingSpeed cyclingSpeed = new BluetoothConnectionManager.CyclingSpeed(this);
switch (message.what) {
case BluetoothConnectionManager.MESSAGE_CONNECTING: private final SensorDataSet sensorDataSet = new SensorDataSet();
//Ignore for now.
toastMessage = context.getString(R.string.settings_sensor_connecting, message.obj);
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
break;
case BluetoothConnectionManager.MESSAGE_CONNECTED:
toastMessage = context.getString(R.string.settings_sensor_connected, message.obj);
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
break;
case BluetoothConnectionManager.MESSAGE_READ:
if (!(message.obj instanceof SensorDataSet)) {
Log.e(TAG, "Received message did not contain a SensorDataSet.");
sensorDataSet = null;
} else {
sensorDataSet = (SensorDataSet) message.obj;
}
break;
case BluetoothConnectionManager.MESSAGE_DISCONNECTED:
toastMessage = context.getString(R.string.settings_sensor_disconnected, message.obj);
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
break;
default:
Log.e(TAG, "Got an undefined case. Please check.");
break;
}
}
};
private final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = new SharedPreferences.OnSharedPreferenceChangeListener() { private final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = new SharedPreferences.OnSharedPreferenceChangeListener() {
@Override @Override
public void onSharedPreferenceChanged(SharedPreferences preferences, String key) { public void onSharedPreferenceChanged(SharedPreferences preferences, String key) {
if (bluetoothConnectionManager != null && PreferencesUtils.isKey(context, R.string.settings_sensor_bluetooth_heart_rate_key, key)) { if (!started) return;
if (PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(context)) {
stop();
return;
}
String address = PreferencesUtils.getBluetoothHeartRateSensorAddress(context);
if (bluetoothConnectionManager.isSameBluetoothDevice(address)) {
return;
}
disconnect(); if (PreferencesUtils.isKey(context, R.string.settings_sensor_bluetooth_heart_rate_key, key)) {
startCurrentSensor(); String address = PreferencesUtils.getBluetoothHeartRateSensorAddress(context);
connect(heartRate, address);
}
if (PreferencesUtils.isKey(context, R.string.settings_sensor_bluetooth_cycling_cadence_key, key)) {
String address = PreferencesUtils.getBluetoothCyclingCadenceSensorAddress(context);
connect(cyclingCadence, address);
}
if (PreferencesUtils.isKey(context, R.string.settings_sensor_bluetooth_cycling_cadence_key, key)) {
String address = PreferencesUtils.getBluetoothCyclingSpeedSensorAddress(context);
connect(cyclingSpeed, address);
} }
} }
}; };
private SensorDataSet sensorDataSet = null;
private BluetoothConnectionManager bluetoothConnectionManager;
/** /**
* @param context the context * @param context the context
*/ */
@@ -113,67 +94,76 @@ public class BluetoothRemoteSensorManager {
} }
public void start() { public void start() {
started = true;
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
startCurrentSensor();
//Trigger connection startup
sharedPreferenceChangeListener.onSharedPreferenceChanged(null, null);
} }
public void stop() { public synchronized void stop() {
disconnect(); // Disconnecting
heartRate.disconnect();
cyclingCadence.disconnect();
sensorDataSet.clear();
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener); sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
started = false;
} }
public boolean isEnabled() { public boolean isEnabled() {
return bluetoothAdapter != null && bluetoothAdapter.isEnabled(); return bluetoothAdapter != null && bluetoothAdapter.isEnabled();
} }
public SensorDataSet getSensorDataSet() { private synchronized void connect(BluetoothConnectionManager connectionManager, String address) {
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() {
if (!isEnabled()) { if (!isEnabled()) {
Log.w(TAG, "Bluetooth not enabled."); Log.w(TAG, "Bluetooth not enabled.");
return; return;
} }
if (PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(context)) { if (PreferencesUtils.isBluetoothSensorAddressNone(context, address)) {
Log.w(TAG, "No bluetooth address."); Log.w(TAG, "No Bluetooth address.");
return; 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); Log.i(TAG, "Connecting to bluetooth address: " + address);
BluetoothDevice device;
try { try {
device = bluetoothAdapter.getRemoteDevice(address); BluetoothDevice device = bluetoothAdapter.getRemoteDevice(address);
connectionManager.connect(context, device);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
Log.w(TAG, "Unable to get remote device for: " + address, e); Log.e(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, messageHandler);
bluetoothConnectionManager.connect();
} }
private void disconnect() { public SensorDataSet getSensorData() {
if (bluetoothConnectionManager != null) { return sensorDataSet;
bluetoothConnectionManager.disconnect(); }
bluetoothConnectionManager = null;
@Override
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(), PreferencesUtils.getWheelCircumference(context));
}
sensorDataSet.set(sensorData);
} }
} }
@@ -13,6 +13,9 @@ import androidx.preference.PreferenceFragmentCompat;
import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.fragments.ChooseActivityTypeDialogFragment; 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.BluetoothUtils;
import de.dennisguse.opentracks.util.HackUtils; import de.dennisguse.opentracks.util.HackUtils;
import de.dennisguse.opentracks.util.PreferencesUtils; import de.dennisguse.opentracks.util.PreferencesUtils;
@@ -111,8 +114,10 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit
} else if (preference instanceof ActivityTypePreference) { } else if (preference instanceof ActivityTypePreference) {
activityPreferenceDialog = ActivityTypePreference.ActivityPreferenceDialog.newInstance(preference.getKey()); activityPreferenceDialog = ActivityTypePreference.ActivityPreferenceDialog.newInstance(preference.getKey());
dialogFragment = activityPreferenceDialog; dialogFragment = activityPreferenceDialog;
} else if (preference instanceof BluetoothLePreference) { } else if (preference instanceof BluetoothLeHeartRatePreference) {
dialogFragment = BluetoothLePreference.BluetoothLePreferenceDialog.newInstance(preference.getKey()); 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) { if (dialogFragment != null) {
@@ -139,7 +144,7 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit
private void updateBluetooth() { private void updateBluetooth() {
// Disable Bluetooth preference if device does not have Bluetooth // 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)); bluetoothPreference.setVisible(BluetoothUtils.hasBluetooth(TAG));
} }
@@ -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);
}
}
@@ -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);
}
}
@@ -1,4 +1,4 @@
package de.dennisguse.opentracks.settings; package de.dennisguse.opentracks.settings.bluetooth;
import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothAdapter;
import android.bluetooth.le.BluetoothLeScanner; import android.bluetooth.le.BluetoothLeScanner;
@@ -21,8 +21,10 @@ import androidx.preference.PreferenceDialogFragmentCompat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.settings.BluetoothLeAdapter;
import de.dennisguse.opentracks.util.BluetoothUtils; import de.dennisguse.opentracks.util.BluetoothUtils;
import de.dennisguse.opentracks.util.PreferencesUtils; import de.dennisguse.opentracks.util.PreferencesUtils;
@@ -30,25 +32,27 @@ import de.dennisguse.opentracks.util.PreferencesUtils;
* Preference to select a discoverable Bluetooth LE device. * Preference to select a discoverable Bluetooth LE device.
* Based upon ListPreference. * 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; 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); 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); super(context, attrs, defStyleAttr);
} }
public BluetoothLePreference(Context context, AttributeSet attrs) { public BluetoothLeSensorPreference(Context context, AttributeSet attrs) {
super(context, attrs); super(context, attrs);
} }
public BluetoothLePreference(Context context) { public BluetoothLeSensorPreference(Context context) {
super(context); super(context);
} }
@@ -78,18 +82,20 @@ public class BluetoothLePreference extends DialogPreference {
@Override @Override
public CharSequence getSummary() { public CharSequence getSummary() {
if (getValue() == null || PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(getContext(), getValue())) { if (getValue() == null || PreferencesUtils.isBluetoothSensorAddressNone(getContext(), getValue())) {
return getContext().getString(DEVICE_NONE_RESOURCEID); return getContext().getString(DEVICE_NONE_RESOURCEID);
} }
return getValue(); return getValue();
} }
public static class BluetoothLePreferenceDialog extends PreferenceDialogFragmentCompat { public static class BluetoothLeSensorPreferenceDialog extends PreferenceDialogFragmentCompat {
private int selectedEntryIndex; private int selectedEntryIndex;
private final BluetoothLeAdapter listAdapter = new BluetoothLeAdapter(); private final BluetoothLeAdapter listAdapter = new BluetoothLeAdapter();
private UUID sensorUUID;
private BluetoothLeScanner scanner = null; private BluetoothLeScanner scanner = null;
private final ScanCallback scanCallback = new ScanCallback() { private final ScanCallback scanCallback = new ScanCallback() {
@Override @Override
@@ -116,10 +122,11 @@ public class BluetoothLePreference extends DialogPreference {
} }
}; };
static BluetoothLePreferenceDialog newInstance(String key) { public static BluetoothLeSensorPreferenceDialog newInstance(String preferenceKey, UUID sensorUUID) {
final BluetoothLePreferenceDialog fragment = new BluetoothLePreferenceDialog(); final BluetoothLeSensorPreferenceDialog fragment = new BluetoothLeSensorPreferenceDialog();
final Bundle b = new Bundle(1); 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); fragment.setArguments(b);
return fragment; return fragment;
} }
@@ -128,6 +135,9 @@ public class BluetoothLePreference extends DialogPreference {
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
sensorUUID = (UUID) getArguments().getSerializable(ARG_BLUETOOTH_UUID);
Log.i(TAG, "UUID: " + sensorUUID);
BluetoothAdapter bluetoothAdapter = BluetoothUtils.getDefaultBluetoothAdapter(TAG); BluetoothAdapter bluetoothAdapter = BluetoothUtils.getDefaultBluetoothAdapter(TAG);
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) { if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
Log.w(TAG, "Bluetooth adapter is present or not enabled."); 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); listAdapter.add(getContext().getString(DEVICE_NONE_RESOURCEID), deviceNone);
selectedEntryIndex = 0; selectedEntryIndex = 0;
BluetoothLePreference preference = (BluetoothLePreference) getPreference(); BluetoothLeSensorPreference preference = (BluetoothLeSensorPreference) getPreference();
String deviceSelected = preference.getValue(); String deviceSelected = preference.getValue();
if (deviceSelected != null && !deviceNone.equals(deviceSelected)) { if (deviceSelected != null && !deviceNone.equals(deviceSelected)) {
listAdapter.add(preference.getValue(), preference.getValue()); listAdapter.add(preference.getValue(), preference.getValue());
selectedEntryIndex = 1; 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> scanFilter = new ArrayList<>(); List<ScanFilter> scanFilter = new ArrayList<>();
scanFilter.add(scanFilterBuilder.build()); scanFilter.add(scanFilterBuilder.build());
@@ -180,7 +190,7 @@ public class BluetoothLePreference extends DialogPreference {
public void onClick(DialogInterface dialog, int which) { public void onClick(DialogInterface dialog, int which) {
selectedEntryIndex = which; selectedEntryIndex = which;
BluetoothLePreferenceDialog.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE); BluetoothLeSensorPreferenceDialog.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE);
dialog.dismiss(); dialog.dismiss();
} }
}); });
@@ -196,7 +206,7 @@ public class BluetoothLePreference extends DialogPreference {
if (positiveResult && selectedEntryIndex >= 0) { if (positiveResult && selectedEntryIndex >= 0) {
String value = listAdapter.get(selectedEntryIndex).getAddress(); String value = listAdapter.get(selectedEntryIndex).getAddress();
BluetoothLePreference preference = (BluetoothLePreference) getPreference(); BluetoothLeSensorPreference preference = (BluetoothLeSensorPreference) getPreference();
if (preference.callChangeListener(value)) { if (preference.callChangeListener(value)) {
preference.setValue(value); preference.setValue(value);
} }
@@ -21,9 +21,13 @@ import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.UUID; import java.util.UUID;
import de.dennisguse.opentracks.content.sensor.SensorDataCycling;
/** /**
* Utilities for dealing with bluetooth devices. * Utilities for dealing with bluetooth devices.
* *
@@ -31,11 +35,21 @@ import java.util.UUID;
*/ */
public class BluetoothUtils { 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_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() { 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) { public static BluetoothAdapter getDefaultBluetoothAdapter(final String TAG) {
// If from the main application thread, return directly // If from the main application thread, return directly
if (Thread.currentThread().equals(Looper.getMainLooper().getThread())) { if (Thread.currentThread().equals(Looper.getMainLooper().getThread())) {
@@ -77,12 +91,56 @@ public class BluetoothUtils {
return BluetoothUtils.getDefaultBluetoothAdapter(TAG) != null; 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/ //DOCUMENTATION https://www.bluetooth.com/specifications/gatt/characteristics/
byte[] raw = characteristic.getValue(); byte[] raw = characteristic.getValue();
int index = ((raw[0] & 0x1) == 1) ? 2 : 1; if (raw.length == 0) {
int format = (index == 1) ? BluetoothGattCharacteristic.FORMAT_UINT8 : BluetoothGattCharacteristic.FORMAT_UINT16; return null;
return characteristic.getIntValue(format, index); }
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);
}
} }
@@ -49,7 +49,7 @@ public class PreferencesUtils {
@Deprecated @Deprecated
//NOTE: is at the moment still used to determine if a track is currently recorded; better ask the service directly. //NOTE: is at the moment still used to determine if a track is currently recorded; better ask the service directly.
//NOTE: This was also used to recover from a reboot, but this data should not be exposed to the whole application. //NOTE: This was also used to recover from a service restart, but this data should not be exposed to the whole application.
public static final long RECORDING_TRACK_ID_DEFAULT = -1L; public static final long RECORDING_TRACK_ID_DEFAULT = -1L;
public static long getRecordingTrackId(Context context) { public static long getRecordingTrackId(Context context) {
@@ -215,20 +215,44 @@ public class PreferencesUtils {
setBoolean(context, R.string.recording_track_paused_key, RECORDING_TRACK_PAUSED); 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); return context.getString(R.string.sensor_type_value_none);
} }
public static boolean isBluetoothHeartRateSensorAddressDefault(Context context) { public static boolean isBluetoothSensorAddressNone(Context context, String currentValue) {
return getBluetoothHeartRateSensorAddressNone(context).equals(getBluetoothHeartRateSensorAddress(context)); 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) { 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 int getWheelCircumference(Context context) {
final int DEFAULT = Integer.parseInt(context.getResources().getString(R.string.settings_sensor_bluetooth_cycling_speed_wheel_circumference_default));
return getInt(context, R.string.settings_sensor_bluetooth_cycling_speed_wheel_circumference_key, DEFAULT);
} }
public static boolean shouldShowStatsOnLockscreen(Context context) { public static boolean shouldShowStatsOnLockscreen(Context context) {
@@ -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;
}
}
@@ -46,6 +46,8 @@ public class UnitConversions {
// multiplication factor to convert kilometers to miles // multiplication factor to convert kilometers to miles
public static final double KM_TO_MI = 0.621371192; public static final double KM_TO_MI = 0.621371192;
public static final double MM_TO_M = 0.001;
// Distance // Distance
// multiplication factor to convert miles to feet // multiplication factor to convert miles to feet
public static final double MI_TO_FT = 5280.0; public static final double MI_TO_FT = 5280.0;
+30 -8
View File
@@ -267,18 +267,24 @@ limitations under the License.
android:id="@+id/stats_sensor_separator" android:id="@+id/stats_sensor_separator"
style="@style/StatsHorizontalLine" /> style="@style/StatsHorizontalLine" />
<!-- Sensor data: heart rate--> <!-- Sensor data -->
<LinearLayout <LinearLayout
android:id="@+id/stats_sensor_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<LinearLayout style="@style/StatsLargeItemContainer"> <LinearLayout
android:id="@+id/stats_sensor_heart_rate_container"
style="@style/StatsLargeItemContainer">
<TextView <TextView
style="@style/StatsLargeLabel" style="@style/StatsLargeLabel"
android:text="@string/sensor_state_heart_rate" /> android:text="@string/sensor_state_heart_rate" />
<TextView
android:id="@+id/stats_sensor_heart_rate_sensor_value"
style="@style/StatsTinyValue"
android:text="@string/value_unknown" />
<LinearLayout style="@style/StatsLargeValueContainer"> <LinearLayout style="@style/StatsLargeValueContainer">
<TextView <TextView
@@ -291,20 +297,36 @@ limitations under the License.
android:text="@string/sensor_unit_beats_per_minute" /> android:text="@string/sensor_unit_beats_per_minute" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<LinearLayout style="@style/StatsSmallGroupContainer"> <LinearLayout
android:id="@+id/stats_sensor_cadence_container"
style="@style/StatsLargeItemContainer">
<TextView <TextView
style="@style/StatsSmallLabel" style="@style/StatsLargeLabel"
android:text="@string/settings_sensor_bluetooth_sensor" /> android:text="@string/sensor_state_cadence" />
<TextView <TextView
android:id="@+id/stats_sensor_heart_rate_sensor_value" android:id="@+id/stats_sensor_cadence_sensor_value"
style="@style/StatsTinyValue" style="@style/StatsTinyValue"
android:value="@string/value_unknown" /> android:text="@string/value_unknown" />
<LinearLayout style="@style/StatsLargeValueContainer">
<TextView
android:id="@+id/stats_sensor_cadence_value"
style="@style/StatsLargeValue"
android:value="@string/value_unknown" />
<TextView
style="@style/StatsUnit"
android:text="@string/sensor_unit_rounds_per_minute" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
+1
View File
@@ -25,6 +25,7 @@ limitations under the License.
<string name="marker_waypoint_icon_url" translatable="false">http://maps.google.com/mapfiles/ms/micons/blue-pushpin.png</string> <string name="marker_waypoint_icon_url" translatable="false">http://maps.google.com/mapfiles/ms/micons/blue-pushpin.png</string>
<string name="sensor_unit_beats_per_minute" translatable="false">BPM</string> <string name="sensor_unit_beats_per_minute" translatable="false">BPM</string>
<string name="sensor_unit_rounds_per_minute" translatable="false">RPM</string>
<string name="value_unknown" translatable="false">-</string> <string name="value_unknown" translatable="false">-</string>
+17
View File
@@ -16,8 +16,25 @@
<string name="settings_reset_key" translatable="false">settingsReset</string> <string name="settings_reset_key" translatable="false">settingsReset</string>
<string name="settings_sensor_bluetooth_heart_rate_key" translatable="false">bluetoothSensor</string> <string name="settings_sensor_bluetooth_heart_rate_key" translatable="false">bluetoothSensor</string>
<string name="settings_sensor_bluetooth_cycling_cadence_key" translatable="false">bluetoothCyclingCadenceSensor</string>
<string name="settings_sensor_bluetooth_cycling_speed_key" translatable="false">bluetoothCyclingSpeedSensor</string>
<string name="sensor_type_value_none" translatable="false">NONE</string> <string name="sensor_type_value_none" translatable="false">NONE</string>
<string name="settings_sensor_bluetooth_cycling_speed_wheel_circumference_key" translatable="false">bluetoothCyclingSpeedWheelCircumference</string>
<string name="settings_sensor_bluetooth_cycling_speed_wheel_circumference_default" translatable="false">2135</string>
<string-array name="wheel_circumference_values">
<item>2100</item>
<item>2125</item>
<item>@string/settings_sensor_bluetooth_cycling_speed_wheel_circumference_default</item>
<item>2150</item>
</string-array>
<string-array name="wheel_circumference_options">
<item>20-622</item>
<item>23-622</item>
<item>25-622</item>
<item>28-622</item>
</string-array>
<string name="default_activity_key" translatable="false">defaultActivity</string> <string name="default_activity_key" translatable="false">defaultActivity</string>
<string name="default_activity_default" translatable="false">@string/activity_type_unknown</string> <string name="default_activity_default" translatable="false">@string/activity_type_unknown</string>
<string-array name="activity_types"> <string-array name="activity_types">
+3
View File
@@ -397,6 +397,9 @@ limitations under the License.
<string name="settings_sensor_connecting">Connected to %1$s</string> <string name="settings_sensor_connecting">Connected to %1$s</string>
<string name="settings_sensor_connected">Connected to %1$s</string> <string name="settings_sensor_connected">Connected to %1$s</string>
<string name="settings_sensor_disconnected">Disconnected from %1$s</string> <string name="settings_sensor_disconnected">Disconnected from %1$s</string>
<string name="settings_sensor_wheel_circumference">Wheel size (ETRTO)</string>
<!-- Settings Stats --> <!-- Settings Stats -->
<string name="settings_stats_coordinate">Latitude/Longitude</string> <string name="settings_stats_coordinate">Latitude/Longitude</string>
<string name="settings_stats_rate_title">Preferred rate</string> <string name="settings_stats_rate_title">Preferred rate</string>
+17 -2
View File
@@ -54,8 +54,8 @@ limitations under the License.
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory <PreferenceCategory
android:title="@string/settings_recording"
android:key="@string/settings_recording_key" android:key="@string/settings_recording_key"
android:title="@string/settings_recording"
app:initialExpandedChildrenCount="4"> app:initialExpandedChildrenCount="4">
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="@bool/stats_show_on_lockscreen_while_recording_default" android:defaultValue="@bool/stats_show_on_lockscreen_while_recording_default"
@@ -111,10 +111,25 @@ limitations under the License.
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="@string/settings_sensor_bluetooth"> <PreferenceCategory android:title="@string/settings_sensor_bluetooth">
<de.dennisguse.opentracks.settings.BluetoothLePreference <de.dennisguse.opentracks.settings.bluetooth.BluetoothLeHeartRatePreference
android:defaultValue="@string/sensor_type_value_none" android:defaultValue="@string/sensor_type_value_none"
android:key="@string/settings_sensor_bluetooth_heart_rate_key" android:key="@string/settings_sensor_bluetooth_heart_rate_key"
android:title="@string/sensor_state_heart_rate" /> android:title="@string/sensor_state_heart_rate" />
<de.dennisguse.opentracks.settings.bluetooth.BluetoothLeCyclingCadenceAndSpeedPreference
android:defaultValue="@string/sensor_type_value_none"
android:key="@string/settings_sensor_bluetooth_cycling_cadence_key"
android:title="@string/sensor_state_cadence" />
<de.dennisguse.opentracks.settings.bluetooth.BluetoothLeCyclingCadenceAndSpeedPreference
android:defaultValue="@string/sensor_type_value_none"
android:key="@string/settings_sensor_bluetooth_cycling_speed_key"
android:title="@string/stats_speed" />
<ListPreference
android:defaultValue="@string/settings_sensor_bluetooth_cycling_speed_wheel_circumference_default"
android:entries="@array/wheel_circumference_options"
android:entryValues="@array/wheel_circumference_values"
android:key="settings_sensor_bluetooth_cycling_speed_wheel_circumference_key"
android:title="@string/settings_sensor_wheel_circumference"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="@string/generic_tracks"> <PreferenceCategory android:title="@string/generic_tracks">