/* Copyright (C) 2024 Damien Gaignon, Martin.JM This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.devices.huawei; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import de.greenrobot.dao.AbstractDao; import de.greenrobot.dao.Property; import de.greenrobot.dao.query.QueryBuilder; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiActivitySampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutDataSample; import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutDataSampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySample; import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutSummarySampleDao; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; public class HuaweiSampleProvider extends AbstractSampleProvider { /* * We save all data by saving a marker at the begin and end. We do not actively use these for * showing the data at the moment, but the samples are still saved as such, to keep the table * entries consistent. * Meaning of fields that are not self-explanatory: * - `otherTimestamp` * The timestamp of the other marker, if it's larger this is the begin, otherwise the end * - `source` * The source of the data, which Huawei Band message the data came from * 0x0d for sleep data from activity, 0x0a for TruSleep data */ private static class RawTypes { public static final int NOT_MEASURED = -1; public static final int UNKNOWN = 1; public static final int DEEP_SLEEP = 0x07; public static final int LIGHT_SLEEP = 0x06; } public HuaweiSampleProvider(GBDevice device, DaoSession session) { super(device, session); } @Override public ActivityKind normalizeType(int rawType) { switch (rawType) { case RawTypes.DEEP_SLEEP: return ActivityKind.DEEP_SLEEP; case RawTypes.LIGHT_SLEEP: return ActivityKind.LIGHT_SLEEP; default: return ActivityKind.UNKNOWN; } } @Override public int toRawActivityKind(ActivityKind activityKind) { switch (activityKind) { case DEEP_SLEEP: return RawTypes.DEEP_SLEEP; case LIGHT_SLEEP: return RawTypes.LIGHT_SLEEP; default: return RawTypes.NOT_MEASURED; } } @Override public float normalizeIntensity(int rawIntensity) { return rawIntensity; } @Override public AbstractDao getSampleDao() { return getSession().getHuaweiActivitySampleDao(); } @Nullable @Override protected Property getRawKindSampleProperty() { return HuaweiActivitySampleDao.Properties.RawKind; } @NonNull @Override protected Property getTimestampSampleProperty() { return HuaweiActivitySampleDao.Properties.Timestamp; } @NonNull @Override protected Property getDeviceIdentifierSampleProperty() { return HuaweiActivitySampleDao.Properties.DeviceId; } @Override public HuaweiActivitySample createActivitySample() { return new HuaweiActivitySample(); } private int getLastFetchTimestamp(QueryBuilder qb) { Device dbDevice = DBHelper.findDevice(getDevice(), getSession()); if (dbDevice == null) return 0; Property deviceProperty = HuaweiActivitySampleDao.Properties.DeviceId; Property timestampProperty = HuaweiActivitySampleDao.Properties.Timestamp; qb.where(deviceProperty.eq(dbDevice.getId())) .orderDesc(timestampProperty) .limit(1); List samples = qb.build().list(); if (samples.isEmpty()) return 0; HuaweiActivitySample sample = samples.get(0); return sample.getTimestamp(); } /** * Gets last timestamp where the sleep data has been fully synchronized * @return Last fully synchronized timestamp for sleep data */ public int getLastSleepFetchTimestamp() { QueryBuilder qb = getSampleDao().queryBuilder(); Property sourceProperty = HuaweiActivitySampleDao.Properties.Source; Property activityTypeProperty = HuaweiActivitySampleDao.Properties.RawKind; qb.where( qb.or( sourceProperty.eq(0x0d), sourceProperty.eq(0x0a) ), qb.or( activityTypeProperty.eq(RawTypes.LIGHT_SLEEP), activityTypeProperty.eq(RawTypes.DEEP_SLEEP) ) ); return getLastFetchTimestamp(qb); } /** * Gets last timestamp where the step data has been fully synchronized * @return Last fully synchronized timestamp for step data */ public int getLastStepFetchTimestamp() { QueryBuilder qb = getSampleDao().queryBuilder(); Property sourceProperty = HuaweiActivitySampleDao.Properties.Source; qb.where(sourceProperty.eq(0x0b)); return getLastFetchTimestamp(qb); } /** * Makes a copy of a sample * @param sample The sample to copy * @return The copy of the sample */ private HuaweiActivitySample copySample(HuaweiActivitySample sample) { HuaweiActivitySample sampleCopy = new HuaweiActivitySample( sample.getTimestamp(), sample.getDeviceId(), sample.getUserId(), sample.getOtherTimestamp(), sample.getSource(), sample.getRawKind(), sample.getRawIntensity(), sample.getSteps(), sample.getCalories(), sample.getDistance(), sample.getSpo(), sample.getHeartRate() ); sampleCopy.setProvider(sample.getProvider()); return sampleCopy; } @Override public void addGBActivitySample(HuaweiActivitySample activitySample) { HuaweiActivitySample start = copySample(activitySample); HuaweiActivitySample end = copySample(activitySample); end.setTimestamp(start.getOtherTimestamp()); end.setSteps(ActivitySample.NOT_MEASURED); end.setCalories(ActivitySample.NOT_MEASURED); end.setDistance(ActivitySample.NOT_MEASURED); end.setSpo(ActivitySample.NOT_MEASURED); end.setHeartRate(ActivitySample.NOT_MEASURED); end.setOtherTimestamp(start.getTimestamp()); getSampleDao().insertOrReplace(start); getSampleDao().insertOrReplace(end); } @Override public void addGBActivitySamples(HuaweiActivitySample[] activitySamples) { List newSamples = new ArrayList<>(); for (HuaweiActivitySample sample : activitySamples) { HuaweiActivitySample start = copySample(sample); HuaweiActivitySample end = copySample(sample); end.setTimestamp(start.getOtherTimestamp()); end.setSteps(ActivitySample.NOT_MEASURED); end.setCalories(ActivitySample.NOT_MEASURED); end.setDistance(ActivitySample.NOT_MEASURED); end.setSpo(ActivitySample.NOT_MEASURED); end.setHeartRate(ActivitySample.NOT_MEASURED); end.setOtherTimestamp(start.getTimestamp()); newSamples.add(start); newSamples.add(end); } getSampleDao().insertOrReplaceInTx(newSamples); } /** * Gets the activity samples, ordered by timestamp * @param timestampFrom Start timestamp * @param timestampTo End timestamp * @return List of activities between the timestamps, ordered by timestamp */ private List getRawOrderedActivitySamples(int timestampFrom, int timestampTo) { QueryBuilder qb = getSampleDao().queryBuilder(); Property timestampProperty = getTimestampSampleProperty(); Device dbDevice = DBHelper.findDevice(getDevice(), getSession()); if (dbDevice == null) { // no device, no samples return Collections.emptyList(); } Property deviceProperty = getDeviceIdentifierSampleProperty(); qb.where(deviceProperty.eq(dbDevice.getId()), timestampProperty.ge(timestampFrom)) .where(timestampProperty.le(timestampTo)) .orderAsc(timestampProperty); List samples = qb.build().list(); for (HuaweiActivitySample sample : samples) { sample.setProvider(this); } detachFromSession(); return samples; } private List getRawOrderedWorkoutSamplesWithHeartRate(int timestampFrom, int timestampTo) { Device dbDevice = DBHelper.findDevice(getDevice(), getSession()); if (dbDevice == null) return Collections.emptyList(); QueryBuilder qb = getSession().getHuaweiWorkoutDataSampleDao().queryBuilder(); Property timestampProperty = HuaweiWorkoutDataSampleDao.Properties.Timestamp; Property heartRateProperty = HuaweiWorkoutDataSampleDao.Properties.HeartRate; Property deviceProperty = HuaweiWorkoutSummarySampleDao.Properties.DeviceId; qb.join(HuaweiWorkoutDataSampleDao.Properties.WorkoutId, HuaweiWorkoutSummarySample.class, HuaweiWorkoutSummarySampleDao.Properties.WorkoutId) .where(deviceProperty.eq(dbDevice.getId())); qb.where( timestampProperty.ge(timestampFrom), timestampProperty.le(timestampTo), heartRateProperty.notEq(ActivitySample.NOT_MEASURED) ).orderAsc(timestampProperty); List samples = qb.build().list(); getSession().getHuaweiWorkoutSummarySampleDao().detachAll(); return samples; } /* * This takes the following three steps: * - Generate a sample every minute * - Add the activity sample data to the generated samples * - Add the workout data to the generated samples */ @Override protected List getGBActivitySamples(int timestamp_from, int timestamp_to) { List processedSamples = new ArrayList<>(); for (int timestamp = timestamp_from; timestamp <= timestamp_to; timestamp += 60) { processedSamples.add(createDummySample(timestamp)); } overlayActivitySamples(processedSamples, timestamp_from, timestamp_to); overlayWorkoutSamples(processedSamples, timestamp_from, timestamp_to); return processedSamples; } @Override protected List getGBActivitySamplesHighRes(int timestamp_from, int timestamp_to) { List processedSamples = getRawOrderedActivitySamples(timestamp_from, timestamp_to); addWorkoutSamples(processedSamples, timestamp_from, timestamp_to); // Filter out the end markers before returning return processedSamples.stream().filter(sample -> sample.getTimestamp() <= sample.getOtherTimestamp()).collect(Collectors.toList()); } @Override public boolean hasHighResData() { return true; } private HuaweiActivitySample createDummySample(int timestamp) { HuaweiActivitySample activitySample = new HuaweiActivitySample( timestamp, -1, -1, timestamp + 60, // Make sure the duration is 60 (byte) 0x00, ActivitySample.NOT_MEASURED, 0, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED); activitySample.setProvider(this); return activitySample; } /* * For every activity sample, it adds the data into the following processed sample. * If there are multiple activity samples, the steps, calories, and distance is added together. * For the SpO and HR only the last value is used. */ private void overlayActivitySamples(List processedSamples, int timestamp_from, int timestamp_to) { List activitySamples = getRawOrderedActivitySamples(timestamp_from, timestamp_to); int currentIndex = 0; boolean hasData = false; int stepCount = ActivitySample.NOT_MEASURED; int calorieCount = ActivitySample.NOT_MEASURED; int distanceCount = ActivitySample.NOT_MEASURED; int lastSpo = ActivitySample.NOT_MEASURED; int lastHr = ActivitySample.NOT_MEASURED; int stateModifier = ActivitySample.NOT_MEASURED; for (HuaweiActivitySample activitySample : activitySamples) { // Ignore the end markers if (activitySample.getTimestamp() > activitySample.getOtherTimestamp()) continue; // Skip the processed samples that are before this activity sample while (activitySample.getTimestamp() > processedSamples.get(currentIndex).getTimestamp()) { // Add data to current index sample if (hasData || stateModifier != ActivitySample.NOT_MEASURED) processedSamples.get(currentIndex).setRawIntensity(1); processedSamples.get(currentIndex).setSteps(stepCount); processedSamples.get(currentIndex).setCalories(calorieCount); processedSamples.get(currentIndex).setDistance(distanceCount); processedSamples.get(currentIndex).setSpo(lastSpo); processedSamples.get(currentIndex).setHeartRate(lastHr); processedSamples.get(currentIndex).setRawKind(stateModifier); // Reset counters hasData = false; stepCount = ActivitySample.NOT_MEASURED; calorieCount = ActivitySample.NOT_MEASURED; distanceCount = ActivitySample.NOT_MEASURED; lastSpo = ActivitySample.NOT_MEASURED; lastHr = ActivitySample.NOT_MEASURED; currentIndex += 1; if (currentIndex >= processedSamples.size()) return; // We cannot add the data to any samples, so we might as well return } // Update data if (activitySample.getSteps() != ActivitySample.NOT_MEASURED) { if (stepCount == ActivitySample.NOT_MEASURED) stepCount = 0; stepCount += activitySample.getSteps(); hasData = true; } if (activitySample.getCalories() != ActivitySample.NOT_MEASURED) { if (calorieCount == ActivitySample.NOT_MEASURED) calorieCount = 0; calorieCount += activitySample.getCalories(); hasData = true; } if (activitySample.getDistance() != ActivitySample.NOT_MEASURED) { if (distanceCount == ActivitySample.NOT_MEASURED) distanceCount = 0; distanceCount += activitySample.getDistance(); hasData = true; } if (activitySample.getSpo() != ActivitySample.NOT_MEASURED) { lastSpo = activitySample.getSpo(); hasData = true; } if (activitySample.getHeartRate() != ActivitySample.NOT_MEASURED) { lastHr = activitySample.getHeartRate(); hasData = true; } if (activitySample.getRawKind() != ActivitySample.NOT_MEASURED) { if (activitySample.getTimestamp() < activitySample.getOtherTimestamp()) { // Starting of modifier stateModifier = activitySample.getRawKind(); } else { // End of modifier, remove it if it was for the same state if (activitySample.getRawKind() == stateModifier) stateModifier = ActivitySample.NOT_MEASURED; } } } // If there is still data, it has to be part of the next index of processed samples currentIndex += 1; if (currentIndex >= processedSamples.size()) return; if (hasData || stateModifier != ActivitySample.NOT_MEASURED) processedSamples.get(currentIndex).setRawIntensity(10); processedSamples.get(currentIndex).setSteps(stepCount); processedSamples.get(currentIndex).setCalories(calorieCount); processedSamples.get(currentIndex).setDistance(distanceCount); processedSamples.get(currentIndex).setSpo(lastSpo); processedSamples.get(currentIndex).setHeartRate(lastHr); processedSamples.get(currentIndex).setRawKind(stateModifier); } /* * For every workout sample, it adds the data into the following processed sample. * It also detects if it is still in the same workout, and resets the HR and intensity for the * samples in between, see #4126 for the reasoning. * NOTE: Huawei devices tend to generate a lot more data - mine up to every 5 seconds. Most of * this is lost in the conversion to data by the minute. It only shows the most recent value. */ private void overlayWorkoutSamples(List processedSamples, int timestamp_from, int timestamp_to) { int currentIndex = 0; int lastHr = ActivitySample.NOT_MEASURED; 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 processed sample that are before this workout sample while (workoutSamples.get(i).getTimestamp() > processedSamples.get(currentIndex).getTimestamp()) { if (inWorkout) { processedSamples.get(currentIndex).setHeartRate(lastHr); processedSamples.get(currentIndex).setRawIntensity(0); } // Reset lastHr = ActivitySample.NOT_MEASURED; currentIndex += 1; if (currentIndex >= processedSamples.size()) return; // We cannot add the data to any samples, so we might as well return } if (workoutSamples.get(i).getHeartRate() != ActivitySample.NOT_MEASURED) lastHr = workoutSamples.get(i).getHeartRate() & 0xFF; } // If there is still data, it has to be part of the next index of processed samples // Data being present implies it's still in a workout currentIndex += 1; if (currentIndex >= processedSamples.size()) return; if (lastHr != ActivitySample.NOT_MEASURED) { processedSamples.get(currentIndex).setHeartRate(lastHr); processedSamples.get(currentIndex).setRawIntensity(0); } } private void addWorkoutSamples(List 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; } }