/* 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;
}
}