Zepp OS: Fetch sleep stages and score

Newer Zepp OS devices report sleep sessions separately, which include
accurate sleep stages, and a sleep score.

- Persist this data raw, since we don't know how to fully process it
- Overlay (and prefer) this data over sleep samples

Devices that do not support these such as the Mi Band 7 reply with
an unsuccessful fetch response, which is fine.
This commit is contained in:
José Rebelo
2025-03-23 15:56:11 +00:00
parent b91254265c
commit 849a7dc996
22 changed files with 475 additions and 70 deletions
@@ -56,7 +56,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception {
final Schema schema = new Schema(98, MAIN_PACKAGE + ".entities");
final Schema schema = new Schema(99, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes);
@@ -81,6 +81,7 @@ public class GBDaoGenerator {
addHuamiHeartRateRestingSample(schema, user, device);
addHuamiPaiSample(schema, user, device);
addHuamiSleepRespiratoryRateSample(schema, user, device);
addHuamiSleepSessionSample(schema, user, device);
addXiaomiActivitySample(schema, user, device);
addXiaomiSleepTimeSamples(schema, user, device);
addHeartPulseSamples(schema, user, device);
@@ -404,6 +405,13 @@ public class GBDaoGenerator {
return sleepRespiratoryRateSample;
}
private static Entity addHuamiSleepSessionSample(Schema schema, Entity user, Entity device) {
Entity sample = addEntity(schema, "HuamiSleepSessionSample");
addCommonTimeSampleProperties("AbstractHuamiSleepSessionSample", sample, user, device);
sample.addByteArrayProperty("data");
return sample;
}
private static Entity addXiaomiActivitySample(Schema schema, Entity user, Entity device) {
Entity activitySample = addEntity(schema, "XiaomiActivitySample");
addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
@@ -925,7 +933,8 @@ public class GBDaoGenerator {
private static Entity addGarminSleepStatsSample(Schema schema, Entity user, Entity device) {
Entity sample = addEntity(schema, "GarminSleepStatsSample");
sample.addImport(MAIN_PACKAGE + ".model.SleepScoreSample");
addCommonTimeSampleProperties("SleepScoreSample", sample, user, device);
addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
sample.implementsInterface("SleepScoreSample");
sample.addIntProperty("sleepScore").notNull().codeBeforeGetter(OVERRIDE);
return sample;
}
@@ -303,7 +303,7 @@ public abstract class AbstractWeekChartFragment extends AbstractActivityChartFra
public boolean supportsSleepScore() {
final GBDevice device = getChartsHost().getDevice();
return device.getDeviceCoordinator().supportsSleepScore();
return device.getDeviceCoordinator().supportsSleepScore(device);
}
abstract String getAverage(float value);
@@ -366,7 +366,7 @@ public class DaySleepChartFragment extends AbstractActivityChartFragment<DaySlee
public boolean supportsSleepScore() {
final GBDevice device = getChartsHost().getDevice();
return device.getDeviceCoordinator().supportsSleepScore();
return device.getDeviceCoordinator().supportsSleepScore(device);
}
@Override
@@ -96,7 +96,7 @@ public class DashboardSleepScoreWidget extends AbstractGaugeWidget {
@Override
protected boolean isSupportedBy(final GBDevice device) {
return device.getDeviceCoordinator().supportsSleepScore();
return device.getDeviceCoordinator().supportsSleepScore(device);
}
private static class SleepScoreData implements Serializable {
@@ -736,7 +736,7 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
}
@Override
public boolean supportsSleepScore() {
public boolean supportsSleepScore(final GBDevice device) {
return false;
}
@@ -16,9 +16,15 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices;
import android.content.Context;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.List;
@@ -29,7 +35,9 @@ import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractTimeSample;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
/**
* Base class for all time sample providers. A Sample provider is device specific and provides
@@ -38,6 +46,8 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
* @param <T> the sample type
*/
public abstract class AbstractTimeSampleProvider<T extends AbstractTimeSample> implements TimeSampleProvider<T> {
private static final Logger LOG = LoggerFactory.getLogger(AbstractTimeSampleProvider.class);
private final DaoSession mSession;
private final GBDevice mDevice;
@@ -196,4 +206,33 @@ public abstract class AbstractTimeSampleProvider<T extends AbstractTimeSample> i
@NonNull
protected abstract Property getDeviceIdentifierSampleProperty();
public void persistForDevice(final Context context, final GBDevice gbDevice, final List<T> samples) {
if (samples.isEmpty()) {
return;
}
LOG.debug(
"Will persist {} {} samples",
samples.size(),
getClass().getSimpleName().replace("SampleProvider", "")
);
try {
final DaoSession session = getSession();
final Device device = DBHelper.getDevice(gbDevice, session);
final User user = DBHelper.getUser(session);
for (final T sample : samples) {
sample.setDevice(device);
sample.setUser(user);
}
this.addSamples(samples);
} catch (final Exception e) {
LOG.error("Error saving samples", e);
GB.toast(context, "Error saving samples", Toast.LENGTH_LONG, GB.ERROR, e);
}
}
}
@@ -625,7 +625,7 @@ public interface DeviceCoordinator {
/**
* Indicates whether the device supports determining a sleep score in a 0-100 range.
*/
boolean supportsSleepScore();
boolean supportsSleepScore(GBDevice device);
/**
* Indicates whether the device supports current weather and/or weather
@@ -17,6 +17,7 @@ public class CyclingSampleProvider extends AbstractTimeSampleProvider<CyclingSam
super(device, session);
}
@NonNull
@Override
public AbstractDao<CyclingSample, ?> getSampleDao() {
return getSession().getCyclingSampleDao();
@@ -330,7 +330,7 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
}
@Override
public boolean supportsSleepScore() {
public boolean supportsSleepScore(final GBDevice device) {
return true;
}
@@ -43,6 +43,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpec
import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.miband.DateTimeDisplay;
import nodomain.freeyourgadget.gadgetbridge.devices.miband.DoNotDisturb;
import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBand2SampleProvider;
@@ -56,6 +57,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.MiBandActivitySampleDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.SleepScoreSample;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibrationPatternNotificationType;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
@@ -170,6 +172,11 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator {
return new HuamiSleepRespiratoryRateSampleProvider(device, session);
}
@Override
public TimeSampleProvider<? extends SleepScoreSample> getSleepScoreProvider(final GBDevice device, final DaoSession session) {
return new HuamiSleepSessionSampleProvider(device, session);
}
@Override
public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
return new HuamiActivitySummaryParser();
@@ -28,6 +28,8 @@ import nodomain.freeyourgadget.gadgetbridge.entities.HuamiExtendedActivitySample
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiExtendedActivitySampleDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.util.RangeMap;
public class HuamiExtendedSampleProvider extends AbstractSampleProvider<HuamiExtendedActivitySample> {
public static final int TYPE_CUSTOM_UNSET = -1;
@@ -37,6 +39,7 @@ public class HuamiExtendedSampleProvider extends AbstractSampleProvider<HuamiExt
public static final int TYPE_SLEEP = 120;
public static final int TYPE_CUSTOM_DEEP_SLEEP = TYPE_SLEEP + 1;
public static final int TYPE_CUSTOM_REM_SLEEP = TYPE_SLEEP + 2;
public static final int TYPE_CUSTOM_AWAKE_SLEEP = TYPE_SLEEP + 3;
public HuamiExtendedSampleProvider(final GBDevice device, final DaoSession session) {
super(device, session);
@@ -77,31 +80,66 @@ public class HuamiExtendedSampleProvider extends AbstractSampleProvider<HuamiExt
@Override
protected List<HuamiExtendedActivitySample> getGBActivitySamples(final int timestamp_from, final int timestamp_to) {
final List<HuamiExtendedActivitySample> samples = super.getGBActivitySamples(timestamp_from, timestamp_to);
postProcess(samples);
postProcess(samples, timestamp_from, timestamp_to);
return samples;
}
private void postProcess(final List<HuamiExtendedActivitySample> samples) {
private void postProcess(final List<HuamiExtendedActivitySample> samples, final int timestamp_from, final int timestamp_to) {
if (samples.isEmpty()) {
return;
}
for (final HuamiExtendedActivitySample sample : samples) {
if (sample.getRawKind() == TYPE_SLEEP) {
// Band reports type sleep regardless of sleep type, so we map it to custom raw types
// These thresholds are arbitrary, but seem to somewhat match the data that's displayed on the band
final HuamiSleepSessionSampleProvider sleepSessionSampleProvider = new HuamiSleepSessionSampleProvider(getDevice(), getSession());
final RangeMap<Long, ActivityKind> sleepStages = sleepSessionSampleProvider.getSleepStages(
timestamp_from * 1000L,
timestamp_to * 1000L
);
sample.setDeepSleep(sample.getDeepSleep() & 127);
sample.setRemSleep(sample.getRemSleep() & 127);
if (!sleepStages.isEmpty()) {
// The device reports the sleep stage durations - overlay them
for (final HuamiExtendedActivitySample sample : samples) {
final long ts = sample.getTimestamp() * 1000L;
final ActivityKind sleepType = sleepStages.get(ts);
if (sleepType != null && !sleepType.equals(ActivityKind.UNKNOWN)) {
switch (sleepType) {
case DEEP_SLEEP:
sample.setRawKind(TYPE_CUSTOM_DEEP_SLEEP);
break;
case REM_SLEEP:
sample.setRawKind(TYPE_CUSTOM_REM_SLEEP);
break;
case AWAKE_SLEEP:
sample.setRawKind(TYPE_CUSTOM_AWAKE_SLEEP);
break;
case LIGHT_SLEEP:
case SLEEP_ANY:
default:
sample.setRawKind(TYPE_SLEEP);
break;
}
if (sample.getRemSleep() > 55) {
sample.setRawKind(TYPE_CUSTOM_REM_SLEEP);
sample.setRawIntensity(sample.getRemSleep());
} else if (sample.getDeepSleep() > 42) {
sample.setRawKind(TYPE_CUSTOM_DEEP_SLEEP);
sample.setRawIntensity(sample.getDeepSleep());
} else {
sample.setRawIntensity(sample.getSleep());
sample.setRawIntensity(ActivitySample.NOT_MEASURED);
}
}
} else {
// Fallback to the old threshold-based approach, which is not accurate
for (final HuamiExtendedActivitySample sample : samples) {
if (sample.getRawKind() == TYPE_SLEEP) {
// Band reports type sleep regardless of sleep type, so we map it to custom raw types
// These thresholds are arbitrary, but seem to somewhat match the data that's displayed on the band
sample.setDeepSleep(sample.getDeepSleep() & 127);
sample.setRemSleep(sample.getRemSleep() & 127);
if (sample.getRemSleep() > 55) {
sample.setRawKind(TYPE_CUSTOM_REM_SLEEP);
sample.setRawIntensity(sample.getRemSleep());
} else if (sample.getDeepSleep() > 42) {
sample.setRawKind(TYPE_CUSTOM_DEEP_SLEEP);
sample.setRawIntensity(sample.getDeepSleep());
} else {
sample.setRawIntensity(sample.getSleep());
}
}
}
}
@@ -121,6 +159,8 @@ public class HuamiExtendedSampleProvider extends AbstractSampleProvider<HuamiExt
return ActivityKind.DEEP_SLEEP;
case TYPE_CUSTOM_REM_SLEEP:
return ActivityKind.REM_SLEEP;
case TYPE_CUSTOM_AWAKE_SLEEP:
return ActivityKind.AWAKE_SLEEP;
}
return ActivityKind.UNKNOWN;
@@ -0,0 +1,196 @@
/* Copyright (C) 2025 José Rebelo
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.huami;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import de.greenrobot.dao.AbstractDao;
import de.greenrobot.dao.Property;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSleepSessionSample;
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSleepSessionSampleDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.RangeMap;
public class HuamiSleepSessionSampleProvider extends AbstractTimeSampleProvider<HuamiSleepSessionSample> {
private static final Logger LOG = LoggerFactory.getLogger(HuamiSleepSessionSampleProvider.class);
public HuamiSleepSessionSampleProvider(final GBDevice device, final DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<HuamiSleepSessionSample, ?> getSampleDao() {
return getSession().getHuamiSleepSessionSampleDao();
}
@NonNull
@Override
protected Property getTimestampSampleProperty() {
return HuamiSleepSessionSampleDao.Properties.Timestamp;
}
@NonNull
@Override
protected Property getDeviceIdentifierSampleProperty() {
return HuamiSleepSessionSampleDao.Properties.DeviceId;
}
@Override
public HuamiSleepSessionSample createSample() {
return new HuamiSleepSessionSample();
}
public RangeMap<Long, ActivityKind> getSleepStages(final long timestampFrom, final long timestampTo) {
final RangeMap<Long, ActivityKind> stagesMap = new RangeMap<>(RangeMap.Mode.LOWER_BOUND);
final List<HuamiSleepSessionSample> sessions = getAllSamples(timestampFrom, timestampTo + 86400_000L);
for (final HuamiSleepSessionSample rawSession : sessions) {
final byte[] bytes = rawSession.getData();
final int numStages = BLETypeConversions.toUnsigned(bytes, 0x54);
if (numStages == 0) {
continue;
}
final SleepSession sleepSession = new SleepSession(rawSession.getData());
stagesMap.put((sleepSession.timestampMidnight - 24 * 3600 + sleepSession.sleepEnd * 60L) * 1000L, ActivityKind.UNKNOWN);
for (final SleepStage stage : sleepSession.stages) {
final int stageStart = sleepSession.timestampMidnight - 24 * 3600 + stage.start * 60;
LOG.trace("Sleep stage start at {}", DateTimeUtils.formatIso8601(new Date(stageStart * 1000L)));
stagesMap.put(stageStart * 1000L, stage.asActivityKind());
}
}
return stagesMap;
}
public static class SleepSession {
private final int timestampSession;
private final int timestampMidnight;
private final int sleepStart;
private final int sleepEnd;
private final int avgHr;
private final int score;
private final List<SleepStage> stages;
private final int totalRemMinutes;
private final int totalLightMinutes;
private final int totalDeepMinutes;
private final int totalWakeMinutes;
public SleepSession(final byte[] bytes) {
this.timestampSession = BLETypeConversions.toUint32(bytes, 0x00);
// midnight boundary of the day, in the user's timezone
this.timestampMidnight = BLETypeConversions.toUint32(bytes, 0x04);
final int one1 = bytes[0x08]; // 1
final int one2 = bytes[0x09]; // 1
this.sleepStart = BLETypeConversions.toUint16(bytes, 0x0a);
this.sleepEnd = BLETypeConversions.toUint16(bytes, 0x0c);
this.avgHr = BLETypeConversions.toUnsigned(bytes, 0x15);
this.score = BLETypeConversions.toUnsigned(bytes, 0x16);
final int numStages = BLETypeConversions.toUnsigned(bytes, 0x54);
this.stages = new ArrayList<>(numStages);
for (int i = 0; i < numStages; i++) {
// Each stage is 5b: start, end, type
final int stageStart = BLETypeConversions.toUint16(bytes, 0x56 + 5 * i);
final int stageEnd = BLETypeConversions.toUint16(bytes, 0x56 + 5 * i + 2);
final int stageType = BLETypeConversions.toUnsigned(bytes, 0x56 + 5 * i + 4);
LOG.trace("Sleep stage start={}, end={}, type={}", stageStart, stageEnd, stageType);
this.stages.add(new SleepStage(stageStart, stageEnd, stageType));
}
this.totalRemMinutes = BLETypeConversions.toUint16(bytes, 0x024a);
this.totalLightMinutes = BLETypeConversions.toUint16(bytes, 0x024c);
this.totalDeepMinutes = BLETypeConversions.toUint16(bytes, 0x024e);
this.totalWakeMinutes = BLETypeConversions.toUint16(bytes, 0x0250);
LOG.trace(
"Sleep session at {}/{}: sleepStart={}, sleepEnd={}, avgHr={}, score={}",
DateTimeUtils.formatIso8601(new Date(timestampSession * 1000L)),
DateTimeUtils.formatIso8601(new Date(timestampMidnight * 1000L)),
sleepStart, sleepEnd, avgHr, score
);
}
}
public static class SleepStage {
private final int start;
private final int end;
private final int type;
public SleepStage(final int start, final int end, final int type) {
this.start = start;
this.end = end;
this.type = type;
}
/**
* Minutes since noon of the previous day?
*/
public int getStart() {
return start;
}
/**
* Minutes since noon of the previous day?
*/
public int getEnd() {
return end;
}
/**
* 4 = light, 5 = deep, 8 = rem, 7 = awake
*/
public int getType() {
return type;
}
public ActivityKind asActivityKind() {
switch (type) {
case 4:
return ActivityKind.LIGHT_SLEEP;
case 5:
return ActivityKind.DEEP_SLEEP;
case 8:
return ActivityKind.REM_SLEEP;
case 7:
return ActivityKind.AWAKE_SLEEP;
}
return ActivityKind.SLEEP_ANY;
}
}
}
@@ -56,6 +56,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateMaxSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiHeartRateRestingSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiPaiSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSleepRespiratoryRateSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSleepSessionSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSpo2SampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiStressSampleDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
@@ -215,6 +216,16 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
return true;
}
@Override
public boolean supportsSleepScore(final GBDevice device) {
return true;
}
@Override
public boolean supportsAwakeSleep() {
return true;
}
@Override
public Set<SleepAsAndroidFeature> getSleepAsAndroidFeatures() {
return EnumSet.of(SleepAsAndroidFeature.ACCELEROMETER, SleepAsAndroidFeature.HEART_RATE, SleepAsAndroidFeature.ALARMS, SleepAsAndroidFeature.NOTIFICATIONS);
@@ -0,0 +1,29 @@
/* Copyright (C) 2025 José Rebelo
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.entities;
import nodomain.freeyourgadget.gadgetbridge.model.SleepScoreSample;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
public abstract class AbstractHuamiSleepSessionSample extends AbstractTimeSample implements SleepScoreSample {
public abstract byte[] getData();
@Override
public int getSleepScore() {
return BLETypeConversions.toUnsigned(getData(), 0x16);
}
}
@@ -29,6 +29,7 @@ public class RecordedDataTypes {
public static final int TYPE_PAI = 0x00000100;
public static final int TYPE_SLEEP_RESPIRATORY_RATE = 0x00000200;
public static final int TYPE_HUAMI_STATISTICS = 0x00000400;
public static final int TYPE_SLEEP = 0x00000800;
public static final int TYPE_ALL = (int)0xffffffff;
@@ -36,5 +37,6 @@ public class RecordedDataTypes {
// Does not include debug logs or workouts
public static final int TYPE_SYNC = TYPE_ACTIVITY | TYPE_GPS_TRACKS | TYPE_SPO2 | TYPE_STRESS |
TYPE_TEMPERATURE |
TYPE_SLEEP |
TYPE_HEART_RATE | TYPE_PAI | TYPE_SLEEP_RESPIRATORY_RATE;
}
@@ -16,22 +16,6 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.model;
import androidx.annotation.NonNull;
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractTimeSample;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
public abstract class SleepScoreSample extends AbstractTimeSample {
public abstract int getSleepScore();
@NonNull
@Override
public String toString() {
return getClass().getSimpleName() + "{" +
"timestamp=" + DateTimeUtils.formatDateTime(DateTimeUtils.parseTimestampMillis(getTimestamp())) +
", userId=" + getUserId() +
", deviceId=" + getDeviceId() +
", sleepScore=" + getSleepScore() +
"}";
}
public interface SleepScoreSample extends TimeSample {
int getSleepScore();
}
@@ -685,30 +685,6 @@ public class FitImporter {
private <T extends AbstractTimeSample> void persistAbstractSamples(final List<T> samples,
final AbstractTimeSampleProvider<T> sampleProvider) {
if (samples.isEmpty()) {
return;
}
LOG.debug(
"Will persist {} {} samples",
samples.size(),
sampleProvider.getClass().getSimpleName().replace("Garmin", "").replace("SampleProvider", "")
);
try {
final DaoSession session = sampleProvider.getSession();
final Device device = DBHelper.getDevice(gbDevice, session);
final User user = DBHelper.getUser(session);
for (final T sample : samples) {
sample.setDevice(device);
sample.setUser(user);
}
sampleProvider.addSamples(samples);
} catch (final Exception e) {
GB.toast(context, "Error saving samples", Toast.LENGTH_LONG, GB.ERROR, e);
}
sampleProvider.persistForDevice(context, gbDevice, samples);
}
}
@@ -130,6 +130,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.SleepState;
import nodomain.freeyourgadget.gadgetbridge.model.WearingState;
import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.AbstractFetchOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchSleepSessionOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchStatisticsOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchTemperatureOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchHeartRateManualOperation;
@@ -1728,6 +1729,10 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements
}
}
if ((dataTypes & RecordedDataTypes.TYPE_SLEEP) != 0 && coordinator.supportsSleepScore(gbDevice)) {
this.fetchOperationQueue.add(new FetchSleepSessionOperation(this));
}
if ((dataTypes & RecordedDataTypes.TYPE_HUAMI_STATISTICS) != 0) {
this.fetchOperationQueue.add(new FetchStatisticsOperation(this));
}
@@ -47,6 +47,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiO
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
/**
@@ -256,14 +257,14 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation {
Calendar startTimestamp = getSupport().fromTimeBytes(Arrays.copyOfRange(value, 7, value.length));
if (expectedDataLength == 0) {
LOG.info("No data to fetch since {}", startTimestamp.getTime());
LOG.info("No data to fetch since {}", DateTimeUtils.formatIso8601(startTimestamp.getTime()));
sendAck(true);
// do not finish the operation - do it in the ack response
return;
}
setStartTimestamp(startTimestamp);
LOG.info("Will transfer {} packets since {}", expectedDataLength, startTimestamp.getTime());
LOG.info("Will transfer {} packets since {}", expectedDataLength, DateTimeUtils.formatIso8601(startTimestamp.getTime()));
GB.updateTransferNotification(taskDescription(),
getContext().getString(R.string.FetchActivityOperation_about_to_transfer_since,
@@ -29,6 +29,7 @@ import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
/**
@@ -50,7 +51,7 @@ public abstract class AbstractRepeatingFetchOperation extends AbstractFetchOpera
@Override
protected void startFetching(final TransactionBuilder builder) {
final GregorianCalendar sinceWhen = getLastSuccessfulSyncTime();
LOG.info("start {} since {}", getName(), sinceWhen.getTime());
LOG.info("start {} since {}", getName(), DateTimeUtils.formatIso8601(sinceWhen.getTime()));
startFetching(builder, dataType.getCode(), sinceWhen);
}
@@ -0,0 +1,103 @@
/* Copyright (C) 2025 José Rebelo
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch;
import android.widget.Toast;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.GregorianCalendar;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiSleepSessionSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.HuamiSleepSessionSample;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
/**
* An operation that fetches sleep sessions.
*/
public class FetchSleepSessionOperation extends AbstractRepeatingFetchOperation {
private static final Logger LOG = LoggerFactory.getLogger(FetchSleepSessionOperation.class);
public FetchSleepSessionOperation(final HuamiSupport support) {
super(support, HuamiFetchDataType.SLEEP_SESSION);
}
@Override
protected String taskDescription() {
return getContext().getString(R.string.busy_task_fetch_sleep_data);
}
@Override
protected boolean handleActivityData(final GregorianCalendar timestamp, final byte[] bytes) {
if (bytes.length % 594 != 0) {
LOG.error("Unexpected length for sleep session data {}, not divisible by 594", bytes.length);
return false;
}
final ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
final List<HuamiSleepSessionSample> samples = new ArrayList<>();
while (buf.hasRemaining()) {
final byte[] session = new byte[594];
buf.get(session);
final int timestampSeconds = BLETypeConversions.toUint32(session, 0);
timestamp.setTimeInMillis(timestampSeconds * 1000L);
final HuamiSleepSessionSample sample = new HuamiSleepSessionSample();
sample.setTimestamp(timestamp.getTimeInMillis());
sample.setData(session);
samples.add(sample);
LOG.trace("Sleep session at {}: {}", timestamp.getTime(), GB.hexdump(session));
}
return persistSamples(samples);
}
protected boolean persistSamples(final List<HuamiSleepSessionSample> samples) {
try (DBHandler handler = GBApplication.acquireDB()) {
final DaoSession session = handler.getDaoSession();
final HuamiSleepSessionSampleProvider sampleProvider = new HuamiSleepSessionSampleProvider(getDevice(), session);
sampleProvider.persistForDevice(getContext(), getDevice(), samples);
} catch (final Exception e) {
GB.toast(getContext(), "Error saving sleep session samples", Toast.LENGTH_LONG, GB.ERROR, e);
return false;
}
return true;
}
@Override
protected String getLastSyncTimeKey() {
return "lastSleepSessionTimeMillis";
}
}
@@ -32,6 +32,7 @@ public enum HuamiFetchDataType {
SLEEP_RESPIRATORY_RATE(0x38),
RESTING_HEART_RATE(0x3a),
MAX_HEART_RATE(0x3d),
SLEEP_SESSION(0x48),
;
private final byte code;