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 de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
/**
* Commons utilities for creating stubs of track, location.
@@ -63,6 +62,6 @@ public class TrackStubUtils {
location.setTime(INITIAL_TIME);
location.setBearing(INITIAL_BEARING);
return new TrackPoint(location, new SensorDataSet(Float.NaN, Float.NaN));
return new TrackPoint(location);
}
}
@@ -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
public void testBulkInsertTrackPoint() {
@@ -533,44 +533,44 @@ public class CustomContentProviderUtilsTest {
/**
* Tests the method {@link ContentProviderUtils#createTrackPoint(Cursor)}.
*/
//TODO incomplete
@Test
public void testCreateTrackPoint() {
// Set index.
int index = 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++);
// given
when(cursorMock.getColumnIndex(TrackPointsColumns._ID)).thenReturn(1);
// Set return value of isNull().
index = 2;
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;
when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.LONGITUDE)).thenReturn(2);
when(cursorMock.isNull(2)).thenReturn(false);
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;
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();
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;
when(cursorMock.getFloat(index++)).thenReturn(speed);
when(cursorMock.getFloat(5)).thenReturn(speed);
byte[] sensor = "Sensor state".getBytes();
when(cursorMock.getBlob(index++)).thenReturn(sensor);
when(cursorMock.getColumnIndexOrThrow(TrackPointsColumns.SENSOR_HEARTRATE)).thenReturn(6);
when(cursorMock.isNull(6)).thenReturn(false);
TrackPoint location = contentProviderUtils.createTrackPoint(cursorMock);
Assert.assertEquals(longitude, location.getLongitude(), 0.01);
Assert.assertEquals(latitude, location.getLatitude(), 0.01);
Assert.assertEquals(time, location.getTime(), 0.01);
Assert.assertEquals(speed, location.getSpeed(), 0.01);
// when
TrackPoint trackPoint = contentProviderUtils.createTrackPoint(cursorMock);
// then
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);
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.ChartView;
import de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.util.UnitConversions;
/**
@@ -72,20 +71,13 @@ public class ChartFragmentTest {
*/
@Test
public void testCreatePendingPoint_sensorIncorrect() {
// given
TrackPoint trackPoint = TrackStubUtils.createDefaultTrackPoint();
// No input.
// when
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.
// Creates SensorData.
SensorDataSet sensorDataSet = new SensorDataSet(SensorDataSet.DATA_UNAVAILABLE, SensorDataSet.DATA_UNAVAILABLE);
trackPoint.setSensorDataSet(sensorDataSet);
// Test.
point = chartFragment.createPendingPoint(trackPoint);
// then
Assert.assertEquals(Float.NaN, point.getHeartRate(), 0.01);
Assert.assertEquals(Float.NaN, point.getCadence(), 0.01);
Assert.assertEquals(Float.NaN, point.getPower(), 0.01);
@@ -96,20 +88,16 @@ public class ChartFragmentTest {
*/
@Test
public void testCreatePendingPoint_sensorCorrect() {
// given
TrackPoint trackPoint = TrackStubUtils.createDefaultTrackPoint();
// No input.
trackPoint.setHeartRate_bpm(100f);
trackPoint.setCyclingCadence_rpm(101f);
trackPoint.setPower(102f);
// when
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.
SensorDataSet sensorDataSet = new SensorDataSet(100, 101, 102);
// Creates SensorDataSet.
trackPoint.setSensorDataSet(sensorDataSet);
// Test.
point = chartFragment.createPendingPoint(trackPoint);
// then
Assert.assertEquals(100.0, point.getHeartRate(), 0.01);
Assert.assertEquals(101.0, point.getCadence(), 0.01);
Assert.assertEquals(102.0, point.getPower(), 0.01);
@@ -331,7 +331,7 @@ public class TrackRecordingServiceTest {
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());
}
@@ -342,7 +342,7 @@ public class TrackRecordingServiceTest {
/**
* 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.setLongitude(longitude);
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.provider.ContentProviderUtils;
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.services.sensors.BluetoothRemoteSensorManager;
import de.dennisguse.opentracks.util.PreferencesUtils;
@@ -244,13 +245,10 @@ public class TrackRecordingServiceTestLocation {
}
@Override
public boolean isSensorDataSetValid() {
return true;
}
@Override
public SensorDataSet getSensorDataSet() {
return new SensorDataSet(1, 2);
public SensorDataSet getSensorData() {
SensorDataSet sensorDataSet = new SensorDataSet();
sensorDataSet.set(new SensorDataHeartRate("sensorName", "sensorAddress", 5f));
return sensorDataSet;
}
});
@@ -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.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.TrackStatisticsUpdater;
import de.dennisguse.opentracks.util.UnitConversions;
@@ -25,7 +25,7 @@ public class ChartPoint {
this.elevation = elevation;
}
public ChartPoint(@NonNull TrackStatisticsUpdater trackStatisticsUpdater, SensorDataSet sensorDataSet, boolean chartByDistance, boolean metricUnits) {
public ChartPoint(@NonNull TrackStatisticsUpdater trackStatisticsUpdater, TrackPoint trackPoint, boolean chartByDistance, boolean metricUnits) {
TrackStatistics trackStatistics = trackStatisticsUpdater.getTrackStatistics();
if (chartByDistance) {
@@ -48,15 +48,15 @@ public class ChartPoint {
speed *= UnitConversions.KM_TO_MI;
}
pace = speed == 0 ? 0.0 : 60.0 / speed;
if (sensorDataSet != null) {
if (sensorDataSet.hasHeartRate()) {
heartRate = sensorDataSet.getHeartRate();
if (trackPoint != null) {
if (trackPoint.hasHeartRate()) {
heartRate = trackPoint.getHeartRate_bpm();
}
if (sensorDataSet.hasCadence()) {
cadence = sensorDataSet.getCadence();
if (trackPoint.hasCyclingCadence()) {
cadence = trackPoint.getCyclingCadence_rpm();
}
if (sensorDataSet.hasPower()) {
power = sensorDataSet.getPower();
if (trackPoint.hasPower()) {
power = trackPoint.getPower();
}
}
}
@@ -20,8 +20,6 @@ import android.location.LocationManager;
import androidx.annotation.NonNull;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
/**
* This class extends the standard Android location with extra information.
*
@@ -31,6 +29,10 @@ public class TrackPoint {
private final Location location;
private Float heartRate_bpm = null;
private Float cyclingCadence_rpm = null;
private Float power = null;
public TrackPoint() {
this.location = new Location("");
}
@@ -39,17 +41,13 @@ public class TrackPoint {
this.location = location;
}
public TrackPoint(@NonNull Location location, SensorDataSet sensorDataSet) {
this.location = location;
this.sensorDataSet = sensorDataSet;
}
public TrackPoint(@NonNull TrackPoint trackPoint, SensorDataSet sensorDataSet) {
public TrackPoint(@NonNull TrackPoint trackPoint) {
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) {
location = new Location(LocationManager.GPS_PROVIDER);
@@ -90,14 +88,6 @@ public class TrackPoint {
return location;
}
public SensorDataSet getSensorDataSet() {
return sensorDataSet;
}
public void setSensorDataSet(SensorDataSet sensorDataSet) {
this.sensorDataSet = sensorDataSet;
}
public double getLatitude() {
return location.getLatitude();
}
@@ -182,6 +172,47 @@ public class TrackPoint {
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
@Override
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.Waypoint;
import de.dennisguse.opentracks.content.data.WaypointsColumns;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.util.FileUtils;
@@ -621,11 +620,15 @@ public class ContentProviderUtils {
trackPoint.setBearing(cursor.getFloat(indexes.bearingIndex));
}
float heartRate = cursor.isNull(indexes.sensorHeartRateIndex) ? SensorDataSet.DATA_UNAVAILABLE : cursor.getFloat(indexes.sensorHeartRateIndex);
float cadence = cursor.isNull(indexes.sensorCadenceIndex) ? SensorDataSet.DATA_UNAVAILABLE : cursor.getFloat(indexes.sensorCadenceIndex);
float power = cursor.isNull(indexes.sensorPowerIndex) ? SensorDataSet.DATA_UNAVAILABLE : cursor.getFloat(indexes.sensorPowerIndex);
trackPoint.setSensorDataSet(new SensorDataSet(heartRate, cadence, power, SensorDataSet.DATA_UNAVAILABLE, trackPoint.getTime()));
if (!cursor.isNull(indexes.sensorHeartRateIndex)) {
trackPoint.setHeartRate_bpm(cursor.getFloat(indexes.sensorHeartRateIndex));
}
if (!cursor.isNull(indexes.sensorCadenceIndex)) {
trackPoint.setCyclingCadence_rpm(cursor.getFloat(indexes.sensorCadenceIndex));
}
if (!cursor.isNull(indexes.sensorPowerIndex)) {
trackPoint.setPower(cursor.getFloat(indexes.sensorPowerIndex));
}
return trackPoint;
}
@@ -805,16 +808,14 @@ public class ContentProviderUtils {
values.put(TrackPointsColumns.BEARING, trackPoint.getBearing());
}
//SensorData
SensorDataSet sensorDataSet = trackPoint.getSensorDataSet();
if (sensorDataSet != null && sensorDataSet.hasHeartRate()) {
values.put(TrackPointsColumns.SENSOR_HEARTRATE, trackPoint.getSensorDataSet().getHeartRate());
if (trackPoint.hasHeartRate()) {
values.put(TrackPointsColumns.SENSOR_HEARTRATE, trackPoint.getHeartRate_bpm());
}
if (sensorDataSet != null && sensorDataSet.hasCadence()) {
values.put(TrackPointsColumns.SENSOR_CADENCE, trackPoint.getSensorDataSet().getCadence());
if (trackPoint.hasCyclingCadence()) {
values.put(TrackPointsColumns.SENSOR_CADENCE, trackPoint.getCyclingCadence_rpm());
}
if (sensorDataSet != null && sensorDataSet.hasPower()) {
values.put(TrackPointsColumns.SENSOR_POWER, trackPoint.getSensorDataSet().getPower());
if (trackPoint.hasPower()) {
values.put(TrackPointsColumns.SENSOR_POWER, trackPoint.getPower());
}
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 de.dennisguse.opentracks.content.data.TrackPoint;
/**
*
*/
public final class SensorDataSet {
public static final float DATA_UNAVAILABLE = Float.NaN;
private SensorDataHeartRate heartRate;
//TODO It might be necessary to consider: if sensor was connected as well.
private String sensorName;
private String sensorAddress;
private float heartRate;
private float cadence;
private float power;
private float batteryLevel;
private long time;
private SensorDataCycling.Cadence cyclingCadence;
public SensorDataSet(float heartRate, float cadence, float power, float batteryLevel, long time) {
this.heartRate = heartRate;
this.cadence = cadence;
this.power = power;
this.batteryLevel = batteryLevel;
this.time = time;
private SensorDataCycling.Speed cyclingSpeed;
public SensorDataSet() {
}
public SensorDataSet(float heartRate, float cadence, float power, float batteryLevel) {
this(heartRate, cadence, power, batteryLevel, System.currentTimeMillis());
}
public SensorDataSet(float heartRate, float cadence, float power) {
this(heartRate, cadence, power, DATA_UNAVAILABLE, System.currentTimeMillis());
}
public SensorDataSet(float heartRate, float cadence) {
this(heartRate, cadence, DATA_UNAVAILABLE, DATA_UNAVAILABLE, System.currentTimeMillis());
}
public SensorDataSet(float heartRate, String sensorName, String sensorAddress) {
this(heartRate, DATA_UNAVAILABLE, DATA_UNAVAILABLE, DATA_UNAVAILABLE, System.currentTimeMillis());
this.sensorName = sensorName;
this.sensorAddress = sensorAddress;
}
public boolean hasHeartRate() {
return !Float.isNaN(heartRate) && heartRate > 0;
}
public float getHeartRate() {
public SensorDataHeartRate getHeartRate() {
return heartRate;
}
public boolean hasCadence() {
return !Float.isNaN(cadence);
public SensorDataCycling.Cadence getCyclingCadence() {
return cyclingCadence;
}
public float getCadence() {
return cadence;
public SensorDataCycling.Speed getCyclingSpeed() {
return cyclingSpeed;
}
public boolean hasPower() {
return !Float.isNaN(power);
public void set(SensorData data) {
if (data == null) {
return;
}
if (data instanceof SensorDataHeartRate) {
this.heartRate = (SensorDataHeartRate) data;
return;
}
if (data instanceof SensorDataCycling.Cadence) {
this.cyclingCadence = (SensorDataCycling.Cadence) data;
return;
}
if (data instanceof SensorDataCycling.Speed) {
this.cyclingSpeed = (SensorDataCycling.Speed) data;
return;
}
if (data instanceof SensorDataCycling.CadenceAndSpeed) {
set(((SensorDataCycling.CadenceAndSpeed) data).getCadence());
set(((SensorDataCycling.CadenceAndSpeed) data).getSpeed());
}
throw new UnsupportedOperationException();
}
public float getPower() {
return power;
public void clear() {
this.heartRate = null;
this.cyclingCadence = null;
this.cyclingSpeed = null;
}
public long getTime() {
return time;
}
public void fillTrackPoint(TrackPoint trackPoint) {
if (heartRate != null) {
trackPoint.setHeartRate_bpm(heartRate.getHeartRate_bpm());
}
/**
* Is the data recent considering the current time.
*
* @param maxAge the maximal age in milliseconds.
*/
public boolean isRecent(long maxAge) {
return time + maxAge > System.currentTimeMillis();
}
if (cyclingCadence != null && cyclingCadence.hasCadence_rpm()) {
trackPoint.setCyclingCadence_rpm(cyclingCadence.getCadence_rpm());
}
public boolean hasBatteryLevel() {
return !Float.isNaN(batteryLevel);
}
public float getBatteryLevel() {
return batteryLevel;
}
public String getSensorName() {
return sensorName;
}
public String getSensorAddress() {
return sensorAddress;
if (cyclingSpeed != null && cyclingSpeed.hasSpeed_mps()) {
trackPoint.setSpeed(cyclingSpeed.getSpeed_mps());
}
}
@NonNull
@Override
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
ChartPoint createPendingPoint(@NonNull TrackPoint trackPoint) {
trackStatisticsUpdater.addTrackPoint(trackPoint, recordingDistanceInterval);
return new ChartPoint(trackStatisticsUpdater, trackPoint.getSensorDataSet(), chartByDistance, chartView.getMetricUnits());
return new ChartPoint(trackStatisticsUpdater, trackPoint, chartByDistance, chartView.getMetricUnits());
}
@VisibleForTesting
@@ -43,7 +43,6 @@ import de.dennisguse.opentracks.content.data.Waypoint;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.services.TrackRecordingServiceConnection;
import de.dennisguse.opentracks.services.TrackRecordingServiceInterface;
import de.dennisguse.opentracks.services.sensors.BluetoothRemoteSensorManager;
import de.dennisguse.opentracks.stats.TrackStatistics;
import de.dennisguse.opentracks.util.LocationUtils;
import de.dennisguse.opentracks.util.PreferencesUtils;
@@ -114,9 +113,12 @@ public class StatsFragment extends Fragment implements TrackDataListener {
}
/* Views */
private View sensorContainerView;
private View heartRateContainer;
private TextView heartRateValueView;
private TextView heartRateSensorView;
private View cadenceContainer;
private TextView cadenceValueView;
private TextView cadenceSensorView;
private TextView totalTimeValueView;
private final Runnable updateUIeachSecond = new Runnable() {
@@ -162,10 +164,14 @@ public class StatsFragment extends Fragment implements TrackDataListener {
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
sensorContainerView = view.findViewById(R.id.stats_sensor_container);
heartRateContainer = view.findViewById(R.id.stats_sensor_heart_rate_container);
heartRateValueView = view.findViewById(R.id.stats_sensor_heart_rate_value);
heartRateSensorView = view.findViewById(R.id.stats_sensor_heart_rate_sensor_value);
cadenceContainer = view.findViewById(R.id.stats_sensor_cadence_container);
cadenceValueView = view.findViewById(R.id.stats_sensor_cadence_value);
cadenceSensorView = view.findViewById(R.id.stats_sensor_cadence_sensor_value);
totalTimeValueView = view.findViewById(R.id.stats_total_time_value);
distanceValue = view.findViewById(R.id.stats_distance_value);
@@ -269,9 +275,12 @@ public class StatsFragment extends Fragment implements TrackDataListener {
@Override
public void onDestroyView() {
super.onDestroyView();
sensorContainerView = null;
heartRateContainer = null;
heartRateValueView = null;
heartRateSensorView = null;
cadenceContainer = null;
cadenceValueView = null;
cadenceSensorView = null;
totalTimeValueView = null;
@@ -426,7 +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() {
TrackRecordingServiceInterface trackRecordingService = trackRecordingServiceConnection.getServiceIfBound();
@@ -435,33 +444,65 @@ public class StatsFragment extends Fragment implements TrackDataListener {
if (trackRecordingService == null) {
Log.d(STATS_FRAGMENT_TAG, "Cannot get the track recording service.");
} else {
//TODO sensorState = trackRecordingService.getSensorState();
sensorDataSet = trackRecordingService.getSensorData();
}
setHeartRateSensorData(sensorDataSet, isSelectedTrackRecording());
setCadenceSensorData(sensorDataSet, isSelectedTrackRecording());
setSpeedSensorData(sensorDataSet, isSelectedTrackRecording());
}
private void setHeartRateSensorData(SensorDataSet sensorDataSet, boolean isRecording) {
// heart rate
int isVisible = View.VISIBLE;
if (!isRecording || PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(getContext())) {
if (!isRecording || PreferencesUtils.isBluetoothHeartRateSensorAddressNone(getContext())) {
isVisible = View.INVISIBLE;
}
sensorContainerView.setVisibility(isVisible);
heartRateContainer.setVisibility(isVisible);
if (isRecording) {
String heartRate = getContext().getString(R.string.value_unknown);
String sensorValue = getContext().getString(R.string.value_unknown);
String sensorName = getContext().getString(R.string.value_unknown);
if (sensorDataSet != null && sensorDataSet.isRecent(BluetoothRemoteSensorManager.MAX_SENSOR_DATE_SET_AGE_MS)) {
sensorName = sensorDataSet.getSensorName();
if (sensorDataSet.hasHeartRate()) {
heartRate = StringUtils.formatDecimal(sensorDataSet.getHeartRate(), 0);
if (sensorDataSet != null && sensorDataSet.getHeartRate() != null) {
sensorName = sensorDataSet.getHeartRate().getSensorName();
if (sensorDataSet.getHeartRate().hasHeartRate_bpm() && sensorDataSet.getHeartRate().isRecent()) {
sensorValue = StringUtils.formatDecimal(sensorDataSet.getHeartRate().getHeartRate_bpm(), 0);
}
}
heartRateSensorView.setText(sensorName);
heartRateValueView.setText(heartRate);
heartRateValueView.setText(sensorValue);
}
}
private void setCadenceSensorData(SensorDataSet sensorDataSet, boolean isRecording) {
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
speedContainer.setVisibility(isRecording ? View.VISIBLE : View.INVISIBLE);
if (isRecording) {
speedLabel.setText(reportSpeed ? R.string.stats_speed : R.string.stats_pace);
double speed = lastTrackPoint != null && lastTrackPoint.hasSpeed() ? lastTrackPoint.getSpeed() : Double.NaN;
Pair<String, String> parts = StringUtils.getSpeedParts(getContext(), speed, metricUnits, reportSpeed);
speedValue.setText(parts.first);
speedUnit.setText(parts.second);
setSpeed(speed);
}
// Set elevation
@@ -603,4 +640,15 @@ public class StatsFragment extends Fragment implements TrackDataListener {
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.database.Cursor;
import android.location.Location;
import android.net.Uri;
import java.io.OutputStream;
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.Waypoint;
import de.dennisguse.opentracks.content.provider.ContentProviderUtils;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.util.FileUtils;
import de.dennisguse.opentracks.util.StringUtils;
@@ -73,11 +71,11 @@ public class KmlTrackWriter implements TrackWriter {
private TrackPoint startTrackPoint;
/**
* @param context the context
* @param hasMultipleTracks should encode multiple tracks into one file?
* @param exportTrackDetail should detailed information about the track be exported (e.g., title, description, waypoints, timing)?
* @param exportSensorData should {@link SensorDataSet} be exported?
* @param exportPhotos should pictures be exported (if true: exports to KMZ)?
* @param context the context
* @param hasMultipleTracks should encode multiple tracks into one file?
* @param exportTrackDetail should detailed information about the track be exported (e.g., title, description, waypoints, timing)?
* @param exportSensorData should {@link TrackPointSensorDataSet} be exported?
* @param exportPhotos should pictures be exported (if true: exports to KMZ)?
*/
public KmlTrackWriter(Context context, boolean hasMultipleTracks, boolean exportTrackDetail, boolean exportSensorData, boolean exportPhotos) {
this.context = context;
@@ -269,17 +267,14 @@ public class KmlTrackWriter implements TrackWriter {
printWriter.println("<gx:coord>" + getCoordinates(trackPoint.getLocation(), " ") + "</gx:coord>");
if (exportSensorData) {
SensorDataSet sensorDataSet = trackPoint.getSensorDataSet();
if (sensorDataSet != null) {
if (sensorDataSet.hasHeartRate()) {
heartRateList.add(sensorDataSet.getHeartRate());
}
if (sensorDataSet.hasCadence()) {
cadenceList.add(sensorDataSet.getCadence());
}
if (sensorDataSet.hasPower()) {
powerList.add(sensorDataSet.getPower());
}
if (trackPoint.hasHeartRate()) {
heartRateList.add(trackPoint.getHeartRate_bpm());
}
if (trackPoint.hasCyclingCadence()) {
cadenceList.add(trackPoint.getCyclingCadence_rpm());
}
if (trackPoint.hasPower()) {
powerList.add(trackPoint.getPower());
}
}
}
@@ -453,7 +448,7 @@ public class KmlTrackWriter implements TrackWriter {
/**
* Writes a sensor style.
*
* @param name the name of the sesnor
* @param name the name of the sesnor
* @param sensorType the sensor display name
*/
private void writeSensorStyle(String name, String sensorType) {
@@ -28,7 +28,6 @@ import java.util.ArrayList;
import de.dennisguse.opentracks.content.data.TrackPoint;
import de.dennisguse.opentracks.content.provider.ContentProviderUtils;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
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++) {
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()) {
heartrate = heartRateList.get(i);
hasSensorData = true;
trackPoint.setHeartRate_bpm(heartRateList.get(i));
}
if (i < cadenceList.size()) {
cadence = cadenceList.get(i);
hasSensorData = true;
trackPoint.setCyclingCadence_rpm(cadenceList.get(i));
}
if (i < powerList.size()) {
power = powerList.get(i);
hasSensorData = true;
trackPoint.setPower(cadenceList.get(i));
}
if (!hasSensorData) {
insertTrackPoint(trackPoint);
} else {
TrackPoint sensorDataSetLocation = new TrackPoint(trackPoint, new SensorDataSet(heartrate, cadence, power, SensorDataSet.DATA_UNAVAILABLE, trackPoint.getTime()));
insertTrackPoint(sensorDataSetLocation);
}
insertTrackPoint(trackPoint);
}
}
@@ -575,7 +575,9 @@ public class TrackRecordingService extends Service {
return;
}
TrackPoint trackPoint = new TrackPoint(location, getSensorDataSet());
TrackPoint trackPoint = new TrackPoint(location);
fillWithSensorDataSet(trackPoint);
notificationManager.updateTrackPoint(this, trackPoint, recordingGpsAccuracy);
if (!TrackPointUtils.fulfillsAccuracy(trackPoint, recordingGpsAccuracy)) {
@@ -626,7 +628,7 @@ public class TrackRecordingService extends Service {
return;
}
if (trackPoint.getSensorDataSet() != null || distanceToLastTrackLocation >= recordingDistanceInterval) {
if (trackPoint.hasSensorData() || distanceToLastTrackLocation >= recordingDistanceInterval) {
insertTrackPointIfNewer(track, lastTrackPoint);
insertTrackPoint(track, trackPoint);
isIdle = false;
@@ -708,10 +710,18 @@ public class TrackRecordingService extends Service {
}
SensorDataSet getSensorDataSet() {
if (remoteSensorManager == null || !remoteSensorManager.isEnabled() || !remoteSensorManager.isSensorDataSetValid()) {
if (remoteSensorManager == null || !remoteSensorManager.isEnabled()) {
return null;
}
return remoteSensorManager.getSensorDataSet();
return remoteSensorManager.getSensorData();
}
void fillWithSensorDataSet(TrackPoint trackPoint) {
SensorDataSet sensorData = getSensorDataSet();
if (sensorData != null) {
sensorData.fillTrackPoint(trackPoint);
}
}
private void registerLocationListener() {
@@ -95,8 +95,13 @@ public interface TrackRecordingServiceInterface {
long insertWaypoint(String name, String category, String description, String photoUrl);
/**
* ONLY FOR TESTING
* Gets the current sensor data. Returns null if there is no data.
*
* @return SensorDataSet object.
*/
SensorDataSet getSensorData();
/**
* Inserts a location in the current recording track.
* <p>
* When recording a track, GPS locations are automatically inserted.
@@ -107,13 +112,6 @@ public interface TrackRecordingServiceInterface {
@VisibleForTesting
void insertLocation(Location location);
/**
* Gets the current sensor data. Returns null if there is no data.
*
* @return SensorDataSet object.
*/
SensorDataSet getSensorData();
/**
* Disables processing of location updates from {@link android.location.LocationManager}.
*/
@@ -24,40 +24,30 @@ import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.os.Handler;
import android.util.Log;
import androidx.annotation.NonNull;
import java.util.UUID;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.content.sensor.SensorState;
import de.dennisguse.opentracks.content.sensor.SensorData;
import de.dennisguse.opentracks.content.sensor.SensorDataCycling;
import de.dennisguse.opentracks.content.sensor.SensorDataHeartRate;
import de.dennisguse.opentracks.util.BluetoothUtils;
/**
* Manages connection to Bluetooth LE heart rate monitor.
* Manages connection to a Bluetooth LE sensor and subscribes for onChange-notifications.
* Also parses the transferred data into {@link SensorDataObserver}.
*/
public class BluetoothConnectionManager {
// 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);
public abstract class BluetoothConnectionManager {
private static final String TAG = BluetoothConnectionManager.class.getSimpleName();
private final Context context;
private final Handler handler;
private SensorState sensorState;
private final SensorDataObserver observer;
private final UUID serviceUUUID;
private final UUID measurementUUID;
private BluetoothGatt bluetoothGatt;
private final BluetoothDevice bluetoothDevice;
private final BluetoothGattCallback connectCallback = new BluetoothGattCallback() {
@Override
@@ -65,86 +55,70 @@ public class BluetoothConnectionManager {
switch (newState) {
case BluetoothProfile.STATE_CONNECTING:
Log.d(TAG, "Connecting to sensor: " + gatt.getDevice());
setState(SensorState.CONNECTING);
handler.obtainMessage(MESSAGE_CONNECTING, gatt.getDevice().getName()).sendToTarget();
case BluetoothProfile.STATE_CONNECTED:
Log.d(TAG, "Connected to sensor: " + gatt.getDevice());
setState(SensorState.CONNECTED);
gatt.discoverServices();
handler.obtainMessage(MESSAGE_CONNECTED, gatt.getDevice().getName()).sendToTarget();
break;
case BluetoothProfile.STATE_DISCONNECTING:
Log.d(TAG, "Disconnecting from sensor: " + gatt.getDevice());
setState(SensorState.DISCONNECTING);
case BluetoothProfile.STATE_DISCONNECTED:
Log.d(TAG, "Disconnected from sensor: " + gatt.getDevice());
setState(SensorState.DISCONNECTED);
handler.obtainMessage(MESSAGE_DISCONNECTED, gatt.getDevice().getName()).sendToTarget();
}
}
@Override
public void onServicesDiscovered(@NonNull BluetoothGatt gatt, int status) {
BluetoothGattService service = gatt.getService(BluetoothUtils.HEART_RATE_SERVICE_UUID);
BluetoothGattService service = gatt.getService(serviceUUUID);
if (service == null) {
Log.e(TAG, "Could not get heart rate service for " + gatt.getDevice().getAddress());
Log.e(TAG, "Could not get service for address=" + gatt.getDevice().getAddress() + " serviceUUID=" + serviceUUUID);
return;
}
BluetoothGattCharacteristic characteristic = service.getCharacteristic(HEART_RATE_MEASUREMENT_CHAR_UUID);
BluetoothGattCharacteristic characteristic = service.getCharacteristic(measurementUUID);
if (characteristic == null) {
Log.e(TAG, "Could not get BluetoothCharacteristic for " + gatt.getDevice().getAddress());
Log.e(TAG, "Could not get BluetoothCharacteristic for address=" + gatt.getDevice().getAddress() + " serviceUUID=" + serviceUUUID + " characteristicUUID=" + measurementUUID);
return;
}
gatt.setCharacteristicNotification(characteristic, true);
BluetoothGattDescriptor descriptor = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID);
// Register for updates.
BluetoothGattDescriptor descriptor = characteristic.getDescriptor(BluetoothUtils.CLIENT_CHARACTERISTIC_CONFIG_UUID);
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(descriptor);
}
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, @NonNull BluetoothGattCharacteristic characteristic) {
int heartRate = BluetoothUtils.parseHeartRate(characteristic);
String deviceName = gatt.getDevice().getName();
String sensorName = gatt.getDevice().getName();
Log.d(TAG, "Received data from " + sensorName);
Log.d(TAG, "Received heart beat rate " + deviceName + ": " + heartRate);
SensorDataSet sensorDataSet = new SensorDataSet(heartRate, deviceName, gatt.getDevice().getAddress());
handler.obtainMessage(MESSAGE_READ, sensorDataSet).sendToTarget();
SensorData sensorData = parsePayload(sensorName, gatt.getDevice().getAddress(), characteristic);
if (sensorData != null) {
observer.onChanged(sensorData);
}
}
};
/**
* Constructor.
*
* @param handler a handler for sending messages back to the UI activity
*/
BluetoothConnectionManager(@NonNull Context context, @NonNull BluetoothDevice bluetoothDevice, @NonNull Handler handler) {
this.context = context;
this.bluetoothDevice = bluetoothDevice;
this.handler = handler;
this.sensorState = SensorState.NONE;
BluetoothConnectionManager(UUID serviceUUUID, UUID measurementUUID, SensorDataObserver observer) {
this.serviceUUUID = serviceUUUID;
this.measurementUUID = measurementUUID;
this.observer = observer;
}
public synchronized void connect() {
synchronized void connect(Context context, @NonNull BluetoothDevice device) {
if (bluetoothGatt != null) {
Log.w(TAG, "Already connected; ignoring.");
}
Log.d(TAG, "Connecting to: " + bluetoothDevice);
Log.d(TAG, "Connecting to: " + device);
bluetoothGatt = bluetoothDevice.connectGatt(this.context, true, this.connectCallback);
bluetoothGatt = device.connectGatt(context, true, this.connectCallback);
setState(SensorState.CONNECTING);
}
public synchronized void disconnect() {
synchronized void disconnect() {
if (bluetoothGatt == null) {
Log.w(TAG, "Cannot disconnect if not connected.");
return;
@@ -153,15 +127,78 @@ public class BluetoothConnectionManager {
bluetoothGatt = null;
}
public synchronized boolean isSameBluetoothDevice(String address) {
return this.bluetoothDevice.getAddress().equals(address);
synchronized boolean isSameBluetoothDevice(String address) {
if (bluetoothGatt == null) {
return false;
}
return address.equals(bluetoothGatt.getDevice().getAddress());
}
synchronized SensorState getSensorState() {
return sensorState;
/**
* @return null if data could not be parsed.
*/
protected abstract de.dennisguse.opentracks.content.sensor.SensorData parsePayload(String sensorName, String address, BluetoothGattCharacteristic characteristic);
public static class HeartRate extends BluetoothConnectionManager {
HeartRate(@NonNull SensorDataObserver observer) {
super(BluetoothUtils.HEART_RATE_SERVICE_UUID, BluetoothUtils.HEART_RATE_MEASUREMENT_CHAR_UUID, observer);
}
@Override
protected SensorDataHeartRate parsePayload(String sensorName, String address, BluetoothGattCharacteristic characteristic) {
Integer heartRate = BluetoothUtils.parseHeartRate(characteristic);
return heartRate != null ? new SensorDataHeartRate(address, sensorName, heartRate) : null;
}
}
private synchronized void setState(SensorState sensorState) {
this.sensorState = sensorState;
public static class CyclingCadence extends BluetoothConnectionManager {
CyclingCadence(SensorDataObserver observer) {
super(BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID, BluetoothUtils.CYCLING_SPPED_CADENCE_MEASUREMENT_CHAR_UUID, observer);
}
@Override
protected SensorDataCycling.Cadence parsePayload(String sensorName, String address, BluetoothGattCharacteristic characteristic) {
SensorDataCycling.CadenceAndSpeed cadenceAndSpeed = BluetoothUtils.parseCyclingCrankAndWheel(address, sensorName, characteristic);
if (cadenceAndSpeed == null) {
return null;
}
if (cadenceAndSpeed.getCadence() != null) {
return cadenceAndSpeed.getCadence();
}
//Workaround for Wahoo CADENCE: this sensor reports speed (instead of cadence)
if (cadenceAndSpeed.getSpeed() != null) {
return new SensorDataCycling.Cadence(cadenceAndSpeed.getSpeed());
}
return null;
}
}
public static class CyclingSpeed extends BluetoothConnectionManager {
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.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
import android.widget.Toast;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.content.sensor.SensorDataCycling;
import de.dennisguse.opentracks.content.sensor.SensorDataSet;
import de.dennisguse.opentracks.util.BluetoothUtils;
import de.dennisguse.opentracks.util.PreferencesUtils;
import de.dennisguse.opentracks.util.UnitConversions;
/**
* Bluetooth LE sensor manager.
* Should only be instantiated once!
* Bluetooth LE sensor manager: manages connections to Bluetooth LE sensors.
*
* Note: should only be instantiated once.
*
* TODO: listen for Bluetooth enabled/disabled events.
*
* TODO: In case, a cycling (Cadence and Speed) sensor reports both values, testing is required.
* We establish two GATT separate GATT connections (as if two different sensors were used).
* However, it is not clear if this is allowed.
* Even if this works, it is not clear what happens if a user (while recording) changes one of the sensors in the settings as this will trigger a disconnect of one GATT.
*
* @author Sandor Dornbush
*/
public class BluetoothRemoteSensorManager {
public class BluetoothRemoteSensorManager implements BluetoothConnectionManager.SensorDataObserver {
public static final long MAX_SENSOR_DATE_SET_AGE_MS = 5000;
private static final String TAG = BluetoothRemoteSensorManager.class.getSimpleName();
private static final String TAG = BluetoothConnectionManager.class.getSimpleName();
public static final long MAX_SENSOR_DATE_SET_AGE_MS = 5 * UnitConversions.S_TO_MS;
private static final BluetoothAdapter bluetoothAdapter = BluetoothUtils.getDefaultBluetoothAdapter(TAG);
private final Context context;
private final SharedPreferences sharedPreferences;
// Handler that gets information back from the bluetoothConnectionManager
private final Handler messageHandler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message message) {
String toastMessage;
switch (message.what) {
case BluetoothConnectionManager.MESSAGE_CONNECTING:
//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 boolean started = false;
private final BluetoothConnectionManager.HeartRate heartRate = new BluetoothConnectionManager.HeartRate(this);
private final BluetoothConnectionManager.CyclingCadence cyclingCadence = new BluetoothConnectionManager.CyclingCadence(this);
private final BluetoothConnectionManager.CyclingSpeed cyclingSpeed = new BluetoothConnectionManager.CyclingSpeed(this);
private final SensorDataSet sensorDataSet = new SensorDataSet();
private final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceChangeListener = new SharedPreferences.OnSharedPreferenceChangeListener() {
@Override
public void onSharedPreferenceChanged(SharedPreferences preferences, String key) {
if (bluetoothConnectionManager != null && PreferencesUtils.isKey(context, R.string.settings_sensor_bluetooth_heart_rate_key, key)) {
if (PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(context)) {
stop();
return;
}
String address = PreferencesUtils.getBluetoothHeartRateSensorAddress(context);
if (bluetoothConnectionManager.isSameBluetoothDevice(address)) {
return;
}
if (!started) return;
disconnect();
startCurrentSensor();
if (PreferencesUtils.isKey(context, R.string.settings_sensor_bluetooth_heart_rate_key, key)) {
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
*/
@@ -113,67 +94,76 @@ public class BluetoothRemoteSensorManager {
}
public void start() {
started = true;
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
startCurrentSensor();
//Trigger connection startup
sharedPreferenceChangeListener.onSharedPreferenceChanged(null, null);
}
public void stop() {
disconnect();
public synchronized void stop() {
// Disconnecting
heartRate.disconnect();
cyclingCadence.disconnect();
sensorDataSet.clear();
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener);
started = false;
}
public boolean isEnabled() {
return bluetoothAdapter != null && bluetoothAdapter.isEnabled();
}
public SensorDataSet getSensorDataSet() {
return sensorDataSet;
}
public boolean isSensorDataSetValid() {
SensorDataSet sensorDataSet = getSensorDataSet();
if (sensorDataSet == null) {
return false;
}
return sensorDataSet.isRecent(MAX_SENSOR_DATE_SET_AGE_MS);
}
private void startCurrentSensor() {
private synchronized void connect(BluetoothConnectionManager connectionManager, String address) {
if (!isEnabled()) {
Log.w(TAG, "Bluetooth not enabled.");
return;
}
if (PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(context)) {
Log.w(TAG, "No bluetooth address.");
if (PreferencesUtils.isBluetoothSensorAddressNone(context, address)) {
Log.w(TAG, "No Bluetooth address.");
return;
}
String address = PreferencesUtils.getBluetoothHeartRateSensorAddress(context);
// Check if there is an ongoing connection; if yes, check if the address changed.
if (connectionManager.isSameBluetoothDevice(address)) {
return;
} else {
connectionManager.disconnect();
}
Log.i(TAG, "Connecting to bluetooth address: " + address);
BluetoothDevice device;
try {
device = bluetoothAdapter.getRemoteDevice(address);
BluetoothDevice device = bluetoothAdapter.getRemoteDevice(address);
connectionManager.connect(context, device);
} catch (IllegalArgumentException e) {
Log.w(TAG, "Unable to get remote device for: " + address, e);
String toastMessage = context.getString(R.string.sensor_not_known, address);
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show();
return;
Log.e(TAG, "Unable to get remote device for: " + address, e);
}
disconnect();
bluetoothConnectionManager = new BluetoothConnectionManager(context, device, messageHandler);
bluetoothConnectionManager.connect();
}
private void disconnect() {
if (bluetoothConnectionManager != null) {
bluetoothConnectionManager.disconnect();
bluetoothConnectionManager = null;
public SensorDataSet getSensorData() {
return sensorDataSet;
}
@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.fragments.ChooseActivityTypeDialogFragment;
import de.dennisguse.opentracks.settings.bluetooth.BluetoothLeCyclingCadenceAndSpeedPreference;
import de.dennisguse.opentracks.settings.bluetooth.BluetoothLeHeartRatePreference;
import de.dennisguse.opentracks.settings.bluetooth.BluetoothLeSensorPreference;
import de.dennisguse.opentracks.util.BluetoothUtils;
import de.dennisguse.opentracks.util.HackUtils;
import de.dennisguse.opentracks.util.PreferencesUtils;
@@ -111,8 +114,10 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit
} else if (preference instanceof ActivityTypePreference) {
activityPreferenceDialog = ActivityTypePreference.ActivityPreferenceDialog.newInstance(preference.getKey());
dialogFragment = activityPreferenceDialog;
} else if (preference instanceof BluetoothLePreference) {
dialogFragment = BluetoothLePreference.BluetoothLePreferenceDialog.newInstance(preference.getKey());
} else if (preference instanceof BluetoothLeHeartRatePreference) {
dialogFragment = BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(preference.getKey(), BluetoothUtils.HEART_RATE_SERVICE_UUID);
} else if (preference instanceof BluetoothLeCyclingCadenceAndSpeedPreference) {
dialogFragment = BluetoothLeSensorPreference.BluetoothLeSensorPreferenceDialog.newInstance(preference.getKey(), BluetoothUtils.CYCLING_SPEED_CADENCE_SERVICE_UUID);
}
if (dialogFragment != null) {
@@ -139,7 +144,7 @@ public class SettingsActivity extends AppCompatActivity implements ChooseActivit
private void updateBluetooth() {
// Disable Bluetooth preference if device does not have Bluetooth
BluetoothLePreference bluetoothPreference = findPreference(getString(R.string.settings_sensor_bluetooth_heart_rate_key));
BluetoothLeSensorPreference bluetoothPreference = findPreference(getString(R.string.settings_sensor_bluetooth_heart_rate_key));
bluetoothPreference.setVisible(BluetoothUtils.hasBluetooth(TAG));
}
@@ -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.le.BluetoothLeScanner;
@@ -21,8 +21,10 @@ import androidx.preference.PreferenceDialogFragmentCompat;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.settings.BluetoothLeAdapter;
import de.dennisguse.opentracks.util.BluetoothUtils;
import de.dennisguse.opentracks.util.PreferencesUtils;
@@ -30,25 +32,27 @@ import de.dennisguse.opentracks.util.PreferencesUtils;
* Preference to select a discoverable Bluetooth LE device.
* Based upon ListPreference.
*/
public class BluetoothLePreference extends DialogPreference {
public abstract class BluetoothLeSensorPreference extends DialogPreference {
private static final String TAG = BluetoothLePreference.class.getSimpleName();
private static final String TAG = BluetoothLeSensorPreference.class.getSimpleName();
private static final String ARG_BLUETOOTH_UUID = "bluetoothUUID";
private static final int DEVICE_NONE_RESOURCEID = R.string.value_none;
public BluetoothLePreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
public BluetoothLeSensorPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public BluetoothLePreference(Context context, AttributeSet attrs, int defStyleAttr) {
public BluetoothLeSensorPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public BluetoothLePreference(Context context, AttributeSet attrs) {
public BluetoothLeSensorPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public BluetoothLePreference(Context context) {
public BluetoothLeSensorPreference(Context context) {
super(context);
}
@@ -78,18 +82,20 @@ public class BluetoothLePreference extends DialogPreference {
@Override
public CharSequence getSummary() {
if (getValue() == null || PreferencesUtils.isBluetoothHeartRateSensorAddressDefault(getContext(), getValue())) {
if (getValue() == null || PreferencesUtils.isBluetoothSensorAddressNone(getContext(), getValue())) {
return getContext().getString(DEVICE_NONE_RESOURCEID);
}
return getValue();
}
public static class BluetoothLePreferenceDialog extends PreferenceDialogFragmentCompat {
public static class BluetoothLeSensorPreferenceDialog extends PreferenceDialogFragmentCompat {
private int selectedEntryIndex;
private final BluetoothLeAdapter listAdapter = new BluetoothLeAdapter();
private UUID sensorUUID;
private BluetoothLeScanner scanner = null;
private final ScanCallback scanCallback = new ScanCallback() {
@Override
@@ -116,10 +122,11 @@ public class BluetoothLePreference extends DialogPreference {
}
};
static BluetoothLePreferenceDialog newInstance(String key) {
final BluetoothLePreferenceDialog fragment = new BluetoothLePreferenceDialog();
public static BluetoothLeSensorPreferenceDialog newInstance(String preferenceKey, UUID sensorUUID) {
final BluetoothLeSensorPreferenceDialog fragment = new BluetoothLeSensorPreferenceDialog();
final Bundle b = new Bundle(1);
b.putString(ARG_KEY, key);
b.putString(ARG_KEY, preferenceKey);
b.putSerializable(ARG_BLUETOOTH_UUID, sensorUUID);
fragment.setArguments(b);
return fragment;
}
@@ -128,6 +135,9 @@ public class BluetoothLePreference extends DialogPreference {
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
sensorUUID = (UUID) getArguments().getSerializable(ARG_BLUETOOTH_UUID);
Log.i(TAG, "UUID: " + sensorUUID);
BluetoothAdapter bluetoothAdapter = BluetoothUtils.getDefaultBluetoothAdapter(TAG);
if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled()) {
Log.w(TAG, "Bluetooth adapter is present or not enabled.");
@@ -152,14 +162,14 @@ public class BluetoothLePreference extends DialogPreference {
listAdapter.add(getContext().getString(DEVICE_NONE_RESOURCEID), deviceNone);
selectedEntryIndex = 0;
BluetoothLePreference preference = (BluetoothLePreference) getPreference();
BluetoothLeSensorPreference preference = (BluetoothLeSensorPreference) getPreference();
String deviceSelected = preference.getValue();
if (deviceSelected != null && !deviceNone.equals(deviceSelected)) {
listAdapter.add(preference.getValue(), preference.getValue());
selectedEntryIndex = 1;
}
ScanFilter.Builder scanFilterBuilder = new ScanFilter.Builder().setServiceUuid(new ParcelUuid(BluetoothUtils.HEART_RATE_SERVICE_UUID));
ScanFilter.Builder scanFilterBuilder = new ScanFilter.Builder().setServiceUuid(new ParcelUuid(sensorUUID));
List<ScanFilter> scanFilter = new ArrayList<>();
scanFilter.add(scanFilterBuilder.build());
@@ -180,7 +190,7 @@ public class BluetoothLePreference extends DialogPreference {
public void onClick(DialogInterface dialog, int which) {
selectedEntryIndex = which;
BluetoothLePreferenceDialog.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE);
BluetoothLeSensorPreferenceDialog.this.onClick(dialog, DialogInterface.BUTTON_POSITIVE);
dialog.dismiss();
}
});
@@ -196,7 +206,7 @@ public class BluetoothLePreference extends DialogPreference {
if (positiveResult && selectedEntryIndex >= 0) {
String value = listAdapter.get(selectedEntryIndex).getAddress();
BluetoothLePreference preference = (BluetoothLePreference) getPreference();
BluetoothLeSensorPreference preference = (BluetoothLeSensorPreference) getPreference();
if (preference.callChangeListener(value)) {
preference.setValue(value);
}
@@ -21,9 +21,13 @@ import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.UUID;
import de.dennisguse.opentracks.content.sensor.SensorDataCycling;
/**
* Utilities for dealing with bluetooth devices.
*
@@ -31,11 +35,21 @@ import java.util.UUID;
*/
public class BluetoothUtils {
public static final UUID CLIENT_CHARACTERISTIC_CONFIG_UUID = new UUID(0x290200001000L, 0x800000805f9b34fbL);
public static final UUID HEART_RATE_SERVICE_UUID = new UUID(0x180D00001000L, 0x800000805f9b34fbL);
public static final UUID HEART_RATE_MEASUREMENT_CHAR_UUID = new UUID(0x2A3700001000L, 0x800000805f9b34fbL);
public static final UUID CYCLING_SPEED_CADENCE_SERVICE_UUID = new UUID(0x181600001000L, 0x800000805f9b34fbL);
public static final UUID CYCLING_SPPED_CADENCE_MEASUREMENT_CHAR_UUID = new UUID(0x2A5B00001000L, 0x800000805f9b34fbL);
private BluetoothUtils() {
}
/**
* If called from UI: use a background thread to get the default Bluetooth adapter.
* TODO Check if this is necessary.
*/
public static BluetoothAdapter getDefaultBluetoothAdapter(final String TAG) {
// If from the main application thread, return directly
if (Thread.currentThread().equals(Looper.getMainLooper().getThread())) {
@@ -77,12 +91,56 @@ public class BluetoothUtils {
return BluetoothUtils.getDefaultBluetoothAdapter(TAG) != null;
}
public static int parseHeartRate(BluetoothGattCharacteristic characteristic) {
public static Integer parseHeartRate(BluetoothGattCharacteristic characteristic) {
//DOCUMENTATION https://www.bluetooth.com/specifications/gatt/characteristics/
byte[] raw = characteristic.getValue();
int index = ((raw[0] & 0x1) == 1) ? 2 : 1;
int format = (index == 1) ? BluetoothGattCharacteristic.FORMAT_UINT8 : BluetoothGattCharacteristic.FORMAT_UINT16;
return characteristic.getIntValue(format, index);
if (raw.length == 0) {
return null;
}
boolean formatUINT16 = ((raw[0] & 0x1) == 1);
if (formatUINT16 && raw.length >= 3) {
return characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, 1);
}
if (!formatUINT16 && raw.length >= 2) {
return characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT8, 1);
}
return null;
}
/**
* Documentation: https://www.bluetooth.org/docman/handlers/downloaddoc.ashx?doc_id=261449
*/
public static SensorDataCycling.CadenceAndSpeed parseCyclingCrankAndWheel(String address, String sensorName, @NonNull BluetoothGattCharacteristic characteristic) {
int valueLength = characteristic.getValue().length;
if (valueLength == 0) {
return null;
}
int flags = characteristic.getValue()[0];
boolean hasCrank = (flags & 0x01) > 0;
boolean hasWheel = (flags & 0x02) > 0;
SensorDataCycling.Cadence cadence = null;
int index = 1;
if (hasCrank && valueLength - index >= 6) {
long crankCount = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT32, index);
index += 4;
int crankTime = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); // 1/1024s
index += 2;
cadence = new SensorDataCycling.Cadence(address, sensorName, crankCount, crankTime);
}
SensorDataCycling.Speed speed = null;
if (hasWheel && valueLength - index >= 4) {
int wheelCount = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index);
index += 2;
int wheelTime = characteristic.getIntValue(BluetoothGattCharacteristic.FORMAT_UINT16, index); // 1/1024s
speed = new SensorDataCycling.Speed(address, sensorName, wheelCount, wheelTime);
}
return new SensorDataCycling.CadenceAndSpeed(address, sensorName, cadence, speed);
}
}
@@ -49,7 +49,7 @@ public class PreferencesUtils {
@Deprecated
//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 long getRecordingTrackId(Context context) {
@@ -215,20 +215,44 @@ public class PreferencesUtils {
setBoolean(context, R.string.recording_track_paused_key, RECORDING_TRACK_PAUSED);
}
private static String getBluetoothHeartRateSensorAddressNone(Context context) {
private static String getBluetoothSensorAddressNone(Context context) {
return context.getString(R.string.sensor_type_value_none);
}
public static boolean isBluetoothHeartRateSensorAddressDefault(Context context) {
return getBluetoothHeartRateSensorAddressNone(context).equals(getBluetoothHeartRateSensorAddress(context));
public static boolean isBluetoothSensorAddressNone(Context context, String currentValue) {
return getBluetoothSensorAddressNone(context).equals(currentValue);
}
public static boolean isBluetoothHeartRateSensorAddressDefault(Context context, String currentValue) {
return getBluetoothHeartRateSensorAddressNone(context).equals(currentValue);
public static boolean isBluetoothHeartRateSensorAddressNone(Context context) {
return isBluetoothSensorAddressNone(context, getBluetoothHeartRateSensorAddress(context));
}
public static String getBluetoothHeartRateSensorAddress(Context context) {
return getString(context, R.string.settings_sensor_bluetooth_heart_rate_key, getBluetoothHeartRateSensorAddressNone(context));
return getString(context, R.string.settings_sensor_bluetooth_heart_rate_key, getBluetoothSensorAddressNone(context));
}
public static boolean isBluetoothCyclingCadenceSensorAddressNone(Context context) {
return isBluetoothSensorAddressNone(context, getBluetoothCyclingCadenceSensorAddress(context));
}
public static String getBluetoothCyclingCadenceSensorAddress(Context context) {
return getString(context, R.string.settings_sensor_bluetooth_cycling_cadence_key, getBluetoothSensorAddressNone(context));
}
public static boolean isBluetoothCyclingSpeedSensorAddressNone(Context context) {
return isBluetoothSensorAddressNone(context, getBluetoothCyclingSpeedSensorAddress(context));
}
public static String getBluetoothCyclingSpeedSensorAddress(Context context) {
return getString(context, R.string.settings_sensor_bluetooth_cycling_speed_key, getBluetoothSensorAddressNone(context));
}
public static 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) {
@@ -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
public static final double KM_TO_MI = 0.621371192;
public static final double MM_TO_M = 0.001;
// Distance
// multiplication factor to convert miles to feet
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"
style="@style/StatsHorizontalLine" />
<!-- Sensor data: heart rate-->
<!-- Sensor data -->
<LinearLayout
android:id="@+id/stats_sensor_container"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout style="@style/StatsLargeItemContainer">
<LinearLayout
android:id="@+id/stats_sensor_heart_rate_container"
style="@style/StatsLargeItemContainer">
<TextView
style="@style/StatsLargeLabel"
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">
<TextView
@@ -291,20 +297,36 @@ limitations under the License.
android:text="@string/sensor_unit_beats_per_minute" />
</LinearLayout>
</LinearLayout>
<LinearLayout style="@style/StatsSmallGroupContainer">
<LinearLayout
android:id="@+id/stats_sensor_cadence_container"
style="@style/StatsLargeItemContainer">
<TextView
style="@style/StatsSmallLabel"
android:text="@string/settings_sensor_bluetooth_sensor" />
style="@style/StatsLargeLabel"
android:text="@string/sensor_state_cadence" />
<TextView
android:id="@+id/stats_sensor_heart_rate_sensor_value"
android:id="@+id/stats_sensor_cadence_sensor_value"
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>
</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="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>
+17
View File
@@ -16,8 +16,25 @@
<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_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="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_default" translatable="false">@string/activity_type_unknown</string>
<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_connected">Connected to %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 -->
<string name="settings_stats_coordinate">Latitude/Longitude</string>
<string name="settings_stats_rate_title">Preferred rate</string>
+17 -2
View File
@@ -54,8 +54,8 @@ limitations under the License.
</PreferenceCategory>
<PreferenceCategory
android:title="@string/settings_recording"
android:key="@string/settings_recording_key"
android:title="@string/settings_recording"
app:initialExpandedChildrenCount="4">
<SwitchPreferenceCompat
android:defaultValue="@bool/stats_show_on_lockscreen_while_recording_default"
@@ -111,10 +111,25 @@ limitations under the License.
</PreferenceCategory>
<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:key="@string/settings_sensor_bluetooth_heart_rate_key"
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 android:title="@string/generic_tracks">