From 82e3a86350f9cd7879b5208c1969c8acd3bb95be Mon Sep 17 00:00:00 2001 From: "Martin.JM" Date: Mon, 28 Oct 2024 11:44:52 +0100 Subject: [PATCH] Implement high res HR data Specifically for: - The HR fragment - The sports activity graph Also adds support for Huawei high res HR, and high res SpO2. --- .../ActivitySummariesChartFragment.java | 10 ++- .../charts/AbstractActivityChartFragment.java | 58 ++++++++++++----- .../charts/HeartRateDailyFragment.java | 2 +- .../devices/AbstractSampleProvider.java | 27 +++++++- .../gadgetbridge/devices/SampleProvider.java | 14 ++++ .../devices/UnknownDeviceCoordinator.java | 11 ++++ .../devices/huawei/HuaweiSampleProvider.java | 65 ++++++++++++++++++- .../huawei/HuaweiSpo2SampleProvider.java | 3 +- 8 files changed, 169 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesChartFragment.java index 35cffc9cc..2eae36ef9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesChartFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesChartFragment.java @@ -152,6 +152,11 @@ public class ActivitySummariesChartFragment extends AbstractActivityChartFragmen return getAllSamples(db, device, tsFrom, tsTo); } + @Override + protected List getSamplesHighRes(DBHandler db, GBDevice device, int tsFrom, int tsTo) { + return getAllSamplesHighRes(db, device, tsFrom, tsTo); + } + @Override protected void setupLegend(Chart chart) { List legendEntries = new ArrayList<>(5); @@ -231,9 +236,12 @@ public class ActivitySummariesChartFragment extends AbstractActivityChartFragmen private DefaultChartsData buildChartFromSamples(DBHandler handler) { final List samples = getAllSamples(handler, gbDevice, startTime, endTime); + final List highResSamples = getAllSamplesHighRes(handler, gbDevice, startTime, endTime); try { - return refresh(gbDevice, samples); + if (highResSamples == null) + return refresh(gbDevice, samples); + return refresh(gbDevice, samples, highResSamples); } catch (Exception e) { LOG.error("Unable to get charts data right now", e); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java index 306ed2c25..e118ee9f9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/AbstractActivityChartFragment.java @@ -27,6 +27,7 @@ import com.github.mikephil.charting.data.LineDataSet; import com.github.mikephil.charting.formatter.ValueFormatter; import com.github.mikephil.charting.interfaces.datasets.ILineDataSet; +import org.apache.commons.lang3.NotImplementedException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -180,14 +181,28 @@ public abstract class AbstractActivityChartFragment extend return provider.getAllActivitySamples(tsFrom, tsTo); } + protected List getAllSamplesHighRes(DBHandler db, GBDevice device, int tsFrom, int tsTo) { + SampleProvider provider = getProvider(db, device); + // Only retrieve if the provider signals it has high res data, otherwise it is useless + if (provider.hasHighResData()) + return provider.getAllActivitySamplesHighRes(tsFrom, tsTo); + return null; + } + protected List getActivitySamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) { SampleProvider provider = getProvider(db, device); return provider.getActivitySamples(tsFrom, tsTo); } public DefaultChartsData refresh(GBDevice gbDevice, List samples) { + // If there is no high res samples, all the samples are high res samples + return refresh(gbDevice, samples, samples); + } + + public DefaultChartsData refresh(GBDevice gbDevice, List samples, List highResSamples) { TimestampTranslation tsTranslation = new TimestampTranslation(); LOG.info("{}: number of samples: {}", getTitle(), samples.size()); + LOG.info("{}: number of high res samples: {}", getTitle(), highResSamples.size()); LineData lineData; if (samples.isEmpty()) { @@ -257,19 +272,25 @@ public abstract class AbstractActivityChartFragment extend } entries.get(index).add(createLineEntry(value, ts)); - // heart rate line graph - if (hr && type != ActivityKind.NOT_WORN && heartRateUtilsInstance.isValidHeartRateValue(sample.getHeartRate())) { - if (lastHrSampleIndex > -1 && ts - lastHrSampleIndex > 1800*HeartRateUtils.MAX_HR_MEASUREMENTS_GAP_MINUTES) { - heartrateEntries.add(createLineEntry(0, lastHrSampleIndex + 1)); - heartrateEntries.add(createLineEntry(0, ts - 1)); - } - heartrateEntries.add(createLineEntry(sample.getHeartRate(), ts)); - lastHrSampleIndex = ts; - } last_type = type; last_value = value; } + // Currently only for HR + if (hr) { + for (ActivitySample sample : highResSamples) { + if (sample.getKind() != ActivityKind.NOT_WORN && heartRateUtilsInstance.isValidHeartRateValue(sample.getHeartRate())) { + int ts = tsTranslation.shorten(sample.getTimestamp()); + if (lastHrSampleIndex > -1 && ts - lastHrSampleIndex > 1800*HeartRateUtils.MAX_HR_MEASUREMENTS_GAP_MINUTES) { + heartrateEntries.add(createLineEntry(0, lastHrSampleIndex + 1)); + heartrateEntries.add(createLineEntry(0, ts - 1)); + } + heartrateEntries.add(createLineEntry(sample.getHeartRate(), ts)); + lastHrSampleIndex = ts; + } + } + } + // convert Entry Lists to Datasets List lineDataSets = new ArrayList<>(); @@ -364,15 +385,16 @@ public abstract class AbstractActivityChartFragment extend /** * Implement this to supply the samples to be displayed. - * - * @param db - * @param device - * @param tsFrom - * @param tsTo - * @return */ protected abstract List getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo); + /** + * Implement this to supply high resolution data + */ + protected List getSamplesHighRes(DBHandler db, GBDevice device, int tsFrom, int tsTo) { + throw new NotImplementedException("High resolution samples have not been implemented for this chart."); + } + protected List getSamples(DBHandler db, GBDevice device) { int tsStart = getTSStart(); int tsEnd = getTSEnd(); @@ -388,6 +410,12 @@ public abstract class AbstractActivityChartFragment extend return samples; } + protected List getSamplesHighRes(DBHandler db, GBDevice device) { + int tsStart = getTSStart(); + int tsEnd = getTSEnd(); + return getSamplesHighRes(db, device, tsStart, tsEnd); + } + protected List getSamplesofSleep(DBHandler db, GBDevice device) { int SLEEP_HOUR_LIMIT = 12; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HeartRateDailyFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HeartRateDailyFragment.java index 83bdb3a24..7bd773d6e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HeartRateDailyFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/HeartRateDailyFragment.java @@ -92,7 +92,7 @@ public class HeartRateDailyFragment extends AbstractChartFragment getActivitySamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) { SampleProvider provider = device.getDeviceCoordinator().getSampleProvider(device, db.getDaoSession()); - return provider.getAllActivitySamples(tsFrom, tsTo); + return provider.getAllActivitySamplesHighRes(tsFrom, tsTo); } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleProvider.java index c78d7e095..3712d4f91 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractSampleProvider.java @@ -76,6 +76,17 @@ public abstract class AbstractSampleProvider i return getGBActivitySamples(timestamp_from, timestamp_to); } + @NonNull + @Override + public List getAllActivitySamplesHighRes(int timestamp_from, int timestamp_to) { + return getGBActivitySamplesHighRes(timestamp_from, timestamp_to); + } + + @Override + public boolean hasHighResData() { + return false; + } + @NonNull @Override @Deprecated // use getAllActivitySamples @@ -138,7 +149,7 @@ public abstract class AbstractSampleProvider i } /** - * Get the activity samples between two timestamps. Exactly one every minute. + * Get the activity samples between two timestamps (inclusive). Exactly one every minute. * @param timestamp_from Start timestamp * @param timestamp_to End timestamp * @return Exactly one sample for every minute @@ -162,6 +173,20 @@ public abstract class AbstractSampleProvider i return samples; } + /** + * Get the activity samples between two timestamps (inclusive). + * Differs from {@link #getGBActivitySamples(int, int)} in that it supplies as many samples as + * available. + * It assumes {@link #getGBActivitySamples(int, int)} returns the highest resolution data unless + * this is overwritten. + * @param timestamp_from Start timestamp + * @param timestamp_to End timestamp + * @return All the samples between start and end timestamp (inclusive) + */ + protected List getGBActivitySamplesHighRes(int timestamp_from, int timestamp_to) { + return getGBActivitySamples(timestamp_from, timestamp_to); + } + /** * Detaches all samples of this type from the session. Changes to them may not be * written back to the database. diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SampleProvider.java index 7b008d21d..2ec813eda 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SampleProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SampleProvider.java @@ -47,6 +47,7 @@ public interface SampleProvider { /** * Returns the list of all samples, of any type, within the given time span. + * This returns exactly one sample every minute. * @param timestamp_from the start timestamp * @param timestamp_to the end timestamp * @return the list of samples of any type @@ -54,6 +55,19 @@ public interface SampleProvider { @NonNull List getAllActivitySamples(int timestamp_from, int timestamp_to); + /** + * Same as {@link #getAllActivitySamples(int, int)}}, but returns as many samples as possible. + * Explicitly does not make a guarantee about how many samples there are per timeframe, which + * can also change over time. + */ + List getAllActivitySamplesHighRes(int timestamp_from, int timestamp_to); + + /** + * Specifies that the sample provider has higher resolution data. Set to true if the sample + * provider can provide more than one sample a minute. + */ + boolean hasHighResData(); + /** * Returns the list of all samples that represent user "activity", within * the given time span. This excludes samples of type sleep, for example. diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/UnknownDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/UnknownDeviceCoordinator.java index b5db76d6c..77e6935d4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/UnknownDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/UnknownDeviceCoordinator.java @@ -21,6 +21,7 @@ import android.app.Activity; import android.content.Context; import android.net.Uri; +import java.util.Collections; import java.util.List; import androidx.annotation.DrawableRes; @@ -64,6 +65,16 @@ public class UnknownDeviceCoordinator extends AbstractDeviceCoordinator { return null; } + @Override + public List getAllActivitySamplesHighRes(int timestamp_from, int timestamp_to) { + return null; + } + + @Override + public boolean hasHighResData() { + return false; + } + @Override public List getActivitySamples(int timestamp_from, int timestamp_to) { return null; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSampleProvider.java index 22a375901..aa4594748 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSampleProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSampleProvider.java @@ -305,12 +305,24 @@ public class HuaweiSampleProvider extends AbstractSampleProvider getGBActivitySamplesHighRes(int timestamp_from, int timestamp_to) { + List processedSamples = getRawOrderedActivitySamples(timestamp_from, timestamp_to); + addWorkoutSamples(processedSamples, timestamp_from, timestamp_to); + return processedSamples; + } + + @Override + public boolean hasHighResData() { + return true; + } + private HuaweiActivitySample createDummySample(int timestamp) { HuaweiActivitySample activitySample = new HuaweiActivitySample( timestamp, -1, -1, - 0, + timestamp + 60, // Make sure the duration is 60 (byte) 0x00, ActivitySample.NOT_MEASURED, 0, @@ -438,7 +450,7 @@ public class HuaweiSampleProvider extends AbstractSampleProvider workoutSamples = getRawOrderedWorkoutSamplesWithHeartRate(timestamp_from, timestamp_to); for (int i = 0; i < workoutSamples.size(); i++) { - // Look ahead to see if this is still the same workout + // Look behind to see if this is still the same workout boolean inWorkout = i != 0 && workoutSamples.get(i).getWorkoutId() == workoutSamples.get(i - 1).getWorkoutId(); // Skip the processed sample that are before this workout sample @@ -470,4 +482,53 @@ public class HuaweiSampleProvider extends AbstractSampleProvider processedSamples, int timestamp_from, int timestamp_to) { + int currentIndex = 0; + List workoutSamples = getRawOrderedWorkoutSamplesWithHeartRate(timestamp_from, timestamp_to); + + for (int i = 0; i < workoutSamples.size(); i++) { + // Look behind to see if this is still the same workout + boolean inWorkout = i != 0 && workoutSamples.get(i).getWorkoutId() == workoutSamples.get(i - 1).getWorkoutId(); + + // Skip the samples that are before this workout sample, and potentially clear the HR + // and intensity - see #4126 for the reasoning + while (currentIndex < processedSamples.size() && workoutSamples.get(i).getTimestamp() > processedSamples.get(currentIndex).getTimestamp()) { + if (inWorkout) { + processedSamples.get(currentIndex).setHeartRate(ActivitySample.NOT_MEASURED); + processedSamples.get(currentIndex).setRawIntensity(0); + } + + currentIndex += 1; + } + + if (i < workoutSamples.size() - 1) { + processedSamples.add(currentIndex, convertWorkoutSampleToActivitySample(workoutSamples.get(i), workoutSamples.get(i + 1).getTimestamp())); + } else { + // For the last workout sample we assume it is over 5 seconds + processedSamples.add(currentIndex, convertWorkoutSampleToActivitySample(workoutSamples.get(i), workoutSamples.get(i).getTimestamp() + 5)); + } + currentIndex += 1; // Prevent clearing the sample in the next loop + } + } + + private HuaweiActivitySample convertWorkoutSampleToActivitySample(HuaweiWorkoutDataSample workoutSample, int nextTimestamp) { + int hr = workoutSample.getHeartRate() & 0xFF; + HuaweiActivitySample newSample = new HuaweiActivitySample( + workoutSample.getTimestamp(), + -1, + -1, + nextTimestamp - 1, // Just to prevent overlap causing issues + (byte) 0x00, + ActivitySample.NOT_MEASURED, + ActivitySample.NOT_MEASURED, + ActivitySample.NOT_MEASURED, + ActivitySample.NOT_MEASURED, + ActivitySample.NOT_MEASURED, + ActivitySample.NOT_MEASURED, + hr + ); + newSample.setProvider(this); + return newSample; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java index a6a5d091f..4c7ce80f3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java @@ -66,7 +66,8 @@ public class HuaweiSpo2SampleProvider extends AbstractTimeSampleProvider getAllSamples(long timestampFrom, long timestampTo) { - List activitySamples = huaweiSampleProvider.getAllActivitySamples((int) (timestampFrom / 1000L), (int) (timestampTo / 1000L)); + // Using high res data is fine for the SpO2 sample provider at the time of writing + List activitySamples = huaweiSampleProvider.getAllActivitySamplesHighRes((int) (timestampFrom / 1000L), (int) (timestampTo / 1000L)); List spo2Samples = new ArrayList<>(activitySamples.size()); for (HuaweiActivitySample sample : activitySamples) { if (sample.getSpo() == -1)