mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -736,7 +736,7 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSleepScore() {
|
||||
public boolean supportsSleepScore(final GBDevice device) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ public class CyclingSampleProvider extends AbstractTimeSampleProvider<CyclingSam
|
||||
super(device, session);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public AbstractDao<CyclingSample, ?> getSampleDao() {
|
||||
return getSession().getCyclingSampleDao();
|
||||
|
||||
+1
-1
@@ -330,7 +330,7 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSleepScore() {
|
||||
public boolean supportsSleepScore(final GBDevice device) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -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();
|
||||
|
||||
+56
-16
@@ -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;
|
||||
|
||||
+196
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
|
||||
+29
@@ -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();
|
||||
}
|
||||
|
||||
+1
-25
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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));
|
||||
}
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+103
@@ -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";
|
||||
}
|
||||
}
|
||||
+1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user