1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-12-26 02:25:50 +01:00

Garmin: Infer sleep time for devices that do not send sleep stages

See #4048 for more information
This commit is contained in:
José Rebelo 2024-09-05 21:27:19 +01:00
parent bd5b54c3b4
commit e51b55a38a
8 changed files with 208 additions and 23 deletions

View File

@ -134,7 +134,12 @@ public class GarminActivitySampleProvider extends AbstractSampleProvider<GarminA
case 0: // start case 0: // start
// We only need the start event as an upper-bound timestamp (anything before it is unknown) // We only need the start event as an upper-bound timestamp (anything before it is unknown)
stagesMap.put(event.getTimestamp(), ActivityKind.UNKNOWN); stagesMap.put(event.getTimestamp(), ActivityKind.UNKNOWN);
break;
case 1: // stop case 1: // stop
// See FitImporter#processRawSleepSamples / #4048
if (event.getData() != null && event.getData() == -1) {
stagesMap.put(event.getTimestamp(), ActivityKind.LIGHT_SLEEP);
}
default: default:
} }
} }

View File

@ -7,8 +7,12 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import de.greenrobot.dao.AbstractDao;
import de.greenrobot.dao.Property;
import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.GBException; import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.R;
@ -24,6 +28,10 @@ import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminBodyEnergySampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvSummarySampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminHrvValueSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2SampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2SampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao;
@ -54,30 +62,25 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
public void deleteAllActivityData(@NonNull final Device device, @NonNull final DaoSession session) throws GBException { public void deleteAllActivityData(@NonNull final Device device, @NonNull final DaoSession session) throws GBException {
final Long deviceId = device.getId(); final Long deviceId = device.getId();
session.getGarminActivitySampleDao().queryBuilder() final Map<AbstractDao<?, ?>, Property> daoMap = new HashMap<AbstractDao<?, ?>, Property>() {{
.where(GarminActivitySampleDao.Properties.DeviceId.eq(deviceId)) put(session.getGarminActivitySampleDao(), GarminActivitySampleDao.Properties.DeviceId);
.buildDelete().executeDeleteWithoutDetachingEntities(); 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() for (final Map.Entry<AbstractDao<?, ?>, Property> e : daoMap.entrySet()) {
.where(GarminStressSampleDao.Properties.DeviceId.eq(deviceId)) e.getKey().queryBuilder()
.buildDelete().executeDeleteWithoutDetachingEntities(); .where(e.getValue().eq(deviceId))
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(); .buildDelete().executeDeleteWithoutDetachingEntities();
} }
}
@Override @Override
public String getManufacturer() { public String getManufacturer() {

View File

@ -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.FitPhysiologicalMetrics;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord; 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.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.FitSleepStage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSpo2; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSpo2;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSport; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitSport;
@ -79,6 +81,8 @@ public class FitImporter {
private final List<GarminHrvSummarySample> hrvSummarySamples = new ArrayList<>(); private final List<GarminHrvSummarySample> hrvSummarySamples = new ArrayList<>();
private final List<GarminHrvValueSample> hrvValueSamples = new ArrayList<>(); private final List<GarminHrvValueSample> hrvValueSamples = new ArrayList<>();
private final Map<Integer, Integer> unknownRecords = new HashMap<>(); private final Map<Integer, Integer> unknownRecords = new HashMap<>();
private FitSleepDataInfo fitSleepDataInfo = null;
private final List<FitSleepDataRaw> fitSleepDataRawSamples = new ArrayList<>();
private FitFileId fileId = null; private FitFileId fileId = null;
private final GarminWorkoutParser workoutParser; private final GarminWorkoutParser workoutParser;
@ -131,6 +135,18 @@ public class FitImporter {
sample.setEnergy(energy); sample.setEnergy(energy);
bodyEnergySamples.add(sample); 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) { } else if (record instanceof FitSleepStage) {
final FieldDefinitionSleepStage.SleepStage stage = ((FitSleepStage) record).getSleepStage(); final FieldDefinitionSleepStage.SleepStage stage = ((FitSleepStage) record).getSleepStage();
if (stage == null) { if (stage == null) {
@ -260,6 +276,7 @@ public class FitImporter {
case SLEEP: case SLEEP:
persistEvents(); persistEvents();
persistSleepStageSamples(); persistSleepStageSamples();
processRawSleepSamples();
break; break;
case HRV_STATUS: case HRV_STATUS:
persistHrvSummarySamples(); persistHrvSummarySamples();
@ -346,6 +363,8 @@ public class FitImporter {
hrvSummarySamples.clear(); hrvSummarySamples.clear();
hrvValueSamples.clear(); hrvValueSamples.clear();
unknownRecords.clear(); unknownRecords.clear();
fitSleepDataInfo = null;
fitSleepDataRawSamples.clear();
fileId = null; fileId = null;
workoutParser.reset(); workoutParser.reset();
} }
@ -469,7 +488,11 @@ public class FitImporter {
} }
private void persistSleepStageSamples() { 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; 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() { private void persistHrvSummarySamples() {
if (hrvSummarySamples.isEmpty()) { if (hrvSummarySamples.isEmpty()) {
return; return;

View File

@ -282,6 +282,19 @@ public class GlobalFITMessage {
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP) 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( 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(0, BaseType.ENUM, "sleep_stage", FieldDefinitionFactory.FIELD.SLEEP_STAGE),
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP) new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
@ -338,6 +351,8 @@ public class GlobalFITMessage {
put(225, SET); put(225, SET);
put(227, STRESS_LEVEL); put(227, STRESS_LEVEL);
put(269, SPO2); put(269, SPO2);
put(273, SLEEP_DATA_INFO);
put(274, SLEEP_DATA_RAW);
put(275, SLEEP_STAGE); put(275, SLEEP_STAGE);
put(297, RESPIRATION_RATE); put(297, RESPIRATION_RATE);
put(346, SLEEP_STATS); put(346, SLEEP_STATS);

View File

@ -28,6 +28,7 @@ public class FieldDefinitionSleepStage extends FieldDefinition {
} }
public enum SleepStage { public enum SleepStage {
UNMEASURABLE(0),
AWAKE(1), AWAKE(1),
LIGHT(2), LIGHT(2),
DEEP(3), DEEP(3),

View File

@ -67,6 +67,10 @@ public class FitRecordDataFactory {
return new FitStressLevel(recordDefinition, recordHeader); return new FitStressLevel(recordDefinition, recordHeader);
case 269: case 269:
return new FitSpo2(recordDefinition, recordHeader); return new FitSpo2(recordDefinition, recordHeader);
case 273:
return new FitSleepDataInfo(recordDefinition, recordHeader);
case 274:
return new FitSleepDataRaw(recordDefinition, recordHeader);
case 275: case 275:
return new FitSleepStage(recordDefinition, recordHeader); return new FitSleepStage(recordDefinition, recordHeader);
case 297: case 297:

View File

@ -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);
}
}

View File

@ -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);
}
}