From 0258905b4a0077871c8f03c6b538f2fce07da506 Mon Sep 17 00:00:00 2001 From: Arjan Schrijver Date: Wed, 8 Jan 2025 23:06:13 +0100 Subject: [PATCH] Moyoung: Implement syncing sleep data --- .../gadgetbridge/daogen/GBDaoGenerator.java | 10 +- .../moyoung/ColmiI28UltraCoordinator.java | 5 + .../devices/moyoung/MoyoungConstants.java | 1 + .../MoyoungActivitySampleProvider.java | 97 ++++++++++-- .../MoyoungSleepStageSampleProvider.java | 56 +++++++ .../devices/moyoung/MoyoungDeviceSupport.java | 139 ++++-------------- 6 files changed, 185 insertions(+), 123 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/samples/MoyoungSleepStageSampleProvider.java diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index ec0570405..ce0a7d866 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -56,7 +56,7 @@ public class GBDaoGenerator { public static void main(String[] args) throws Exception { - final Schema schema = new Schema(93, MAIN_PACKAGE + ".entities"); + final Schema schema = new Schema(94, MAIN_PACKAGE + ".entities"); Entity userAttributes = addUserAttributes(schema); Entity user = addUserInfo(schema, userAttributes); @@ -157,6 +157,7 @@ public class GBDaoGenerator { addMoyoungHeartRateSample(schema, user, device); addMoyoungSpo2Sample(schema, user, device); addMoyoungBloodPressureSample(schema, user, device); + addMoyoungSleepStageSample(schema, user, device); addHuaweiActivitySample(schema, user, device); @@ -1103,6 +1104,13 @@ public class GBDaoGenerator { return bpSample; } + private static Entity addMoyoungSleepStageSample(Schema schema, Entity user, Entity device) { + Entity sleepStageSample = addEntity(schema, "MoyoungSleepStageSample"); + addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device); + sleepStageSample.addIntProperty("stage").notNull(); + return sleepStageSample; + } + private static void addCommonActivitySampleProperties(String superClass, Entity activitySample, Entity user, Entity device) { activitySample.setSuperclass(superClass); activitySample.addImport(MAIN_PACKAGE + ".devices.SampleProvider"); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/ColmiI28UltraCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/ColmiI28UltraCoordinator.java index 408b4e392..54f21d187 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/ColmiI28UltraCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/ColmiI28UltraCoordinator.java @@ -75,4 +75,9 @@ public class ColmiI28UltraCoordinator extends AbstractMoyoungDeviceCoordinator { public int getWorldClocksLabelLength() { return 30; } + + @Override + public boolean supportsRemSleep() { + return true; + } } \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/MoyoungConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/MoyoungConstants.java index 9e902889e..e61b50fcc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/MoyoungConstants.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/MoyoungConstants.java @@ -173,6 +173,7 @@ public class MoyoungConstants { public static final byte SLEEP_SOBER = 0; public static final byte SLEEP_LIGHT = 1; public static final byte SLEEP_RESTFUL = 2; + public static final byte SLEEP_REM = 3; public static final byte CMD_QUERY_SLEEP_ACTION = 58; // (*) {i} -> {hour, x[60]} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/samples/MoyoungActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/samples/MoyoungActivitySampleProvider.java index d6903a12f..668361a8d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/samples/MoyoungActivitySampleProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/samples/MoyoungActivitySampleProvider.java @@ -24,6 +24,7 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.ListIterator; @@ -41,6 +42,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungActivitySampleDao; import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungHeartRateSample; +import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungSleepStageSample; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; @@ -74,6 +76,7 @@ public class MoyoungActivitySampleProvider extends AbstractSampleProvider finalSamples = new ArrayList<>(sampleByTs.values()); - Collections.sort(finalSamples, (a, b) -> Integer.compare(a.getTimestamp(), b.getTimestamp())); + Collections.sort(finalSamples, Comparator.comparingInt(MoyoungActivitySample::getTimestamp)); final long nanoEnd = System.nanoTime(); final long executionTime = (nanoEnd - nanoStart) / 1000000; @@ -221,6 +230,64 @@ public class MoyoungActivitySampleProvider extends AbstractSampleProvider sampleByTs, final int timestamp_from, final int timestamp_to) { + final MoyoungSleepStageSampleProvider sleepStageSampleProvider = new MoyoungSleepStageSampleProvider(getDevice(), getSession()); + final List sleepStageSamples = sleepStageSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L); + + // Retrieve the last stage before this time range, as the user could have been asleep during + // the range transition + final MoyoungSleepStageSample lastSleepStageBeforeRange = sleepStageSampleProvider.getLastSampleBefore(timestamp_from * 1000L); + if (lastSleepStageBeforeRange != null && lastSleepStageBeforeRange.getStage() != MoyoungConstants.SLEEP_SOBER) { + LOG.debug("Last sleep stage before range: ts={}, stage={}", lastSleepStageBeforeRange.getTimestamp(), lastSleepStageBeforeRange.getStage()); + sleepStageSamples.add(0, lastSleepStageBeforeRange); + } + // Retrieve the next sample after the time range, as the last stage could exceed it + final MoyoungSleepStageSample nextSleepStageAfterRange = sleepStageSampleProvider.getNextSampleAfter(timestamp_to * 1000L); + if (nextSleepStageAfterRange != null) { + LOG.debug("Next sleep stage after range: ts={}, stage={}", nextSleepStageAfterRange.getTimestamp(), nextSleepStageAfterRange.getStage()); + sleepStageSamples.add(nextSleepStageAfterRange); + } + + if (sleepStageSamples.size() > 1) { + LOG.debug("Overlaying with data from {} sleep stage samples", sleepStageSamples.size()); + } else { + LOG.warn("Not overlaying sleep data because more than 1 sleep stage sample is required"); + return; + } + + MoyoungSleepStageSample prevSample = null; + for (final MoyoungSleepStageSample sleepStageSample : sleepStageSamples) { + if (prevSample == null) { + prevSample = sleepStageSample; + continue; + } + final ActivityKind sleepRawKind = sleepStageToActivityKind(prevSample.getStage()); + if (sleepRawKind.equals(ActivityKind.AWAKE_SLEEP)) { + prevSample = sleepStageSample; + continue; + } + // round to the nearest minute, we don't need per-second granularity + final int tsSecondsPrev = (int) ((prevSample.getTimestamp() / 1000) / 60) * 60; + final int tsSecondsCur = (int) ((sleepStageSample.getTimestamp() / 1000) / 60) * 60; + for (int i = tsSecondsPrev; i < tsSecondsCur; i += 60) { + if (i < timestamp_from || i > timestamp_to) continue; + MoyoungActivitySample sample = sampleByTs.get(i); + if (sample == null) { + sample = new MoyoungActivitySample(); + sample.setTimestamp(i); + sample.setProvider(this); + sampleByTs.put(i, sample); + } + sample.setRawKind(toRawActivityKind(sleepRawKind)); + sample.setRawIntensity(ActivitySample.NOT_MEASURED); + } + prevSample = sleepStageSample; + } + if (prevSample != null && !sleepStageToActivityKind(prevSample.getStage()).equals(ActivityKind.AWAKE_SLEEP)) { + LOG.warn("Last sleep stage sample was not of type awake"); + } + } + /** * Set the activity kind from NOT_MEASURED to new_raw_activity_kind on the given range * @param timestamp_from the start timestamp diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/samples/MoyoungSleepStageSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/samples/MoyoungSleepStageSampleProvider.java new file mode 100644 index 000000000..cb389cde9 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/moyoung/samples/MoyoungSleepStageSampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2025 Arjan Schrijver + + 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 . */ +package nodomain.freeyourgadget.gadgetbridge.devices.moyoung.samples; + +import androidx.annotation.NonNull; + +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungSleepStageSample; +import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungSleepStageSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class MoyoungSleepStageSampleProvider extends AbstractTimeSampleProvider { + public MoyoungSleepStageSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getMoyoungSleepStageSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return MoyoungSleepStageSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return MoyoungSleepStageSampleDao.Properties.DeviceId; + } + + @Override + public MoyoungSleepStageSample createSample() { + return new MoyoungSleepStageSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/moyoung/MoyoungDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/moyoung/MoyoungDeviceSupport.java index e60ed1e7b..0874354c0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/moyoung/MoyoungDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/moyoung/MoyoungDeviceSupport.java @@ -72,6 +72,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.moyoung.MoyoungWeatherToday; import nodomain.freeyourgadget.gadgetbridge.devices.moyoung.samples.MoyoungActivitySampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.moyoung.samples.MoyoungBloodPressureSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.moyoung.samples.MoyoungHeartRateSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.moyoung.samples.MoyoungSleepStageSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.moyoung.samples.MoyoungSpo2SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.moyoung.settings.MoyoungEnumDeviceVersion; import nodomain.freeyourgadget.gadgetbridge.devices.moyoung.settings.MoyoungEnumLanguage; @@ -87,6 +88,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungBloodPressureSample; import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungHeartRateSample; +import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungSleepStageSample; import nodomain.freeyourgadget.gadgetbridge.entities.MoyoungSpo2Sample; import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; @@ -1186,126 +1188,49 @@ public class MoyoungDeviceSupport extends AbstractBTLEDeviceSupport { if (data.length % 3 != 0) throw new IllegalArgumentException(); - int prevActivityType = MoyoungActivitySampleProvider.ACTIVITY_SLEEP_START; - int prevSampleTimestamp = -1; + try (DBHandler dbHandler = GBApplication.acquireDB()) { + MoyoungSleepStageSampleProvider provider = new MoyoungSleepStageSampleProvider(getDevice(), dbHandler.getDaoSession()); - for(int i = 0; i < data.length / 3; i++) - { - int type = data[3*i]; - int start_h = data[3*i + 1]; - int start_m = data[3*i + 2]; + User user = DBHelper.getUser(dbHandler.getDaoSession()); + Device device = DBHelper.getDevice(getDevice(), dbHandler.getDaoSession()); - LOG.info("sleep[" + daysAgo + "][" + i + "] type=" + type + ", start_h=" + start_h + ", start_m=" + start_m); + List samples = new ArrayList<>(); - // SleepAnalysis measures sleep fragment type by marking the END of the fragment. - // The watch provides data by marking the START of the fragment. + for(int i = 0; i < data.length / 3; i++) + { + int type = data[3*i]; + int start_h = data[3*i + 1]; + int start_m = data[3*i + 2]; - // Additionally, ActivityAnalysis (used by the weekly view...) does AVERAGING when - // adjacent samples are not of the same type.. - - // FIXME: The way Gadgetbridge does it seems kinda broken... - - // This means that we have to convert the data when importing. Each sample gets - // converted to two samples - one marking the beginning of the segment, and another - // marking the end. - - // Watch: SLEEP_LIGHT ... SLEEP_DEEP ... SLEEP_LIGHT ... SLEEP_SOBER - // Gadgetbridge: ANYTHING,SLEEP_LIGHT ... SLEEP_LIGHT,SLEEP_DEEP ... SLEEP_DEEP,SLEEP_LIGHT ... SLEEP_LIGHT,ANYTHING - // ^ ^- this is important, it MUST be sleep, to ensure proper detection - // Time since the last -| of sleepStart, see SleepAnalysis.calculateSleepSessions - // sample must be 0 - // (otherwise SleepAnalysis will include this fragment...) - - // This means that when inserting samples: - // * every sample is converted to (previous_sample_type, current_sample_type) happening - // roughly at the same time (but in this order) - // * the first sample is prefixed by unspecified activity - // * the last sample (SOBER) is converted to unspecified activity - - try (DBHandler dbHandler = GBApplication.acquireDB()) { - User user = DBHelper.getUser(dbHandler.getDaoSession()); - Device device = DBHelper.getDevice(getDevice(), dbHandler.getDaoSession()); - - MoyoungActivitySampleProvider provider = new MoyoungActivitySampleProvider(getDevice(), dbHandler.getDaoSession()); + LOG.info("sleep[" + daysAgo + "][" + i + "] type=" + type + ", start_h=" + start_h + ", start_m=" + start_m); Calendar thisSample = Calendar.getInstance(); - thisSample.add(Calendar.HOUR_OF_DAY, 4); // the clock assumes the sleep day changes at 20:00, so move the time forward to make the day correct - thisSample.set(Calendar.MINUTE, 0); - thisSample.add(Calendar.DATE, -daysAgo); - + thisSample.add(Calendar.DAY_OF_MONTH, -daysAgo); thisSample.set(Calendar.HOUR_OF_DAY, start_h); thisSample.set(Calendar.MINUTE, start_m); thisSample.set(Calendar.SECOND, 0); thisSample.set(Calendar.MILLISECOND, 0); - int thisSampleTimestamp = (int) (thisSample.getTimeInMillis() / 1000); + if (start_h >= 20) { + // Evening sleep is considered to be a day earlier + thisSample.add(Calendar.MINUTE, -1440); + } - int activityType; - if (type == MoyoungConstants.SLEEP_SOBER) - activityType = MoyoungActivitySampleProvider.ACTIVITY_SLEEP_END; - else if (type == MoyoungConstants.SLEEP_LIGHT) - activityType = MoyoungActivitySampleProvider.ACTIVITY_SLEEP_LIGHT; - else if (type == MoyoungConstants.SLEEP_RESTFUL) - activityType = MoyoungActivitySampleProvider.ACTIVITY_SLEEP_RESTFUL; - else - throw new IllegalArgumentException("Invalid sleep type"); + MoyoungSleepStageSample currentSample = new MoyoungSleepStageSample(); + currentSample.setDevice(device); + currentSample.setUser(user); + currentSample.setStage(type); + currentSample.setTimestamp(thisSample.getTimeInMillis()); + samples.add(currentSample); - // Insert the end of previous segment sample - MoyoungActivitySample prevSegmentSample = new MoyoungActivitySample(); - prevSegmentSample.setDevice(device); - prevSegmentSample.setUser(user); - prevSegmentSample.setProvider(provider); - prevSegmentSample.setTimestamp(thisSampleTimestamp - 1); - - prevSegmentSample.setRawKind(prevActivityType); - prevSegmentSample.setDataSource(MoyoungActivitySampleProvider.SOURCE_SLEEP_SUMMARY); - -// prevSegmentSample.setBatteryLevel(ActivitySample.NOT_MEASURED); - prevSegmentSample.setSteps(ActivitySample.NOT_MEASURED); - prevSegmentSample.setDistanceMeters(ActivitySample.NOT_MEASURED); - prevSegmentSample.setCaloriesBurnt(ActivitySample.NOT_MEASURED); - - prevSegmentSample.setHeartRate(ActivitySample.NOT_MEASURED); -// prevSegmentSample.setBloodPressureSystolic(ActivitySample.NOT_MEASURED); -// prevSegmentSample.setBloodPressureDiastolic(ActivitySample.NOT_MEASURED); -// prevSegmentSample.setBloodOxidation(ActivitySample.NOT_MEASURED); - -// addGBActivitySampleIfNotExists(provider, prevSegmentSample); - - // Insert the start of new segment sample - MoyoungActivitySample nextSegmentSample = new MoyoungActivitySample(); - nextSegmentSample.setDevice(device); - nextSegmentSample.setUser(user); - nextSegmentSample.setProvider(provider); - nextSegmentSample.setTimestamp(thisSampleTimestamp); - - nextSegmentSample.setRawKind(activityType); - nextSegmentSample.setDataSource(MoyoungActivitySampleProvider.SOURCE_SLEEP_SUMMARY); - -// nextSegmentSample.setBatteryLevel(ActivitySample.NOT_MEASURED); - nextSegmentSample.setSteps(ActivitySample.NOT_MEASURED); - nextSegmentSample.setDistanceMeters(ActivitySample.NOT_MEASURED); - nextSegmentSample.setCaloriesBurnt(ActivitySample.NOT_MEASURED); - - nextSegmentSample.setHeartRate(ActivitySample.NOT_MEASURED); -// nextSegmentSample.setBloodPressureSystolic(ActivitySample.NOT_MEASURED); -// nextSegmentSample.setBloodPressureDiastolic(ActivitySample.NOT_MEASURED); -// nextSegmentSample.setBloodOxidation(ActivitySample.NOT_MEASURED); - -// addGBActivitySampleIfNotExists(provider, nextSegmentSample); - - // Set the activity type on all samples in this time period - if (prevActivityType != MoyoungActivitySampleProvider.ACTIVITY_SLEEP_START) -// provider.updateActivityInRange(prevSampleTimestamp, thisSampleTimestamp, prevActivityType); - - prevActivityType = activityType; - if (prevActivityType == MoyoungActivitySampleProvider.ACTIVITY_SLEEP_END) - prevActivityType = MoyoungActivitySampleProvider.ACTIVITY_SLEEP_START; - prevSampleTimestamp = thisSampleTimestamp; - } catch (Exception ex) { - LOG.error("Error saving samples: ", ex); - GB.toast(getContext(), "Error saving samples: " + ex.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); - GB.updateTransferNotification(null, "Data transfer failed", false, 0, getContext()); + LOG.debug("Adding sleep stage sample: ts={} stage={}", thisSample.getTime(), type); } + + LOG.debug("Will persist {} sleep stage samples", samples.size()); + provider.addSamples(samples); + } catch (Exception ex) { + LOG.error("Error saving sleep stage samples: ", ex); + GB.toast(getContext(), "Error saving sleep stage samples: " + ex.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); + GB.updateTransferNotification(null, "Data transfer failed", false, 0, getContext()); } }