From e51b55a38a939c55686c74a690a6a8c6a11fb402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Thu, 5 Sep 2024 21:27:19 +0100 Subject: [PATCH] Garmin: Infer sleep time for devices that do not send sleep stages See #4048 for more information --- .../garmin/GarminActivitySampleProvider.java | 5 ++ .../devices/garmin/GarminCoordinator.java | 47 ++++++----- .../devices/garmin/fit/FitImporter.java | 80 ++++++++++++++++++- .../devices/garmin/fit/GlobalFITMessage.java | 15 ++++ .../FieldDefinitionSleepStage.java | 1 + .../fit/messages/FitRecordDataFactory.java | 4 + .../garmin/fit/messages/FitSleepDataInfo.java | 52 ++++++++++++ .../garmin/fit/messages/FitSleepDataRaw.java | 27 +++++++ 8 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepDataInfo.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepDataRaw.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java index c1a35bc49..dcc240fd2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java @@ -134,7 +134,12 @@ public class GarminActivitySampleProvider extends AbstractSampleProvider, Property> daoMap = new HashMap, Property>() {{ + put(session.getGarminActivitySampleDao(), GarminActivitySampleDao.Properties.DeviceId); + put(session.getGarminStressSampleDao(), GarminStressSampleDao.Properties.DeviceId); + put(session.getGarminBodyEnergySampleDao(), GarminBodyEnergySampleDao.Properties.DeviceId); + put(session.getGarminSpo2SampleDao(), GarminSpo2SampleDao.Properties.DeviceId); + put(session.getGarminSleepStageSampleDao(), GarminSleepStageSampleDao.Properties.DeviceId); + put(session.getGarminEventSampleDao(), GarminEventSampleDao.Properties.DeviceId); + put(session.getGarminHrvSummarySampleDao(), GarminHrvSummarySampleDao.Properties.DeviceId); + put(session.getGarminHrvValueSampleDao(), GarminHrvValueSampleDao.Properties.DeviceId); + put(session.getBaseActivitySummaryDao(), BaseActivitySummaryDao.Properties.DeviceId); + put(session.getPendingFileDao(), PendingFileDao.Properties.DeviceId); + }}; - session.getGarminStressSampleDao().queryBuilder() - .where(GarminStressSampleDao.Properties.DeviceId.eq(deviceId)) - .buildDelete().executeDeleteWithoutDetachingEntities(); - - session.getGarminSleepStageSampleDao().queryBuilder() - .where(GarminSleepStageSampleDao.Properties.DeviceId.eq(deviceId)) - .buildDelete().executeDeleteWithoutDetachingEntities(); - - session.getGarminSpo2SampleDao().queryBuilder() - .where(GarminSpo2SampleDao.Properties.DeviceId.eq(deviceId)) - .buildDelete().executeDeleteWithoutDetachingEntities(); - - session.getBaseActivitySummaryDao().queryBuilder() - .where(BaseActivitySummaryDao.Properties.DeviceId.eq(deviceId)) - .buildDelete().executeDeleteWithoutDetachingEntities(); - - session.getPendingFileDao().queryBuilder() - .where(PendingFileDao.Properties.DeviceId.eq(deviceId)) - .buildDelete().executeDeleteWithoutDetachingEntities(); + for (final Map.Entry, Property> e : daoMap.entrySet()) { + e.getKey().queryBuilder() + .where(e.getValue().eq(deviceId)) + .buildDelete().executeDeleteWithoutDetachingEntities(); + } } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java index da3d15548..9120fbab5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java @@ -57,6 +57,8 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages. import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitPhysiologicalMetrics; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSession; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSleepDataInfo; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSleepDataRaw; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSleepStage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSpo2; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSport; @@ -79,6 +81,8 @@ public class FitImporter { private final List hrvSummarySamples = new ArrayList<>(); private final List hrvValueSamples = new ArrayList<>(); private final Map unknownRecords = new HashMap<>(); + private FitSleepDataInfo fitSleepDataInfo = null; + private final List fitSleepDataRawSamples = new ArrayList<>(); private FitFileId fileId = null; private final GarminWorkoutParser workoutParser; @@ -131,6 +135,18 @@ public class FitImporter { sample.setEnergy(energy); bodyEnergySamples.add(sample); } + } else if (record instanceof FitSleepDataInfo) { + final FitSleepDataInfo newFitSleepDataInfo = (FitSleepDataInfo) record; + LOG.debug("Sleep Data Info: {}", newFitSleepDataInfo); + if (fitSleepDataInfo != null) { + // Should not happen + LOG.warn("Already had sleep data info: {}", fitSleepDataInfo); + } + fitSleepDataInfo = newFitSleepDataInfo; + } else if (record instanceof FitSleepDataRaw) { + final FitSleepDataRaw fitSleepDataRaw = (FitSleepDataRaw) record; + //LOG.debug("Sleep Data Raw: {}", fitSleepDataRaw); + fitSleepDataRawSamples.add(fitSleepDataRaw); } else if (record instanceof FitSleepStage) { final FieldDefinitionSleepStage.SleepStage stage = ((FitSleepStage) record).getSleepStage(); if (stage == null) { @@ -260,6 +276,7 @@ public class FitImporter { case SLEEP: persistEvents(); persistSleepStageSamples(); + processRawSleepSamples(); break; case HRV_STATUS: persistHrvSummarySamples(); @@ -346,6 +363,8 @@ public class FitImporter { hrvSummarySamples.clear(); hrvValueSamples.clear(); unknownRecords.clear(); + fitSleepDataInfo = null; + fitSleepDataRawSamples.clear(); fileId = null; workoutParser.reset(); } @@ -469,7 +488,11 @@ public class FitImporter { } private void persistSleepStageSamples() { - if (sleepStageSamples.isEmpty()) { + // We may have samples, but not sleep samples - #4048 + // 0 unmeasurable, 1 awake + final boolean anySleepSample = sleepStageSamples.stream() + .anyMatch(s -> s.getStage() != 0 && s.getStage() != 1); + if (!anySleepSample) { return; } @@ -494,6 +517,61 @@ public class FitImporter { } } + /** + * As per #4048, devices that do not have a sleep widget send raw sleep samples, which we do not + * know how to parse. Therefore, we don't persist the sleep stages they report (they're all awake), + * but we fake light sleep for the duration of the raw sleep samples, in order to have some data + * at all. + */ + private void processRawSleepSamples() { + if (fitSleepDataRawSamples.isEmpty()) { + return; + } + + final boolean anySleepSample = sleepStageSamples.stream() + .anyMatch(s -> s.getStage() != 0 && s.getStage() != 1); + if (anySleepSample) { + // We have at least one real sleep sample - do nothing + return; + } + + final long asleepTimeMillis = Objects.requireNonNull(fileId.getTimeCreated()).intValue() * 1000L; + final long wakeTimeMillis = asleepTimeMillis + fitSleepDataRawSamples.size() * 60 * 1000L; + + LOG.debug("Got {} raw sleep samples - faking sleep events from {} to {}", fitSleepDataRawSamples.size(), asleepTimeMillis, wakeTimeMillis); + + // We only need to fake sleep start and end times, the sample provider will take care of the rest + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + + final GarminEventSampleProvider sampleProvider = new GarminEventSampleProvider(gbDevice, session); + + final GarminEventSample sampleFallAsleep = new GarminEventSample(); + sampleFallAsleep.setTimestamp(asleepTimeMillis); + sampleFallAsleep.setEvent(74); // sleep + sampleFallAsleep.setEventType(0); // sleep start + sampleFallAsleep.setData(-1L); // in actual samples they're a garmin epoch, this way we can identify them + sampleFallAsleep.setDevice(device); + sampleFallAsleep.setUser(user); + + final GarminEventSample sampleWakeUp = new GarminEventSample(); + sampleWakeUp.setTimestamp(wakeTimeMillis); + sampleWakeUp.setEvent(74); // sleep + sampleWakeUp.setEventType(1); // sleep end + sampleWakeUp.setData(-1L); // in actual samples they're a garmin epoch, this way we can identify them + sampleWakeUp.setDevice(device); + sampleWakeUp.setUser(user); + + sampleProvider.addSample(sampleFallAsleep); + sampleProvider.addSample(sampleWakeUp); + } catch (final Exception e) { + GB.toast(context, "Error faking event samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + private void persistHrvSummarySamples() { if (hrvSummarySamples.isEmpty()) { return; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java index c387ac510..9eff50451 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/GlobalFITMessage.java @@ -282,6 +282,19 @@ public class GlobalFITMessage { new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP) )); + public static GlobalFITMessage SLEEP_DATA_INFO = new GlobalFITMessage(273, "SLEEP_DATA_INFO", Arrays.asList( + new FieldDefinitionPrimitive(0, BaseType.UINT8, "unk0"), // 2 + new FieldDefinitionPrimitive(1, BaseType.UINT16, "sample_length"), // 60, sample time? + new FieldDefinitionPrimitive(2, BaseType.UINT32, "timestamp_in_tz"), // garmin timestamp, but in user timezone + new FieldDefinitionPrimitive(3, BaseType.ENUM, "unk3"), // 1 + new FieldDefinitionPrimitive(4, BaseType.STRING, "version"), // matches ETE in settings + new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP) + )); + + public static GlobalFITMessage SLEEP_DATA_RAW = new GlobalFITMessage(274, "SLEEP_DATA_RAW", Arrays.asList( + new FieldDefinitionPrimitive(0, BaseType.BASE_TYPE_BYTE, "bytes") // arr of 20 bytes per sample + )); + public static GlobalFITMessage SLEEP_STAGE = new GlobalFITMessage(275, "SLEEP_STAGE", Arrays.asList( new FieldDefinitionPrimitive(0, BaseType.ENUM, "sleep_stage", FieldDefinitionFactory.FIELD.SLEEP_STAGE), new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP) @@ -338,6 +351,8 @@ public class GlobalFITMessage { put(225, SET); put(227, STRESS_LEVEL); put(269, SPO2); + put(273, SLEEP_DATA_INFO); + put(274, SLEEP_DATA_RAW); put(275, SLEEP_STAGE); put(297, RESPIRATION_RATE); put(346, SLEEP_STATS); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionSleepStage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionSleepStage.java index caf9a614c..c9532a675 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionSleepStage.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/fieldDefinitions/FieldDefinitionSleepStage.java @@ -28,6 +28,7 @@ public class FieldDefinitionSleepStage extends FieldDefinition { } public enum SleepStage { + UNMEASURABLE(0), AWAKE(1), LIGHT(2), DEEP(3), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java index e6977b802..634cf4689 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java @@ -67,6 +67,10 @@ public class FitRecordDataFactory { return new FitStressLevel(recordDefinition, recordHeader); case 269: return new FitSpo2(recordDefinition, recordHeader); + case 273: + return new FitSleepDataInfo(recordDefinition, recordHeader); + case 274: + return new FitSleepDataRaw(recordDefinition, recordHeader); case 275: return new FitSleepStage(recordDefinition, recordHeader); case 297: diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepDataInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepDataInfo.java new file mode 100644 index 000000000..6684e18a1 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepDataInfo.java @@ -0,0 +1,52 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages; + +import androidx.annotation.Nullable; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader; + +// +// WARNING: This class was auto-generated, please avoid modifying it directly. +// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen +// +public class FitSleepDataInfo extends RecordData { + public FitSleepDataInfo(final RecordDefinition recordDefinition, final RecordHeader recordHeader) { + super(recordDefinition, recordHeader); + + final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber(); + if (globalNumber != 273) { + throw new IllegalArgumentException("FitSleepDataInfo expects global messages of " + 273 + ", got " + globalNumber); + } + } + + @Nullable + public Integer getUnk0() { + return (Integer) getFieldByNumber(0); + } + + @Nullable + public Integer getSampleLength() { + return (Integer) getFieldByNumber(1); + } + + @Nullable + public Long getTimestampInTz() { + return (Long) getFieldByNumber(2); + } + + @Nullable + public Integer getUnk3() { + return (Integer) getFieldByNumber(3); + } + + @Nullable + public String getVersion() { + return (String) getFieldByNumber(4); + } + + @Nullable + public Long getTimestamp() { + return (Long) getFieldByNumber(253); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepDataRaw.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepDataRaw.java new file mode 100644 index 000000000..8c59e9932 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitSleepDataRaw.java @@ -0,0 +1,27 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages; + +import androidx.annotation.Nullable; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader; + +// +// WARNING: This class was auto-generated, please avoid modifying it directly. +// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen +// +public class FitSleepDataRaw extends RecordData { + public FitSleepDataRaw(final RecordDefinition recordDefinition, final RecordHeader recordHeader) { + super(recordDefinition, recordHeader); + + final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber(); + if (globalNumber != 274) { + throw new IllegalArgumentException("FitSleepDataRaw expects global messages of " + 274 + ", got " + globalNumber); + } + } + + @Nullable + public Integer getBytes() { + return (Integer) getFieldByNumber(0); + } +}