Moyoung: Implement syncing sleep data

This commit is contained in:
Arjan Schrijver 2025-01-08 23:06:13 +01:00
parent 938085b5fa
commit 0258905b4a
6 changed files with 185 additions and 123 deletions

View File

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

View File

@ -75,4 +75,9 @@ public class ColmiI28UltraCoordinator extends AbstractMoyoungDeviceCoordinator {
public int getWorldClocksLabelLength() {
return 30;
}
@Override
public boolean supportsRemSleep() {
return true;
}
}

View File

@ -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]}

View File

@ -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<Moyoun
public static final int ACTIVITY_SLEEP_RESTFUL = 17;
public static final int ACTIVITY_SLEEP_START = 18;
public static final int ACTIVITY_SLEEP_END = 19;
public static final int ACTIVITY_SLEEP_REM = 20;
public MoyoungActivitySampleProvider(GBDevice device, DaoSession session) {
super(device, session);
@ -115,6 +118,8 @@ public class MoyoungActivitySampleProvider extends AbstractSampleProvider<Moyoun
return ActivityKind.LIGHT_SLEEP;
else if (rawType == ACTIVITY_SLEEP_RESTFUL)
return ActivityKind.DEEP_SLEEP;
else if (rawType == ACTIVITY_SLEEP_REM)
return ActivityKind.REM_SLEEP;
else if (rawType == ACTIVITY_SLEEP_START || rawType == ACTIVITY_SLEEP_END)
return ActivityKind.NOT_MEASURED;
else if (rawType == ACTIVITY_TRAINING_WALK)
@ -142,12 +147,29 @@ public class MoyoungActivitySampleProvider extends AbstractSampleProvider<Moyoun
return ACTIVITY_SLEEP_LIGHT;
else if (activityKind == ActivityKind.DEEP_SLEEP)
return ACTIVITY_SLEEP_RESTFUL;
else if (activityKind == ActivityKind.REM_SLEEP)
return ACTIVITY_SLEEP_REM;
else if (activityKind == ActivityKind.ACTIVITY)
return ACTIVITY_NOT_MEASURED; // TODO: ?
else
throw new IllegalArgumentException("Invalid Gadgetbridge activity kind: " + activityKind);
}
final ActivityKind sleepStageToActivityKind(final int sleepStage) {
switch (sleepStage) {
case MoyoungConstants.SLEEP_LIGHT:
return ActivityKind.LIGHT_SLEEP;
case MoyoungConstants.SLEEP_RESTFUL:
return ActivityKind.DEEP_SLEEP;
case MoyoungConstants.SLEEP_REM:
return ActivityKind.REM_SLEEP;
case MoyoungConstants.SLEEP_SOBER:
return ActivityKind.AWAKE_SLEEP;
default:
return ActivityKind.UNKNOWN;
}
}
@Override
public float normalizeIntensity(int rawIntensity) {
if (rawIntensity == ActivitySample.NOT_MEASURED)
@ -177,23 +199,10 @@ public class MoyoungActivitySampleProvider extends AbstractSampleProvider<Moyoun
}
overlayHeartRate(sampleByTs, timestamp_from, timestamp_to);
// overlaySleep(sampleByTs, timestamp_from, timestamp_to);
// Add empty dummy samples every 5 min to make sure the charts and stats aren't too malformed
// This is necessary due to the Colmi rings just reporting steps/calories/distance aggregates per hour
// for (int i=timestamp_from; i<=timestamp_to; i+=300) {
// MoyoungActivitySample sample = sampleByTs.get(i);
// if (sample == null) {
// sample = new MoyoungActivitySample();
// sample.setTimestamp(i);
// sample.setProvider(this);
// sample.setRawKind(ActivitySample.NOT_MEASURED);
// sampleByTs.put(i, sample);
// }
// }
overlaySleep(sampleByTs, timestamp_from, timestamp_to);
final List<MoyoungActivitySample> 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<Moyoun
}
}
private void overlaySleep(final Map<Integer, MoyoungActivitySample> sampleByTs, final int timestamp_from, final int timestamp_to) {
final MoyoungSleepStageSampleProvider sleepStageSampleProvider = new MoyoungSleepStageSampleProvider(getDevice(), getSession());
final List<MoyoungSleepStageSample> 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

View File

@ -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 <https://www.gnu.org/licenses/>. */
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<MoyoungSleepStageSample> {
public MoyoungSleepStageSampleProvider(final GBDevice device, final DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<MoyoungSleepStageSample, ?> 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();
}
}

View File

@ -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,8 +1188,13 @@ 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());
User user = DBHelper.getUser(dbHandler.getDaoSession());
Device device = DBHelper.getDevice(getDevice(), dbHandler.getDaoSession());
List<MoyoungSleepStageSample> samples = new ArrayList<>();
for(int i = 0; i < data.length / 3; i++)
{
@ -1197,115 +1204,33 @@ public class MoyoungDeviceSupport extends AbstractBTLEDeviceSupport {
LOG.info("sleep[" + daysAgo + "][" + i + "] type=" + type + ", start_h=" + start_h + ", start_m=" + start_m);
// SleepAnalysis measures sleep fragment type by marking the END of the fragment.
// The watch provides data by marking the START of the fragment.
// 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());
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);
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");
// 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());
if (start_h >= 20) {
// Evening sleep is considered to be a day earlier
thisSample.add(Calendar.MINUTE, -1440);
}
MoyoungSleepStageSample currentSample = new MoyoungSleepStageSample();
currentSample.setDevice(device);
currentSample.setUser(user);
currentSample.setStage(type);
currentSample.setTimestamp(thisSample.getTimeInMillis());
samples.add(currentSample);
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());
}
}