mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge
synced 2024-12-25 10:05:49 +01:00
Garmin: Infer sleep time for devices that do not send sleep stages
See #4048 for more information
This commit is contained in:
parent
bd5b54c3b4
commit
e51b55a38a
@ -134,7 +134,12 @@ public class GarminActivitySampleProvider extends AbstractSampleProvider<GarminA
|
||||
case 0: // start
|
||||
// We only need the start event as an upper-bound timestamp (anything before it is unknown)
|
||||
stagesMap.put(event.getTimestamp(), ActivityKind.UNKNOWN);
|
||||
break;
|
||||
case 1: // stop
|
||||
// See FitImporter#processRawSleepSamples / #4048
|
||||
if (event.getData() != null && event.getData() == -1) {
|
||||
stagesMap.put(event.getTimestamp(), ActivityKind.LIGHT_SLEEP);
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
@ -7,8 +7,12 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
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.GBException;
|
||||
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.Device;
|
||||
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.GarminSpo2SampleDao;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao;
|
||||
@ -54,29 +62,24 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
|
||||
public void deleteAllActivityData(@NonNull final Device device, @NonNull final DaoSession session) throws GBException {
|
||||
final Long deviceId = device.getId();
|
||||
|
||||
session.getGarminActivitySampleDao().queryBuilder()
|
||||
.where(GarminActivitySampleDao.Properties.DeviceId.eq(deviceId))
|
||||
.buildDelete().executeDeleteWithoutDetachingEntities();
|
||||
final Map<AbstractDao<?, ?>, Property> daoMap = new HashMap<AbstractDao<?, ?>, 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<AbstractDao<?, ?>, Property> e : daoMap.entrySet()) {
|
||||
e.getKey().queryBuilder()
|
||||
.where(e.getValue().eq(deviceId))
|
||||
.buildDelete().executeDeleteWithoutDetachingEntities();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -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<GarminHrvSummarySample> hrvSummarySamples = new ArrayList<>();
|
||||
private final List<GarminHrvValueSample> hrvValueSamples = new ArrayList<>();
|
||||
private final Map<Integer, Integer> unknownRecords = new HashMap<>();
|
||||
private FitSleepDataInfo fitSleepDataInfo = null;
|
||||
private final List<FitSleepDataRaw> 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;
|
||||
|
@ -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);
|
||||
|
@ -28,6 +28,7 @@ public class FieldDefinitionSleepStage extends FieldDefinition {
|
||||
}
|
||||
|
||||
public enum SleepStage {
|
||||
UNMEASURABLE(0),
|
||||
AWAKE(1),
|
||||
LIGHT(2),
|
||||
DEEP(3),
|
||||
|
@ -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:
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user