From b1829c2436d47c0c283369b04be8efd6f4156f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Wed, 1 May 2024 22:20:34 +0100 Subject: [PATCH] Garmin: Persist and display activity - Steps, hr, intensity - Sleep stages - Stress - SpO2 - Workouts --- .../gadgetbridge/daogen/GBDaoGenerator.java | 48 +- .../garmin/GarminActivitySampleProvider.java | 229 ++++++++ .../devices/garmin/GarminCoordinator.java | 96 +++- .../garmin/GarminEventSampleProvider.java | 80 +++ .../GarminSleepStageSampleProvider.java | 56 ++ .../garmin/GarminSpo2SampleProvider.java | 56 ++ .../garmin/GarminStressSampleProvider.java | 56 ++ .../gadgetbridge/export/GPXExporter.java | 2 +- .../model/ActivitySummaryData.java | 58 +++ .../gadgetbridge/model/ActivityTrack.java | 5 + .../gadgetbridge/model/GPSCoordinate.java | 6 + .../devices/cmfwatchpro/CmfActivitySync.java | 3 +- .../service/devices/garmin/GarminSupport.java | 105 +++- .../service/devices/garmin/GarminUtils.java | 4 + .../service/devices/garmin/fit/FitFile.java | 6 +- .../devices/garmin/fit/FitImporter.java | 491 ++++++++++++++++++ .../garmin/fit/messages/FitRecord.java | 26 + .../gadgetbridge/util/RangeMap.java | 33 +- .../gadgetbridge/util/RangeMapTest.java | 51 ++ 19 files changed, 1392 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminEventSampleProvider.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStageSampleProvider.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSpo2SampleProvider.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminStressSampleProvider.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/RangeMapTest.java diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index dd5bde65f..8ba796cec 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -45,7 +45,7 @@ public class GBDaoGenerator { public static void main(String[] args) throws Exception { - final Schema schema = new Schema(73, MAIN_PACKAGE + ".entities"); + final Schema schema = new Schema(74, MAIN_PACKAGE + ".entities"); Entity userAttributes = addUserAttributes(schema); Entity user = addUserInfo(schema, userAttributes); @@ -108,6 +108,11 @@ public class GBDaoGenerator { addHybridHRActivitySample(schema, user, device); addVivomoveHrActivitySample(schema, user, device); addGarminFitFile(schema, user, device); + addGarminActivitySample(schema, user, device); + addGarminStressSample(schema, user, device); + addGarminSpo2Sample(schema, user, device); + addGarminSleepStageSample(schema, user, device); + addGarminEventSample(schema, user, device); addWena3EnergySample(schema, user, device); addWena3BehaviorSample(schema, user, device); addWena3CaloriesSample(schema, user, device); @@ -666,6 +671,47 @@ public class GBDaoGenerator { return downloadedFitFile; } + private static Entity addGarminActivitySample(Schema schema, Entity user, Entity device) { + Entity activitySample = addEntity(schema, "GarminActivitySample"); + addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device); + activitySample.implementsSerializable(); + activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE); + activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE); + activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE); + addHeartRateProperties(activitySample); + return activitySample; + } + + private static Entity addGarminStressSample(Schema schema, Entity user, Entity device) { + Entity stressSample = addEntity(schema, "GarminStressSample"); + addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device); + stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE); + return stressSample; + } + + private static Entity addGarminSpo2Sample(Schema schema, Entity user, Entity device) { + Entity spo2sample = addEntity(schema, "GarminSpo2Sample"); + addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device); + spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE); + return spo2sample; + } + + private static Entity addGarminSleepStageSample(Schema schema, Entity user, Entity device) { + Entity sleepStageSample = addEntity(schema, "GarminSleepStageSample"); + addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device); + sleepStageSample.addIntProperty("stage").notNull(); + return sleepStageSample; + } + + private static Entity addGarminEventSample(Schema schema, Entity user, Entity device) { + Entity sleepStageSample = addEntity(schema, "GarminEventSample"); + addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device); + sleepStageSample.addIntProperty("event").notNull().primaryKey(); + sleepStageSample.addIntProperty("eventType"); + sleepStageSample.addLongProperty("data"); + return sleepStageSample; + } + private static Entity addWatchXPlusHealthActivitySample(Schema schema, Entity user, Entity device) { Entity activitySample = addEntity(schema, "WatchXPlusActivitySample"); activitySample.implementsSerializable(); 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 new file mode 100644 index 000000000..44d10f2ce --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java @@ -0,0 +1,229 @@ +/* Copyright (C) 2024 José Rebelo + + 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.garmin; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.threeten.bp.LocalDate; + +import java.util.Calendar; +import java.util.List; + +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiSleepTimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSample; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSample; +import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiSleepTimeSample; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionSleepStage; +import nodomain.freeyourgadget.gadgetbridge.util.RangeMap; + +public class GarminActivitySampleProvider extends AbstractSampleProvider { + private static final Logger LOG = LoggerFactory.getLogger(GarminActivitySampleProvider.class); + + public GarminActivitySampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @Override + public AbstractDao getSampleDao() { + return getSession().getGarminActivitySampleDao(); + } + + @Nullable + @Override + protected Property getRawKindSampleProperty() { + return GarminActivitySampleDao.Properties.RawKind; + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return GarminActivitySampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return GarminActivitySampleDao.Properties.DeviceId; + } + + @Override + public int normalizeType(final int rawType) { + return rawType; + } + + @Override + public int toRawActivityKind(final int activityKind) { + return activityKind; + } + + @Override + public float normalizeIntensity(final int rawIntensity) { + return rawIntensity / 100f; + } + + @Override + public GarminActivitySample createActivitySample() { + return new GarminActivitySample(); + } + + @Override + protected List getGBActivitySamples(final int timestamp_from, final int timestamp_to, final int activityType) { + LOG.trace( + "Getting garmin activity samples for {} between {} and {}", + String.format("0x%08x", activityType), + timestamp_from, + timestamp_to + ); + + final long nanoStart = System.nanoTime(); + + final List samples = super.getGBActivitySamples(timestamp_from, timestamp_to, activityType); + + if (!samples.isEmpty()) { + convertCumulativeSteps(samples); + } + + overlaySleep(samples, timestamp_from, timestamp_to); + + final long nanoEnd = System.nanoTime(); + + final long executionTime = (nanoEnd - nanoStart) / 1000000; + + LOG.trace("Getting Garmin samples took {}ms", executionTime); + + return samples; + } + + private void convertCumulativeSteps(final List samples) { + final Calendar cal = Calendar.getInstance(); + + // Steps on the Garmin Watch are reported cumulatively per day - convert them to + // This slightly breaks activity recognition, because we don't have per-minute granularity... + int prevSteps = samples.get(0).getSteps(); + samples.get(0).setTimestamp((samples.get(0).getTimestamp() / 60) * 60); + + for (int i = 1; i < samples.size(); i++) { + final GarminActivitySample s1 = samples.get(i - 1); + final GarminActivitySample s2 = samples.get(i); + s2.setTimestamp((s2.getTimestamp() / 60) * 60); + + cal.setTimeInMillis(s1.getTimestamp() * 1000L - 1000L); + final LocalDate d1 = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)); + cal.setTimeInMillis(s2.getTimestamp() * 1000L - 1000L); + final LocalDate d2 = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)); + + if (d1.equals(d2) && s2.getSteps() > 0) { + int bak = s2.getSteps(); + s2.setSteps(s2.getSteps() - prevSteps); + prevSteps = bak; + } + } + } + + public void overlaySleep(final List samples, final int timestamp_from, final int timestamp_to) { + // The samples provided by Garmin are upper-bound timestamps of the sleep stage + final RangeMap stagesMap = new RangeMap<>(RangeMap.Mode.UPPER_BOUND); + + final GarminEventSampleProvider eventSampleProvider = new GarminEventSampleProvider(getDevice(), getSession()); + final List sleepEventSamples = eventSampleProvider.getSleepEvents( + timestamp_from * 1000L - 86400000L, + timestamp_to * 1000L + ); + if (!sleepEventSamples.isEmpty()) { + LOG.debug("Found {} sleep event samples between {} and {}", sleepEventSamples.size(), timestamp_from, timestamp_to); + for (final GarminEventSample event : sleepEventSamples) { + switch (event.getEventType()) { + case 0: // start + // We only need the start event as an upper-bound timestamp (anything before it is unknown) + stagesMap.put(event.getTimestamp(), ActivityKind.TYPE_UNKNOWN); + case 1: // stop + default: + } + } + } + + final GarminSleepStageSampleProvider sleepStagesSampleProvider = new GarminSleepStageSampleProvider(getDevice(), getSession()); + final List stageSamples = sleepStagesSampleProvider.getAllSamples( + timestamp_from * 1000L - 86400000L, + timestamp_to * 1000L + ); + + if (!stageSamples.isEmpty()) { + // We got actual sleep stages + LOG.debug("Found {} sleep stage samples between {} and {}", stageSamples.size(), timestamp_from, timestamp_to); + + for (final GarminSleepStageSample stageSample : stageSamples) { + final int activityKind; + + final FieldDefinitionSleepStage.SleepStage sleepStage = FieldDefinitionSleepStage.SleepStage.fromId(stageSample.getStage()); + if (sleepStage == null) { + LOG.error("Unknown sleep stage for {}", stageSample.getStage()); + continue; + } + + switch (sleepStage) { + case LIGHT: + activityKind = ActivityKind.TYPE_LIGHT_SLEEP; + break; + case DEEP: + activityKind = ActivityKind.TYPE_DEEP_SLEEP; + break; + case REM: + activityKind = ActivityKind.TYPE_REM_SLEEP; + break; + default: + activityKind = ActivityKind.TYPE_UNKNOWN; + break; + } + stagesMap.put(stageSample.getTimestamp(), activityKind); + } + } + + if (!stagesMap.isEmpty()) { + for (final GarminActivitySample sample : samples) { + final long ts = sample.getTimestamp() * 1000L; + final Integer sleepType = stagesMap.get(ts); + if (sleepType != null && !sleepType.equals(ActivityKind.TYPE_UNKNOWN)) { + sample.setRawKind(sleepType); + + switch (sleepType) { + case ActivityKind.TYPE_DEEP_SLEEP: + sample.setRawIntensity(20); + break; + case ActivityKind.TYPE_LIGHT_SLEEP: + sample.setRawIntensity(30); + break; + case ActivityKind.TYPE_REM_SLEEP: + sample.setRawIntensity(40); + break; + } + } + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java index 68bffb2be..57a791ba9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java @@ -15,17 +15,51 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpec import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen; import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; +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.GarminSleepStageSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2SampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; +import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; +import nodomain.freeyourgadget.gadgetbridge.model.StressSample; import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator { @Override - protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException { + protected void deleteDevice(@NonNull final GBDevice gbDevice, @NonNull final Device device, @NonNull final DaoSession session) throws GBException { + deleteAllActivityData(device, session); + } + 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(); + + 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(); } @Override @@ -39,6 +73,21 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator { return GarminSupport.class; } + @Override + public SampleProvider getSampleProvider(final GBDevice device, DaoSession session) { + return new GarminActivitySampleProvider(device, session); + } + + @Override + public TimeSampleProvider getStressSampleProvider(final GBDevice device, final DaoSession session) { + return new GarminStressSampleProvider(device, session); + } + + @Override + public TimeSampleProvider getSpo2SampleProvider(final GBDevice device, final DaoSession session) { + return new GarminSpo2SampleProvider(device, session); + } + @Override public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) { final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings(); @@ -78,6 +127,51 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator { return true; } + @Override + public boolean supportsActivityTracking() { + return true; + } + + @Override + public boolean supportsActivityTracks() { + return true; + } + + @Override + public boolean supportsStressMeasurement() { + return true; + } + + @Override + public int[] getStressRanges() { + // 1-25 = relaxed + // 26-50 = low + // 51-80 = moderate + // 76-100 = high + return new int[]{1, 26, 51, 76}; + } + + @Override + public boolean supportsHeartRateMeasurement(final GBDevice device) { + return true; + } + + @Override + public boolean supportsManualHeartRateMeasurement(final GBDevice device) { + // TODO: It should be supported, but not yet implemented + return false; + } + + @Override + public boolean supportsSpo2() { + return true; + } + + @Override + public boolean supportsRemSleep() { + return true; + } + @Override public boolean supportsFindDevice() { return true; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminEventSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminEventSampleProvider.java new file mode 100644 index 000000000..24d6be0cd --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminEventSampleProvider.java @@ -0,0 +1,80 @@ +/* Copyright (C) 2024 José Rebelo + + 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.garmin; + +import androidx.annotation.NonNull; + +import java.util.Collections; +import java.util.List; + +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import de.greenrobot.dao.query.QueryBuilder; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSample; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSampleDao; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class GarminEventSampleProvider extends AbstractTimeSampleProvider { + public GarminEventSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getGarminEventSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return GarminEventSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return GarminEventSampleDao.Properties.DeviceId; + } + + @Override + public GarminEventSample createSample() { + return new GarminEventSample(); + } + + public List getSleepEvents(final long timestampFrom, final long timestampTo) { + final QueryBuilder qb = getSampleDao().queryBuilder(); + final Property timestampProperty = getTimestampSampleProperty(); + final Device dbDevice = DBHelper.findDevice(getDevice(), getSession()); + if (dbDevice == null) { + // no device, no samples + return Collections.emptyList(); + } + final Property deviceProperty = getDeviceIdentifierSampleProperty(); + qb.where(deviceProperty.eq(dbDevice.getId()), timestampProperty.ge(timestampFrom)) + .where(timestampProperty.le(timestampTo)) + .where(GarminEventSampleDao.Properties.Event.eq(74)); + + final List samples = qb.build().list(); + detachFromSession(); + return samples; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStageSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStageSampleProvider.java new file mode 100644 index 000000000..bf34a505c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSleepStageSampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2024 José Rebelo + + 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.garmin; + +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.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSample; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSampleDao; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class GarminSleepStageSampleProvider extends AbstractTimeSampleProvider { + public GarminSleepStageSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getGarminSleepStageSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return GarminSleepStageSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return GarminSleepStageSampleDao.Properties.DeviceId; + } + + @Override + public GarminSleepStageSample createSample() { + return new GarminSleepStageSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSpo2SampleProvider.java new file mode 100644 index 000000000..0705aa113 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSpo2SampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2024 José Rebelo + + 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.garmin; + +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.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2Sample; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2SampleDao; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class GarminSpo2SampleProvider extends AbstractTimeSampleProvider { + public GarminSpo2SampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getGarminSpo2SampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return GarminSpo2SampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return GarminSpo2SampleDao.Properties.DeviceId; + } + + @Override + public GarminSpo2Sample createSample() { + return new GarminSpo2Sample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminStressSampleProvider.java new file mode 100644 index 000000000..163b1d98e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminStressSampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2024 José Rebelo + + 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.garmin; + +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.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSample; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class GarminStressSampleProvider extends AbstractTimeSampleProvider { + public GarminStressSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getGarminStressSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return GarminStressSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return GarminStressSampleDao.Properties.DeviceId; + } + + @Override + public GarminStressSample createSample() { + return new GarminStressSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java index 925e6ff86..ae4b1a836 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/export/GPXExporter.java @@ -142,7 +142,7 @@ public class GPXExporter implements ActivityTrackExporter { // lon and lat attributes do not have an explicit namespace ser.attribute(null, "lon", formatLocation(location.getLongitude())); ser.attribute(null, "lat", formatLocation(location.getLatitude())); - if (location.getAltitude() != -20000) { + if (location.getAltitude() != GPSCoordinate.UNKNOWN_ALTITUDE) { ser.startTag(NS_GPX_URI, "ele").text(formatLocation(location.getAltitude())).endTag(NS_GPX_URI, "ele"); } ser.startTag(NS_GPX_URI, "time").text(DateTimeUtils.formatIso8601UTC(point.getTime())).endTag(NS_GPX_URI, "time"); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java new file mode 100644 index 000000000..d73eebb65 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.java @@ -0,0 +1,58 @@ +/* Copyright (C) 2024 José Rebelo + + 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.model; + +import org.json.JSONException; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitImporter; + +/** + * A small wrapper for a JSONObject, with helper methods to add activity summary data in the format + * Gadgetbridge expects. + */ +public class ActivitySummaryData extends JSONObject { + private static final Logger LOG = LoggerFactory.getLogger(FitImporter.class); + + public void add(final String key, final float value, final String unit) { + if (value > 0) { + try { + final JSONObject innerData = new JSONObject(); + innerData.put("value", value); + innerData.put("unit", unit); + put(key, innerData); + } catch (final JSONException e) { + LOG.error("This should never happen", e); + } + } + } + + public void add(final String key, final String value) { + if (key != null && !key.isEmpty() && value != null && !value.isEmpty()) { + try { + final JSONObject innerData = new JSONObject(); + innerData.put("value", value); + innerData.put("unit", "string"); + put(key, innerData); + } catch (final JSONException e) { + LOG.error("This should never happen", e); + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java index 84563bf28..8161339a3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivityTrack.java @@ -17,6 +17,7 @@ package nodomain.freeyourgadget.gadgetbridge.model; import java.util.ArrayList; +import java.util.Collection; import java.util.Date; import java.util.List; @@ -60,6 +61,10 @@ public class ActivityTrack { currentSegment.add(point); } + public void addTrackPoints(final Collection points) { + currentSegment.addAll(points); + } + public void startNewSegment() { // Only really start a new segment if the current one is not empty if (!currentSegment.isEmpty()) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java index 6938857ea..6d8ab5b1d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/GPSCoordinate.java @@ -25,6 +25,8 @@ public class GPSCoordinate { private final double longitude; private final double altitude; + public static final double UNKNOWN_ALTITUDE = -20000d; + public static final int GPS_DECIMAL_DEGREES_SCALE = 6; // precise to 111.132mm at equator: https://en.wikipedia.org/wiki/Decimal_degrees public GPSCoordinate(double longitude, double latitude, double altitude) { @@ -33,6 +35,10 @@ public class GPSCoordinate { this.altitude = altitude; } + public GPSCoordinate(double longitude, double latitude) { + this(longitude, latitude, UNKNOWN_ALTITUDE); + } + public double getLatitude() { return latitude; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivitySync.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivitySync.java index 577cddcec..fb579d2f5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivitySync.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivitySync.java @@ -589,8 +589,7 @@ public class CmfActivitySync { final ActivityPoint ap = new ActivityPoint(new Date(gpsSample.getTimestamp())); final GPSCoordinate coordinate = new GPSCoordinate( gpsSample.getLongitude() / 10000000d, - gpsSample.getLatitude() / 10000000d, - -20000 + gpsSample.getLatitude() / 10000000d ); ap.setLocation(coordinate); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java index c13361230..93bb44c71 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java @@ -26,13 +26,20 @@ import java.util.Timer; import java.util.TimerTask; import java.util.UUID; +import nodomain.freeyourgadget.gadgetbridge.BuildConfig; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminAgpsInstallHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences; import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; @@ -58,6 +65,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents. import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.NotificationSubscriptionDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.SupportedFileTypesDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.WeatherRequestDeviceEvent; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitImporter; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.PredefinedLocalMessage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition; @@ -264,17 +272,31 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni this.supportedFileTypeList.clear(); this.supportedFileTypeList.addAll(((SupportedFileTypesDeviceEvent) deviceEvent).getSupportedFileTypes()); } else if (deviceEvent instanceof FileDownloadedDeviceEvent) { - LOG.debug("FILE DOWNLOAD COMPLETE {}", ((FileDownloadedDeviceEvent) deviceEvent).directoryEntry.getFileName()); + final FileTransferHandler.DirectoryEntry entry = ((FileDownloadedDeviceEvent) deviceEvent).directoryEntry; + final String filename = entry.getFileName(); + LOG.debug("FILE DOWNLOAD COMPLETE {}", filename); - if (!getKeepActivityDataOnDevice()) // delete file from watch upon successful download - sendOutgoingMessage(new SetFileFlagsMessage(((FileDownloadedDeviceEvent) deviceEvent).directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE)); + if (entry.getFiletype().isFitFile()) { + try { + final File dir = getWritableExportDirectory(); + final File file = new File(dir, filename); + final FitImporter fitImporter = new FitImporter(getContext(), getDevice()); + fitImporter.importFile(file); + } catch (final IOException e) { + LOG.error("Failed to import fit file", e); + } + } + + if (!getKeepActivityDataOnDevice()) { // delete file from watch upon successful download + sendOutgoingMessage(new SetFileFlagsMessage(entry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE)); + } } super.evaluateGBDeviceEvent(deviceEvent); } private boolean getKeepActivityDataOnDevice() { - return getDevicePrefs().getBoolean("keep_activity_data_on_device", true); // TODO: change to default false once we are sure of the consequences + return getDevicePrefs().getBoolean("keep_activity_data_on_device", false); } @Override @@ -466,8 +488,9 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni FileTransferHandler.DirectoryEntry directoryEntry = filesToDownload.remove(); while (checkFileExists(directoryEntry.getFileName()) || checkFileExists(directoryEntry.getLegacyFileName())) { LOG.debug("File: {} already downloaded, not downloading again.", directoryEntry.getFileName()); - if (!getKeepActivityDataOnDevice()) // delete file from watch if already downloaded + if (!getKeepActivityDataOnDevice()) { // delete file from watch if already downloaded sendOutgoingMessage(new SetFileFlagsMessage(directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE)); + } directoryEntry = filesToDownload.remove(); } DownloadRequestMessage downloadRequestMessage = fileTransferHandler.downloadDirectoryEntry(directoryEntry); @@ -692,4 +715,76 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni return agpsCacheDir; } + public GarminCoordinator getCoordinator() { + return (GarminCoordinator) getDevice().getDeviceCoordinator(); + } + + @Override + public void onTestNewFunction() { + parseAllFitFilesFromStorage(); + } + + private void parseAllFitFilesFromStorage() { + // This function as-is should only be used for debug purposes + if (!BuildConfig.DEBUG) { + LOG.error("This should never be used in release builds"); + return; + } + + LOG.info("Parsing all fit files from storage"); + + final File[] fitFiles; + try { + final File exportDir = getWritableExportDirectory(); + + if (!exportDir.exists() || !exportDir.isDirectory()) { + LOG.error("export directory {} not found", exportDir); + return; + } + + fitFiles = exportDir.listFiles((dir, name) -> name.endsWith(".fit")); + if (fitFiles == null) { + LOG.error("fitFiles is null for {}", exportDir); + return; + } + if (fitFiles.length == 0) { + LOG.error("No fit files found in {}", exportDir); + return; + } + } catch (final Exception e) { + LOG.error("Failed to parse from storage", e); + return; + } + + GB.updateTransferNotification("Parsing fit files", "...", true, 0, getContext()); + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + final Device device = DBHelper.getDevice(gbDevice, session); + getCoordinator().deleteAllActivityData(device, session); + } catch (final Exception e) { + GB.toast(getContext(), "Error deleting activity data", Toast.LENGTH_LONG, GB.ERROR, e); + } + + try { + int i = 0; + for (final File file : fitFiles) { + i++; + LOG.debug("Parsing {}", file); + + GB.updateTransferNotification("Parsing fit files", "File " + i + " of " + fitFiles.length, true, (i * 100) / fitFiles.length, getContext()); + + try { + final FitImporter fitImporter = new FitImporter(getContext(), getDevice()); + fitImporter.importFile(file); + } catch (final Exception ex) { + LOG.error("Exception while importing {}", file, ex); + } + } + } catch (final Exception e) { + LOG.error("Failed to parse from storage", e); + } + + GB.updateTransferNotification("", "", false, 100, getContext()); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java index c218f2c95..9d54b0d2f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java @@ -10,6 +10,10 @@ public final class GarminUtils { // utility class } + public static double semicirclesToDegrees(final long semicircles) { + return semicircles * (180.0D / 0x80000000L); + } + public static GdiCore.CoreService.LocationData toLocationData(final Location location, final GdiCore.CoreService.DataType dataType) { final GdiCore.CoreService.LatLon positionForWatch = GdiCore.CoreService.LatLon.newBuilder() .setLat((int) ((location.getLatitude() * 2.147483648E9d) / 180.0d)) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitFile.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitFile.java index 99865bdc0..6579d7d45 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitFile.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitFile.java @@ -39,7 +39,7 @@ public class FitFile { this.canGenerateOutput = true; } - private static byte[] readFileToByteArray(File file) { + private static byte[] readFileToByteArray(File file) throws IOException { try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); InputStream inputStream = new FileInputStream(file)) { byte[] buffer = new byte[1024]; int length; @@ -47,12 +47,10 @@ public class FitFile { outputStream.write(buffer, 0, length); } return outputStream.toByteArray(); - } catch (IOException e) { - throw new RuntimeException(e); } } - public static FitFile parseIncoming(File file) { + public static FitFile parseIncoming(File file) throws IOException { return parseIncoming(readFileToByteArray(file)); } 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 new file mode 100644 index 000000000..7ece581bf --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/FitImporter.java @@ -0,0 +1,491 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit; + +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ACTIVE_SECONDS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ASCENT_DISTANCE; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.CALORIES_BURNT; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.DESCENT_DISTANCE; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.DISTANCE_METERS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KCAL; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS; + +import android.content.Context; +import android.widget.Toast; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminActivitySampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminEventSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminSleepStageSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminSpo2SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminStressSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminEventSample; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSample; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2Sample; +import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSample; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionSleepStage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitEvent; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFileId; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitMonitoring; +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.FitSleepStage; +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.FitStressLevel; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitTimeInZone; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class FitImporter { + private static final Logger LOG = LoggerFactory.getLogger(FitImporter.class); + + private final Context context; + private final GBDevice gbDevice; + + private final List activitySamples = new ArrayList<>(); + private final SortedMap> activitySamplesPerTimestamp = new TreeMap<>(); + private final List stressSamples = new ArrayList<>(); + private final List spo2samples = new ArrayList<>(); + private final List events = new ArrayList<>(); + private final List sleepStageSamples = new ArrayList<>(); + private final List timesInZone = new ArrayList<>(); + private final List activityPoints = new ArrayList<>(); + private final Map unknownRecords = new HashMap<>(); + private FitFileId fileId = null; + private FitSession session = null; + private FitSport sport = null; + + public FitImporter(final Context context, final GBDevice gbDevice) { + this.context = context; + this.gbDevice = gbDevice; + } + + public void importFile(final File file) throws IOException { + reset(); + + final FitFile fitFile = FitFile.parseIncoming(file); + + for (final RecordData record : fitFile.getRecords()) { + final Long ts = record.getComputedTimestamp(); + + if (record instanceof FitFileId) { + final FitFileId newFileId = (FitFileId) record; + LOG.debug("File ID: {}", newFileId); + if (fileId != null) { + // Should not happen + LOG.warn("Already had a file ID: {}", fileId); + } + fileId = newFileId; + } else if (record instanceof FitStressLevel) { + final Integer stress = ((FitStressLevel) record).getStressLevelValue(); + if (stress == null || stress < 0) { + continue; + } + LOG.trace("Stress at {}: {}", ts, stress); + final GarminStressSample sample = new GarminStressSample(); + sample.setTimestamp(ts * 1000L); + sample.setStress(stress); + stressSamples.add(sample); + } else if (record instanceof FitSleepStage) { + final FieldDefinitionSleepStage.SleepStage stage = ((FitSleepStage) record).getSleepStage(); + if (stage == null) { + continue; + } + LOG.trace("Sleep stage at {}: {}", ts, record); + final GarminSleepStageSample sample = new GarminSleepStageSample(); + sample.setTimestamp(ts * 1000L); + sample.setStage(stage.getId()); + sleepStageSamples.add(sample); + } else if (record instanceof FitMonitoring) { + final Integer hr = ((FitMonitoring) record).getHeartRate(); + final Long steps = ((FitMonitoring) record).getCycles(); + final Integer activityType = ((FitMonitoring) record).getComputedActivityType(); + final Integer intensity = ((FitMonitoring) record).getComputedIntensity(); + LOG.trace("Monitoring at {}: hr={} steps={} activityType={} intensity={}", ts, hr, steps, activityType, intensity); + final GarminActivitySample sample = new GarminActivitySample(); + sample.setTimestamp(ts.intValue()); + if (hr != null) { + sample.setHeartRate(hr); + } + if (steps != null) { + sample.setSteps(steps.intValue()); + } + if (activityType != null) { + sample.setRawKind(activityType); + } + if (intensity != null) { + sample.setRawIntensity(intensity); + } + activitySamples.add(sample); + List samplesForTimestamp = activitySamplesPerTimestamp.get(ts.intValue()); + if (samplesForTimestamp == null) { + samplesForTimestamp = new ArrayList<>(); + activitySamplesPerTimestamp.put(ts.intValue(), samplesForTimestamp); + } + samplesForTimestamp.add(sample); + } else if (record instanceof FitSpo2) { + final Integer spo2 = ((FitSpo2) record).getReadingSpo2(); + if (spo2 == null || spo2 <= 0) { + continue; + } + LOG.trace("SpO2 at {}: {}", ts, spo2); + final GarminSpo2Sample sample = new GarminSpo2Sample(); + sample.setTimestamp(ts * 1000L); + sample.setSpo2(spo2); + spo2samples.add(sample); + } else if (record instanceof FitEvent) { + final FitEvent event = (FitEvent) record; + if (event.getEvent() == null) { + LOG.warn("Event in {} is null", event); + continue; + } + + LOG.trace("Event at {}: {}", ts, event); + + final GarminEventSample sample = new GarminEventSample(); + sample.setTimestamp(ts * 1000L); + sample.setEvent(event.getEvent()); + if (event.getEventType() != null) { + sample.setEventType(event.getEventType()); + } + if (event.getData() != null) { + sample.setData(event.getData()); + } + events.add(sample); + } else if (record instanceof FitRecord) { + activityPoints.add(((FitRecord) record).toActivityPoint()); + } else if (record instanceof FitSession) { + LOG.debug("Session: {}", record); + if (session != null) { + LOG.warn("Got multiple sessions - NOT SUPPORTED: {}", record); + } else { + // We only support 1 session + session = (FitSession) record; + } + } else if (record instanceof FitSport) { + LOG.debug("Sport: {}", record); + if (sport != null) { + LOG.warn("Got multiple sports - NOT SUPPORTED: {}", record); + } else { + // We only support 1 sport + sport = (FitSport) record; + } + } else if (record instanceof FitTimeInZone) { + LOG.trace("Time in zone: {}", record); + timesInZone.add((FitTimeInZone) record); + } else { + LOG.trace("Unknown record: {}", record); + + if (!unknownRecords.containsKey(record.getGlobalFITMessage().getNumber())) { + unknownRecords.put(record.getGlobalFITMessage().getNumber(), 0); + } + unknownRecords.put( + record.getGlobalFITMessage().getNumber(), + Objects.requireNonNull(unknownRecords.get(record.getGlobalFITMessage().getNumber())) + 1 + ); + } + } + + if (fileId == null) { + LOG.error("Got no file ID"); + return; + } + if (fileId.getType() == null) { + LOG.error("File has no type"); + return; + } + + switch (fileId.getType()) { + case activity: + persistWorkout(file); + break; + case monitor: + persistActivitySamples(); + persistSpo2Samples(); + persistStressSamples(); + break; + case sleep: + persistEvents(); + persistSleepStageSamples(); + break; + default: + LOG.warn("Unable to handle fit file of type {}", fileId.getType()); + } + + for (final Map.Entry e : unknownRecords.entrySet()) { + LOG.warn("Unknown record of global number {} seen {} times", e.getKey(), e.getValue()); + } + } + + private void persistWorkout(final File file) { + if (session == null) { + LOG.error("Got workout from {}, but no session", fileId); + return; + } + if (sport == null) { + LOG.error("Got workout from {}, but no sport", fileId); + return; + } + + LOG.debug("Persisting workout for {}", fileId); + + final BaseActivitySummary summary = new BaseActivitySummary(); + summary.setActivityKind(ActivityKind.TYPE_UNKNOWN); + + final ActivitySummaryData summaryData = new ActivitySummaryData(); + + // TODO map all sports + if (sport.getSport() != null) { + switch (sport.getSport()) { + case 2: + summary.setActivityKind(ActivityKind.TYPE_CYCLING); + break; + case 4: // fitness_equipment + case 10: // training + if (sport.getSubSport() != null) { + switch (sport.getSubSport()) { + case 15: + summary.setActivityKind(ActivityKind.TYPE_ELLIPTICAL_TRAINER); + break; + default: + LOG.warn("Unknown sub sport {}", sport.getSubSport()); + summaryData.add("Fit Sub Sport", sport.getSubSport(), ""); + } + break; + } + default: + LOG.warn("Unknown sport {}", sport.getSport()); + summaryData.add("Fit Sport", sport.getSport(), ""); + } + } + + summary.setName(sport.getName()); + if (session.getStartTime() == null) { + LOG.error("No session start time for {}", fileId); + return; + } + summary.setStartTime(new Date(GarminTimeUtils.garminTimestampToJavaMillis(session.getStartTime().intValue()))); + + if (session.getTotalElapsedTime() == null) { + LOG.error("No elapsed time for {}", fileId); + return; + } + summary.setEndTime(new Date(GarminTimeUtils.garminTimestampToJavaMillis(session.getStartTime().intValue() + session.getTotalElapsedTime().intValue() / 1000))); + + if (session.getTotalTimerTime() != null) { + summaryData.add(ACTIVE_SECONDS, session.getTotalTimerTime() / 1000f, UNIT_SECONDS); + } + if (session.getTotalDistance() != null) { + summaryData.add(DISTANCE_METERS, session.getTotalDistance() / 100f, UNIT_METERS); + } + if (session.getTotalCalories() != null) { + summaryData.add(CALORIES_BURNT, session.getTotalCalories(), UNIT_KCAL); + } + if (session.getTotalAscent() != null) { + summaryData.add(ASCENT_DISTANCE, session.getTotalAscent(), UNIT_METERS); + } + if (session.getTotalDescent() != null) { + summaryData.add(DESCENT_DISTANCE, session.getTotalDescent(), UNIT_METERS); + } + + //FitTimeInZone timeInZone = null; + //for (final FitTimeInZone fitTimeInZone : timesInZone) { + // // Find the firt time in zone for the session (assumes single-session) + // if (fitTimeInZone.getReferenceMessage() != null && fitTimeInZone.getReferenceMessage() == 18) { + // timeInZone = fitTimeInZone; + // break; + // } + //} + //if (timeInZone != null) { + //} + + summary.setSummaryData(summaryData.toString()); + if (file != null) { + summary.setRawDetailsPath(file.getAbsolutePath()); + } + + try (DBHandler dbHandler = GBApplication.acquireDB()) { + final DaoSession session = dbHandler.getDaoSession(); + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + + summary.setDevice(device); + summary.setUser(user); + + session.getBaseActivitySummaryDao().insertOrReplace(summary); + } catch (final Exception e) { + GB.toast(context, "Error saving workout", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private void reset() { + activitySamples.clear(); + stressSamples.clear(); + spo2samples.clear(); + events.clear(); + sleepStageSamples.clear(); + timesInZone.clear(); + activityPoints.clear(); + unknownRecords.clear(); + fileId = null; + session = null; + sport = null; + } + + private void persistActivitySamples() { + if (activitySamples.isEmpty()) { + return; + } + + // FIXME prevent overlapping samples in the same timestamp.. + + LOG.debug("Will persist {} activity samples", activitySamples.size()); + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + + final GarminActivitySampleProvider sampleProvider = new GarminActivitySampleProvider(gbDevice, session); + + for (final GarminActivitySample sample : activitySamples) { + sample.setDevice(device); + sample.setUser(user); + } + + sampleProvider.addGBActivitySamples(activitySamples.toArray(new GarminActivitySample[0])); + } catch (final Exception e) { + GB.toast(context, "Error saving activity samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private void persistEvents() { + if (events.isEmpty()) { + return; + } + + LOG.debug("Will persist {} event samples", events.size()); + + 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); + + for (final GarminEventSample sample : events) { + sample.setDevice(device); + sample.setUser(user); + } + + sampleProvider.addSamples(events); + } catch (final Exception e) { + GB.toast(context, "Error saving event samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private void persistSleepStageSamples() { + if (sleepStageSamples.isEmpty()) { + return; + } + + LOG.debug("Will persist {} sleep stage samples", sleepStageSamples.size()); + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + + final GarminSleepStageSampleProvider sampleProvider = new GarminSleepStageSampleProvider(gbDevice, session); + + for (final GarminSleepStageSample sample : sleepStageSamples) { + sample.setDevice(device); + sample.setUser(user); + } + + sampleProvider.addSamples(sleepStageSamples); + } catch (final Exception e) { + GB.toast(context, "Error saving sleep stage samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private void persistSpo2Samples() { + if (spo2samples.isEmpty()) { + return; + } + + LOG.debug("Will persist {} spo2 samples", stressSamples.size()); + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + + final GarminSpo2SampleProvider sampleProvider = new GarminSpo2SampleProvider(gbDevice, session); + + for (final GarminSpo2Sample sample : spo2samples) { + sample.setDevice(device); + sample.setUser(user); + } + + sampleProvider.addSamples(spo2samples); + } catch (final Exception e) { + GB.toast(context, "Error saving spo2 samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private void persistStressSamples() { + if (stressSamples.isEmpty()) { + return; + } + + LOG.debug("Will persist {} stress samples", stressSamples.size()); + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + + final GarminStressSampleProvider sampleProvider = new GarminStressSampleProvider(gbDevice, session); + + for (final GarminStressSample sample : stressSamples) { + sample.setDevice(device); + sample.setUser(user); + } + + sampleProvider.addSamples(stressSamples); + } catch (final Exception e) { + GB.toast(context, "Error saving stress samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java index 8b786bf32..aff3aec2c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecord.java @@ -2,6 +2,11 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages import androidx.annotation.Nullable; +import java.util.Date; + +import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminUtils; 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; @@ -64,4 +69,25 @@ public class FitRecord extends RecordData { public Long getTimestamp() { return (Long) getFieldByNumber(253); } + + // manual changes below + + public ActivityPoint toActivityPoint() { + final ActivityPoint activityPoint = new ActivityPoint(); + activityPoint.setTime(new Date(getComputedTimestamp())); + if (getLatitude() != null && getLongitude() != null) { + activityPoint.setLocation(new GPSCoordinate( + GarminUtils.semicirclesToDegrees(getLongitude().longValue()), + GarminUtils.semicirclesToDegrees(getLatitude().longValue()), + getEnhancedAltitude() != null ? getEnhancedAltitude() / 10d : GPSCoordinate.UNKNOWN_ALTITUDE + )); + } + if (getHeartRate() != null) { + activityPoint.setHeartRate(getHeartRate()); + } + if (getEnhancedSpeed() != null) { + activityPoint.setSpeed(getEnhancedSpeed()); + } + return activityPoint; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/RangeMap.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/RangeMap.java index 6c96658b2..f34e812f9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/RangeMap.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/RangeMap.java @@ -22,14 +22,33 @@ import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.List; /** - * A map of lower bounds for ranges. + * A map of bounds for ranges. Returns the value closest to the key, in upper or lower bound mode. */ public class RangeMap, V> { private final List> list = new ArrayList<>(); private boolean isSorted = false; + private final Comparator comparator; + + public RangeMap() { + this(Mode.LOWER_BOUND); + } + + public RangeMap(final Mode mode) { + switch (mode) { + case LOWER_BOUND: + comparator = (k1, k2) -> k1.compareTo(k2); + break; + case UPPER_BOUND: + comparator = (k1, k2) -> k2.compareTo(k1); + break; + default: + throw new IllegalArgumentException("Unknown mode " + mode); + } + } public void put(final K key, final V value) { list.add(Pair.create(key, value)); @@ -39,14 +58,12 @@ public class RangeMap, V> { @Nullable public V get(final K key) { if (!isSorted) { - Collections.sort(list, (a, b) -> { - return a.first.compareTo(b.first); - }); + Collections.sort(list, (a, b) -> comparator.compare(a.first, b.first)); isSorted = true; } for (int i = list.size() - 1; i >= 0; i--) { - if (key.compareTo(list.get(i).first) > 0) { + if (comparator.compare(key, list.get(i).first) >= 0) { return list.get(i).second; } } @@ -61,4 +78,10 @@ public class RangeMap, V> { public int size() { return list.size(); } + + public enum Mode { + LOWER_BOUND, + UPPER_BOUND, + ; + } } diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/RangeMapTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/RangeMapTest.java new file mode 100644 index 000000000..e6259c3c4 --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/util/RangeMapTest.java @@ -0,0 +1,51 @@ +package nodomain.freeyourgadget.gadgetbridge.util; + +import static org.junit.Assert.*; + +import org.junit.Test; + +import nodomain.freeyourgadget.gadgetbridge.test.TestBase; + +public class RangeMapTest extends TestBase { + @Test + public void testLowerBound() { + final RangeMap map = new RangeMap<>(); + assertEquals(0, map.size()); + assertNull(map.get(0)); + + map.put(10, 20); + assertNull(map.get(0)); + assertEquals(20, map.get(10).intValue()); + assertEquals(20, map.get(20).intValue()); + + map.put(20, 30); + map.put(30, 40); + assertNull(map.get(0)); + assertEquals(20, map.get(10).intValue()); + assertEquals(20, map.get(15).intValue()); + assertEquals(30, map.get(20).intValue()); + assertEquals(30, map.get(25).intValue()); + assertEquals(40, map.get(30).intValue()); + } + + @Test + public void testUpperBound() { + final RangeMap map = new RangeMap<>(RangeMap.Mode.UPPER_BOUND); + assertEquals(0, map.size()); + assertNull(map.get(0)); + + map.put(10, 20); + assertNull(map.get(20)); + assertEquals(20, map.get(10).intValue()); + assertEquals(20, map.get(0).intValue()); + + map.put(20, 30); + map.put(30, 40); + assertNull(map.get(50)); + assertEquals(40, map.get(30).intValue()); + assertEquals(40, map.get(25).intValue()); + assertEquals(30, map.get(20).intValue()); + assertEquals(30, map.get(15).intValue()); + assertEquals(20, map.get(10).intValue()); + } +}