fix(withings,scanwatch): ECG 40ms and 200ms grid

This commit is contained in:
d3vv3
2026-04-05 15:04:28 +02:00
parent 299f941f65
commit 36baca30fa
7 changed files with 139 additions and 38 deletions
@@ -138,7 +138,7 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
akLightSleep = new ActivityConfig(ActivityKind.LIGHT_SLEEP, getString(R.string.abstract_chart_fragment_kind_light_sleep), AK_LIGHT_SLEEP_COLOR);
akDeepSleep = new ActivityConfig(ActivityKind.DEEP_SLEEP, getString(R.string.abstract_chart_fragment_kind_deep_sleep), AK_DEEP_SLEEP_COLOR);
akRemSleep = new ActivityConfig(ActivityKind.REM_SLEEP, getString(R.string.abstract_chart_fragment_kind_rem_sleep), AK_REM_SLEEP_COLOR);
akAwakeSleep = new ActivityConfig(ActivityKind.REM_SLEEP, getString(R.string.abstract_chart_fragment_kind_awake_sleep), AK_AWAKE_SLEEP_COLOR);
akAwakeSleep = new ActivityConfig(ActivityKind.AWAKE_SLEEP, getString(R.string.abstract_chart_fragment_kind_awake_sleep), AK_AWAKE_SLEEP_COLOR);
akNotWorn = new ActivityConfig(ActivityKind.NOT_WORN, getString(R.string.abstract_chart_fragment_kind_not_worn), AK_NOT_WORN_COLOR);
}
@@ -470,4 +470,3 @@ public abstract class AbstractActivityChartFragment<D extends ChartsData> extend
return sample;
}
}
@@ -1,6 +1,8 @@
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import android.os.Bundle;
import android.graphics.Color;
import android.util.Log;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.MotionEvent;
@@ -56,6 +58,13 @@ public class EcgChartFragment extends AbstractChartFragment<EcgChartFragment.Ecg
private static final int DISPLAY_RESAMPLE_MS = 20;
private static final int WATCH_STYLE_RESAMPLE_MS = 28;
private static final int WATCH_STYLE_AVERAGE_WINDOW = 5;
private static final float ECG_MAJOR_X_GRID_SECONDS = 0.2f;
private static final float ECG_MINOR_X_GRID_SECONDS = 0.04f;
private static final float ECG_MAJOR_Y_GRID = 0.5f;
private static final float ECG_MINOR_Y_GRID = 0.1f;
private static final int ECG_PAPER_BACKGROUND = 0xFFFFFCFC;
private static final int ECG_MAJOR_GRID_COLOR = 0x33E57373;
private static final int ECG_MINOR_GRID_COLOR = 0x1AE57373;
private int backgroundColor;
private int chartTextColor;
@@ -216,7 +225,10 @@ public class EcgChartFragment extends AbstractChartFragment<EcgChartFragment.Ecg
dataSets.add(createDataSet(entries));
chart.setData(new LineData(dataSets));
final float durationSeconds = Math.max(maxDelta / 1000f, 1f);
final long recordDurationMs = data.selectedSummary.getEndTimestamp() - data.selectedSummary.getStartTimestamp();
final float durationSeconds = recordDurationMs > 0
? Math.max(maxDelta / 1000f, recordDurationMs / 1000f)
: Math.max(maxDelta / 1000f, 1f);
chart.getXAxis().setAxisMaximum(durationSeconds);
chart.getXAxis().setLabelCount(6, true);
chart.getXAxis().setValueFormatter(new SessionXAxisFormatter(data.selectedSummary.getStartTimestamp()));
@@ -226,6 +238,9 @@ public class EcgChartFragment extends AbstractChartFragment<EcgChartFragment.Ecg
final float halfRange = Math.max(DISPLAY_Y_AXIS_HALF_RANGE, maxAbs * 1.25f);
leftAxis.setAxisMinimum(-halfRange);
leftAxis.setAxisMaximum(halfRange);
applyEcgGrid(0f, durationSeconds, halfRange);
Log.d("EcgChart", "durationSeconds=" + durationSeconds + " maxDelta=" + maxDelta + " recordDurationMs=" + recordDurationMs + " limitLineCount=" + chart.getXAxis().getLimitLines().size());
chart.fitScreen();
}
if (!data.summaries.isEmpty()) {
@@ -592,7 +607,7 @@ public class EcgChartFragment extends AbstractChartFragment<EcgChartFragment.Ecg
}
private void setupLineChart() {
chart.setBackgroundColor(backgroundColor);
chart.setBackgroundColor(ECG_PAPER_BACKGROUND);
chart.getDescription().setEnabled(false);
chart.getLegend().setEnabled(false);
chart.setDoubleTapToZoomEnabled(false);
@@ -609,18 +624,59 @@ public class EcgChartFragment extends AbstractChartFragment<EcgChartFragment.Ecg
xAxis.setAxisMinimum(0f);
xAxis.setAxisMaximum(30f);
xAxis.setLabelCount(6, true);
xAxis.setGranularity(1f);
xAxis.setDrawLimitLinesBehindData(true);
final YAxis leftAxis = chart.getAxisLeft();
leftAxis.setDrawGridLines(true);
leftAxis.setDrawGridLines(false);
leftAxis.setTextColor(chartTextColor);
leftAxis.setAxisMinimum(-2f);
leftAxis.setAxisMaximum(2f);
leftAxis.setDrawLimitLinesBehindData(true);
final YAxis rightAxis = chart.getAxisRight();
rightAxis.setEnabled(true);
rightAxis.setDrawLabels(false);
rightAxis.setDrawGridLines(false);
rightAxis.setDrawAxisLine(true);
applyEcgGrid(0f, 30f, 2f);
}
private void applyEcgGrid(final float minX, final float maxX, final float halfRange) {
final XAxis xAxis = chart.getXAxis();
xAxis.removeAllLimitLines();
final int minorPerMajorX = Math.round(ECG_MAJOR_X_GRID_SECONDS / ECG_MINOR_X_GRID_SECONDS);
final int firstMinorIndex = (int) Math.floor(minX / ECG_MINOR_X_GRID_SECONDS);
for (int i = firstMinorIndex; ; i++) {
final float second = i * ECG_MINOR_X_GRID_SECONDS;
if (second > maxX + 0.0001f) break;
final boolean major = (i % minorPerMajorX) == 0;
xAxis.addLimitLine(createGridLine(second, major ? ECG_MAJOR_GRID_COLOR : ECG_MINOR_GRID_COLOR, major ? 0.9f : 0.45f));
}
final YAxis leftAxis = chart.getAxisLeft();
leftAxis.removeAllLimitLines();
final int minorPerMajorY = Math.round(ECG_MAJOR_Y_GRID / ECG_MINOR_Y_GRID);
for (int i = 0; ; i++) {
final float value = i * ECG_MINOR_Y_GRID;
if (value > halfRange + 0.0001f) break;
final boolean major = (i % minorPerMajorY) == 0;
leftAxis.addLimitLine(createGridLine(value, major ? ECG_MAJOR_GRID_COLOR : ECG_MINOR_GRID_COLOR, major ? 0.9f : 0.45f));
if (i > 0) {
leftAxis.addLimitLine(createGridLine(-value, major ? ECG_MAJOR_GRID_COLOR : ECG_MINOR_GRID_COLOR, major ? 0.9f : 0.45f));
}
}
}
private com.github.mikephil.charting.components.LimitLine createGridLine(final float value,
final int color,
final float width) {
final com.github.mikephil.charting.components.LimitLine line = new com.github.mikephil.charting.components.LimitLine(value);
line.setLineColor(color);
line.setLineWidth(width);
line.setLabel("");
return line;
}
private void setupChartTouchHandling() {
@@ -90,6 +90,11 @@ public class WithingsScanwatchDeviceCoordinator extends AbstractBLEDeviceCoordin
return true;
}
@Override
public boolean supportsAwakeSleep(@NonNull final GBDevice device) {
return true;
}
@Override
public boolean supportsDataFetching(@NonNull final GBDevice device) {
return true;
@@ -38,6 +38,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.comm
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ActivitySampleMovement;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ActivitySampleSleep;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ActivitySampleTime;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ActivitySampleWalk;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.ActivityHeartrate;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructure;
import nodomain.freeyourgadget.gadgetbridge.service.devices.withingssteelhr.communication.datastructures.WithingsStructureType;
@@ -146,6 +147,13 @@ public class ActivitySampleHandler extends AbstractResponseHandler {
}
private void handleWalk(WithingsStructure data) {
final ActivitySampleWalk walk = (ActivitySampleWalk) data;
if (isLikelyAwakeSleep(walk)) {
activityEntry.setRawKind(ActivityKind.AWAKE_SLEEP.getCode());
activityEntry.setRawIntensity(5);
return;
}
activityEntry.setRawKind(ActivityKind.WALKING.getCode());
}
@@ -270,4 +278,11 @@ public class ActivitySampleHandler extends AbstractResponseHandler {
heartRateEntry.setCalories(activityEntry.getCalories());
}
}
private boolean isLikelyAwakeSleep(final ActivitySampleWalk walk) {
return walk.getLevel() == 0
&& activityEntry.getSteps() == 0
&& activityEntry.getDistance() == 0
&& activityEntry.getRawKind() == ActivityKind.UNKNOWN.getCode();
}
}
@@ -189,10 +189,12 @@ public class StoredMeasureSignalHandler implements ResponseHandler {
if (signalType != ECG_WAVEFORM_SIGNAL_TYPE) {
logger.info("Ignoring ECG markers on stored-measure signalType={} cursor={} so mixed SpO2 pages can advance without invalid ECG fetch fallback",
signalType, currentCursor);
} else if (support.hasDiscoveredEcgRecord(ecgMetaToNotify)) {
logger.info("Stored-measure page at cursor={} contains an already-seen ECG record; ignoring marker so SpO2 loop retry logic can delete it", currentCursor);
} else {
logger.info("Stored-measure page at cursor={} contains ECG markers; scheduling ECG fetch and stopping SpO2 loop to allow ECG fetch to complete and delete the cursor", currentCursor);
if (support.hasDiscoveredEcgRecord(ecgMetaToNotify)) {
logger.info("Stored-measure ECG waveform page at cursor={} contains an already-seen ECG record; re-fetching it so the dedicated ECG delete path can retry", currentCursor);
} else {
logger.info("Stored-measure page at cursor={} contains ECG markers; scheduling ECG fetch and stopping SpO2 loop to allow ECG fetch to complete and delete the cursor", currentCursor);
}
support.notifyEcgRecordDiscovered(ecgMetaToNotify, signalType);
return;
}
@@ -297,7 +297,12 @@ public class WithingsEcgHandler implements ResponseHandler {
activeWaveformHandler = null;
waveformFetchQueued = false;
if (foundViaSpO2Loop) {
support.queueGetStoredMeasureSignal(originatingSignalType, 0, new StoredMeasureSignalHandler(support, device, originatingSignalType));
if (originatingSignalType == ECG_WAVEFORM_SIGNAL_TYPE && !waveform.isEmpty()) {
logger.warn("Stopping ECG waveform paging loop after failed delete verification for ts={} so sync can finish; ECG may remain on watch",
startTimestampMs);
} else {
support.queueGetStoredMeasureSignal(originatingSignalType, 0, new StoredMeasureSignalHandler(support, device, originatingSignalType));
}
} else if (discoveryPagesSeen < MAX_DISCOVERY_PAGES) {
queueDiscoveryProbe(false);
}
@@ -10,12 +10,14 @@ import nodomain.freeyourgadget.gadgetbridge.model.EcgSample;
public final class EcgInterpretationUtil {
private static final int MIN_SAMPLES_FOR_ANALYSIS = 600;
private static final int MIN_RR_INTERVALS_FOR_IRREGULAR = 5;
private static final float MIN_SIGNAL_RANGE = 12f;
private static final float MAX_DERIVED_HR_DELTA_BPM = 10f;
private static final float MAX_DERIVED_HR_DELTA_RATIO = 0.18f;
private static final float RELAXED_DERIVED_HR_DELTA_BPM = 14f;
private static final float RELAXED_DERIVED_HR_DELTA_RATIO = 0.22f;
private static final int MIN_RR_INTERVALS_FOR_IRREGULAR = 4;
private static final int MIN_RR_INTERVALS_FOR_NORMAL_HINT = 3;
private static final float MIN_SIGNAL_RANGE = 0.4f;
private static final float MIN_SIGNAL_RANGE_FOR_NORMAL_HINT = 0.3f;
private static final float MAX_DERIVED_HR_DELTA_BPM = 12f;
private static final float MAX_DERIVED_HR_DELTA_RATIO = 0.20f;
private static final float RELAXED_DERIVED_HR_DELTA_BPM = 16f;
private static final float RELAXED_DERIVED_HR_DELTA_RATIO = 0.25f;
private static final float STRONG_IRREGULAR_RR_CV = 0.40f;
private static final float STRONG_IRREGULAR_RR_SPREAD_SECONDS = 0.50f;
private static final float STRONG_IRREGULAR_RR_SPREAD_RATIO = 0.60f;
@@ -35,7 +37,7 @@ public final class EcgInterpretationUtil {
public static EcgInterpretation interpret(final EcgRecord record, final List<EcgSample> waveform) {
final EcgInterpretation.DeviceHint deviceHint = toDeviceHint(record.getDeviceHintCode());
if (waveform == null || waveform.size() < MIN_SAMPLES_FOR_ANALYSIS) {
if (waveform == null || waveform.isEmpty()) {
return new EcgInterpretation(deviceHint, EcgInterpretation.SignalQuality.INCONCLUSIVE, EcgInterpretation.Rhythm.INCONCLUSIVE);
}
@@ -43,7 +45,6 @@ public final class EcgInterpretationUtil {
float min = Float.MAX_VALUE;
float max = -Float.MAX_VALUE;
float totalAbsDiff = 0f;
float clippedSamples = 0f;
float previous = waveform.get(0).getValue();
values[0] = previous;
@@ -55,34 +56,52 @@ public final class EcgInterpretationUtil {
if (i > 0) {
totalAbsDiff += Math.abs(value - previous);
}
if (Math.abs(value) >= 120f) {
clippedSamples++;
}
previous = value;
}
final float range = max - min;
final float averageAbsDiff = totalAbsDiff / Math.max(1, waveform.size() - 1);
// Detect ADC clipping: count samples at the top/bottom 0.5% of the range.
// A high fraction there indicates signal saturation rather than legitimate peaks.
final float clipWindow = range * 0.005f;
float clippedSamples = 0f;
for (int i = 0; i < waveform.size(); i++) {
final float value = values[i];
if (value >= max - clipWindow || value <= min + clipWindow) {
clippedSamples++;
}
}
final float clippedRatio = clippedSamples / waveform.size();
final EcgInterpretation.SignalQuality signalQuality;
if (range < MIN_SIGNAL_RANGE) {
if (range < MIN_SIGNAL_RANGE_FOR_NORMAL_HINT) {
signalQuality = EcgInterpretation.SignalQuality.NOISY;
} else if (clippedRatio > 0.12f || averageAbsDiff > range * 0.45f) {
} else if (clippedRatio > 0.40f || averageAbsDiff > range * 1.10f) {
signalQuality = EcgInterpretation.SignalQuality.NOISY;
} else {
signalQuality = EcgInterpretation.SignalQuality.GOOD;
}
final boolean normalHintWithUsableSignal = deviceHint == EcgInterpretation.DeviceHint.NORMAL
&& signalQuality == EcgInterpretation.SignalQuality.GOOD
&& range >= MIN_SIGNAL_RANGE_FOR_NORMAL_HINT;
if (waveform.size() < MIN_SAMPLES_FOR_ANALYSIS) {
final EcgInterpretation.Rhythm rhythm = normalHintWithUsableSignal
? EcgInterpretation.Rhythm.REGULAR
: EcgInterpretation.Rhythm.INCONCLUSIVE;
return new EcgInterpretation(deviceHint, signalQuality, rhythm);
}
final float sampleRateHz = estimateSampleRate(record, waveform);
final float[] smoothedValues = smooth(values, Math.max(7, Math.round(sampleRateHz * 0.04f) | 1));
final List<Integer> peaks = detectPeaks(smoothedValues, sampleRateHz, record.getAverageHeartRate());
if (peaks.size() < 3) {
return new EcgInterpretation(deviceHint, signalQuality, EcgInterpretation.Rhythm.INCONCLUSIVE);
}
if (record.getAverageHeartRate() > 0 && !hasPlausiblePeakCount(record, peaks.size(), 0.55f, 1.45f)) {
return new EcgInterpretation(deviceHint, signalQuality, EcgInterpretation.Rhythm.INCONCLUSIVE);
final EcgInterpretation.Rhythm rhythm = normalHintWithUsableSignal && peaks.size() >= 2
? EcgInterpretation.Rhythm.REGULAR
: EcgInterpretation.Rhythm.INCONCLUSIVE;
return new EcgInterpretation(deviceHint, signalQuality, rhythm);
}
final List<Float> rrIntervals = new ArrayList<>(peaks.size() - 1);
@@ -109,13 +128,13 @@ public final class EcgInterpretationUtil {
final float rrCoeffVar = rrMean > 0 ? rrStdDev / rrMean : 0f;
final float derivedHeartRate = rrMean > 0 ? 60f / rrMean : 0f;
final boolean strictPeakCountPlausible = record.getAverageHeartRate() <= 0 || hasPlausiblePeakCount(record, peaks.size(), 0.55f, 1.45f);
final boolean relaxedPeakCountPlausible = record.getAverageHeartRate() <= 0 || hasPlausiblePeakCount(record, peaks.size(), 0.50f, 1.50f);
final boolean strictPeakCountPlausible = record.getAverageHeartRate() <= 0 || hasPlausiblePeakCount(record, peaks.size(), 0.50f, 1.50f);
final boolean relaxedPeakCountPlausible = record.getAverageHeartRate() <= 0 || hasPlausiblePeakCount(record, peaks.size(), 0.45f, 1.55f);
final boolean strictHeartRateMatch = record.getAverageHeartRate() <= 0
|| Math.abs(derivedHeartRate - record.getAverageHeartRate()) <= Math.max(MAX_DERIVED_HR_DELTA_BPM, record.getAverageHeartRate() * MAX_DERIVED_HR_DELTA_RATIO);
final boolean relaxedHeartRateMatch = record.getAverageHeartRate() <= 0
|| Math.abs(derivedHeartRate - record.getAverageHeartRate()) <= Math.max(RELAXED_DERIVED_HR_DELTA_BPM, record.getAverageHeartRate() * RELAXED_DERIVED_HR_DELTA_RATIO);
final boolean looksRegular = rrCoeffVar <= 0.18f && (rrMax - rrMin) <= Math.max(0.22f, rrMean * 0.28f);
final boolean looksRegular = rrCoeffVar <= 0.20f && (rrMax - rrMin) <= Math.max(0.25f, rrMean * 0.32f);
final boolean irregular = rrCoeffVar > 0.34f || (rrMax - rrMin) > Math.max(0.45f, rrMean * 0.55f);
final boolean stronglyIrregular = rrCoeffVar > STRONG_IRREGULAR_RR_CV
|| (rrMax - rrMin) > Math.max(STRONG_IRREGULAR_RR_SPREAD_SECONDS, rrMean * STRONG_IRREGULAR_RR_SPREAD_RATIO);
@@ -123,23 +142,23 @@ public final class EcgInterpretationUtil {
final boolean normalHint = deviceHint == EcgInterpretation.DeviceHint.NORMAL;
final boolean canTrustNormalHint = normalHint
&& signalQuality == EcgInterpretation.SignalQuality.GOOD
&& relaxedPeakCountPlausible
&& relaxedHeartRateMatch;
&& rrIntervals.size() >= MIN_RR_INTERVALS_FOR_NORMAL_HINT;
final EcgInterpretation.Rhythm rhythm;
if (signalQuality == EcgInterpretation.SignalQuality.NOISY) {
rhythm = EcgInterpretation.Rhythm.INCONCLUSIVE;
} else if (rrIntervals.size() < MIN_RR_INTERVALS_FOR_IRREGULAR) {
rhythm = EcgInterpretation.Rhythm.INCONCLUSIVE;
} else if (canTrustNormalHint && !stronglyIrregular) {
} else if (canTrustNormalHint) {
// Device says Normal, signal is good, enough RR intervals detected - trust the device
rhythm = EcgInterpretation.Rhythm.REGULAR;
} else if (rrIntervals.size() < MIN_RR_INTERVALS_FOR_IRREGULAR) {
rhythm = normalHintWithUsableSignal ? EcgInterpretation.Rhythm.REGULAR : EcgInterpretation.Rhythm.INCONCLUSIVE;
} else if (stronglyIrregular && !normalHint) {
rhythm = EcgInterpretation.Rhythm.IRREGULAR;
} else if (irregular && !canTrustNormalHint) {
rhythm = EcgInterpretation.Rhythm.IRREGULAR;
} else if (strictPeakCountPlausible && strictHeartRateMatch) {
rhythm = EcgInterpretation.Rhythm.REGULAR;
} else if (canTrustNormalHint && looksRegular) {
rhythm = EcgInterpretation.Rhythm.REGULAR;
} else if (canTrustNormalHint) {
} else if (normalHintWithUsableSignal && (relaxedPeakCountPlausible || relaxedHeartRateMatch || looksRegular)) {
rhythm = EcgInterpretation.Rhythm.REGULAR;
} else {
rhythm = EcgInterpretation.Rhythm.INCONCLUSIVE;