From a240d0429e1d7340fd3d6d6e93baed8d04992465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Wed, 1 May 2024 22:09:25 +0100 Subject: [PATCH] Garmin WIP: Fix sleep parsing --- .../gadgetbridge/daogen/GBDaoGenerator.java | 10 + .../ActivitySummariesGpsFragment.java | 48 +++-- .../activities/ActivitySummaryDetail.java | 27 ++- .../garmin/GarminActivitySampleProvider.java | 28 ++- .../garmin/GarminEventSampleProvider.java | 80 ++++++++ .../model/ActivitySummaryData.java | 58 ++++++ .../service/devices/garmin/GarminUtils.java | 12 +- .../devices/garmin/fit/FitImporter.java | 171 +++++++++++------- .../garmin/fit/codegen/FitCodeGen.java | 29 ++- .../devices/garmin/fit/messages/FitGoals.java | 2 +- .../garmin/fit/messages/FitRecord.java | 26 +++ .../garmin/fit/messages/FitUserProfile.java | 5 - .../gadgetbridge/util/RangeMap.java | 33 +++- .../gadgetbridge/util/RangeMapTest.java | 51 ++++++ 14 files changed, 469 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminEventSampleProvider.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/ActivitySummaryData.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 ef776bbf4..8ba796cec 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -112,6 +112,7 @@ public class GBDaoGenerator { 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); @@ -702,6 +703,15 @@ public class GBDaoGenerator { 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/activities/ActivitySummariesGpsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesGpsFragment.java index 10a1861fe..3f62a7820 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesGpsFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummariesGpsFragment.java @@ -34,12 +34,17 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitFile; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FitImporter; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitRecord; import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParseException; import nodomain.freeyourgadget.gadgetbridge.util.gpx.GpxParser; import nodomain.freeyourgadget.gadgetbridge.util.gpx.model.GpxFile; @@ -76,21 +81,40 @@ public class ActivitySummariesGpsFragment extends AbstractGBFragment { new Thread(new Runnable() { @Override public void run() { - final GpxFile gpxFile; - - try (FileInputStream inputStream = new FileInputStream(inputFile)) { - final GpxParser gpxParser = new GpxParser(inputStream); - gpxFile = gpxParser.getGpxFile(); - } catch (final IOException e) { - LOG.error("Failed to open {}", inputFile, e); - return; - } catch (final GpxParseException e) { - LOG.error("Failed to parse gpx file", e); + final List points = new ArrayList<>(); + if (inputFile.getName().endsWith(".gpx")) { + try (FileInputStream inputStream = new FileInputStream(inputFile)) { + final GpxParser gpxParser = new GpxParser(inputStream); + points.addAll(gpxParser.getGpxFile().getPoints()); + } catch (final IOException e) { + LOG.error("Failed to open {}", inputFile, e); + return; + } catch (final GpxParseException e) { + LOG.error("Failed to parse gpx file", e); + return; + } + } else if (inputFile.getName().endsWith(".fit")) { + try { + FitFile fitFile = FitFile.parseIncoming(inputFile); + for (final RecordData record : fitFile.getRecords()) { + if (record instanceof FitRecord) { + points.add(((FitRecord) record).toActivityPoint().getLocation()); + } + } + } catch (final IOException e) { + LOG.error("Failed to open {}", inputFile, e); + return; + } catch (final Exception e) { + LOG.error("Failed to parse fit file", e); + return; + } + } else { + LOG.warn("Unknown file type {}", inputFile.getName()); return; } - if (!gpxFile.getPoints().isEmpty()) { - drawTrack(canvas, gpxFile.getPoints()); + if (!points.isEmpty()) { + drawTrack(canvas, points); } } }).start(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java index 9a6b372be..e50185a13 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ActivitySummaryDetail.java @@ -184,9 +184,9 @@ public class ActivitySummaryDetail extends AbstractGBActivity { makeSummaryHeader(newItem); makeSummaryContent(newItem); activitySummariesChartFragment.setDateAndGetData(getGBDevice(currentItem.getDevice()), currentItem.getStartTime().getTime() / 1000, currentItem.getEndTime().getTime() / 1000); - if (get_gpx_file() != null) { + if (getTrackFile() != null) { showCanvas(); - activitySummariesGpsFragment.set_data(get_gpx_file()); + activitySummariesGpsFragment.set_data(getTrackFile()); } else { hideCanvas(); } @@ -206,9 +206,9 @@ public class ActivitySummaryDetail extends AbstractGBActivity { makeSummaryHeader(newItem); makeSummaryContent(newItem); activitySummariesChartFragment.setDateAndGetData(getGBDevice(currentItem.getDevice()), currentItem.getStartTime().getTime() / 1000, currentItem.getEndTime().getTime() / 1000); - if (get_gpx_file() != null) { + if (getTrackFile() != null) { showCanvas(); - activitySummariesGpsFragment.set_data(get_gpx_file()); + activitySummariesGpsFragment.set_data(getTrackFile()); } else { hideCanvas(); } @@ -227,9 +227,9 @@ public class ActivitySummaryDetail extends AbstractGBActivity { makeSummaryHeader(currentItem); makeSummaryContent(currentItem); activitySummariesChartFragment.setDateAndGetData(getGBDevice(currentItem.getDevice()), currentItem.getStartTime().getTime() / 1000, currentItem.getEndTime().getTime() / 1000); - if (get_gpx_file() != null) { + if (getTrackFile() != null) { showCanvas(); - activitySummariesGpsFragment.set_data(get_gpx_file()); + activitySummariesGpsFragment.set_data(getTrackFile()); } else { hideCanvas(); } @@ -320,9 +320,9 @@ public class ActivitySummaryDetail extends AbstractGBActivity { public void onClick(DialogInterface dialog, int which) { currentItem.setGpxTrack(selectedGpxFile); currentItem.update(); - if (get_gpx_file() != null) { + if (getTrackFile() != null) { showCanvas(); - activitySummariesGpsFragment.set_data(get_gpx_file()); + activitySummariesGpsFragment.set_data(getTrackFile()); } else { hideCanvas(); } @@ -712,7 +712,7 @@ public class ActivitySummaryDetail extends AbstractGBActivity { gpsView.setLayoutParams(params); } - private File get_gpx_file() { + private File getTrackFile() { final String gpxTrack = currentItem.getGpxTrack(); if (gpxTrack != null) { File file = new File(gpxTrack); @@ -722,6 +722,15 @@ public class ActivitySummaryDetail extends AbstractGBActivity { return null; } } + final String rawDetails = currentItem.getRawDetailsPath(); + if (rawDetails != null && rawDetails.endsWith(".fit")) { + File file = new File(rawDetails); + if (file.exists()) { + return file; + } else { + return null; + } + } return null; } 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 index 647dd3a0a..44d10f2ce 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminActivitySampleProvider.java @@ -29,10 +29,13 @@ 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; @@ -142,15 +145,34 @@ public class GarminActivitySampleProvider extends AbstractSampleProvider samples, final int timestamp_from, final int timestamp_to) { - final RangeMap stagesMap = new RangeMap<>(); + // 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); @@ -183,8 +205,6 @@ public class GarminActivitySampleProvider extends AbstractSampleProvider. */ +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/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/service/devices/garmin/GarminUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminUtils.java index c218f2c95..aab5c9405 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,10 +10,18 @@ public final class GarminUtils { // utility class } + public static int degreesToSemicircles(final double degrees) { + return (int) ((degrees * 2.147483648E9d) / 180.0d); + } + + public static double semicirclesToDegrees(final long semicircles) { + return semicircles * (180.0D / 0x80000000); + } + 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)) - .setLon((int) ((location.getLongitude() * 2.147483648E9d) / 180.0d)) + .setLat(degreesToSemicircles(location.getLatitude())) + .setLon(degreesToSemicircles(location.getLongitude())) .build(); float vAccuracy = 0; 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 index a82f2f15b..7ece581bf 100644 --- 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 @@ -12,8 +12,6 @@ import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries. import android.content.Context; import android.widget.Toast; -import org.json.JSONException; -import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,11 +23,14 @@ 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; @@ -37,6 +38,7 @@ 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; @@ -44,7 +46,7 @@ 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.GPSCoordinate; +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; @@ -66,9 +68,10 @@ public class FitImporter { 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 sleepEvents = 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<>(); @@ -83,11 +86,11 @@ public class FitImporter { } public void importFile(final File file) throws IOException { - importFile(FitFile.parseIncoming(file)); - } + reset(); - public void importFile(final FitFile file) { - for (final RecordData record : file.getRecords()) { + final FitFile fitFile = FitFile.parseIncoming(file); + + for (final RecordData record : fitFile.getRecords()) { final Long ts = record.getComputedTimestamp(); if (record instanceof FitFileId) { @@ -123,7 +126,7 @@ public class FitImporter { final Long steps = ((FitMonitoring) record).getCycles(); final Integer activityType = ((FitMonitoring) record).getComputedActivityType(); final Integer intensity = ((FitMonitoring) record).getComputedIntensity(); - LOG.trace("Monitoring: hr={} steps={} activityType={} intensity={}", hr, steps, activityType, intensity); + 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) { @@ -139,6 +142,12 @@ public class FitImporter { 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) { @@ -151,30 +160,25 @@ public class FitImporter { 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); - if (event.getEvent() == null || event.getEvent() != 74) { - continue; // we only handle sleep events for now + final GarminEventSample sample = new GarminEventSample(); + sample.setTimestamp(ts * 1000L); + sample.setEvent(event.getEvent()); + if (event.getEventType() != null) { + sample.setEventType(event.getEventType()); } - - sleepEvents.add(event); + if (event.getData() != null) { + sample.setData(event.getData()); + } + events.add(sample); } else if (record instanceof FitRecord) { - final FitRecord fitRecord = (FitRecord) record; - final ActivityPoint activityPoint = new ActivityPoint(); - if (fitRecord.getLatitude() != null && fitRecord.getLongitude() != null) { - activityPoint.setLocation(new GPSCoordinate( - fitRecord.getLongitude() * 485028008 / 2.147483648E9d, - fitRecord.getLatitude() * 485028008 / 2.147483648E9d, - fitRecord.getEnhancedAltitude() != null ? fitRecord.getEnhancedAltitude() / 10d : GPSCoordinate.UNKNOWN_ALTITUDE - )); - } - if (fitRecord.getHeartRate() != null) { - activityPoint.setHeartRate(fitRecord.getHeartRate()); - } - if (fitRecord.getEnhancedSpeed() != null) { - activityPoint.setSpeed(fitRecord.getEnhancedSpeed()); - } - activityPoints.add(activityPoint); + activityPoints.add(((FitRecord) record).toActivityPoint()); } else if (record instanceof FitSession) { LOG.debug("Session: {}", record); if (session != null) { @@ -218,7 +222,7 @@ public class FitImporter { switch (fileId.getType()) { case activity: - persistActivity(); + persistWorkout(file); break; case monitor: persistActivitySamples(); @@ -226,6 +230,7 @@ public class FitImporter { persistStressSamples(); break; case sleep: + persistEvents(); persistSleepStageSamples(); break; default: @@ -237,23 +242,24 @@ public class FitImporter { } } - private void persistActivity() { + private void persistWorkout(final File file) { if (session == null) { - LOG.error("Got activity from {}, but no session", fileId); + LOG.error("Got workout from {}, but no session", fileId); return; } if (sport == null) { - LOG.error("Got activity from {}, but no sport", fileId); + LOG.error("Got workout from {}, but no sport", fileId); return; } - LOG.debug("Persisting activity for {}", fileId); + LOG.debug("Persisting workout for {}", fileId); final BaseActivitySummary summary = new BaseActivitySummary(); summary.setActivityKind(ActivityKind.TYPE_UNKNOWN); - final JSONObject summaryData = new JSONObject(); + final ActivitySummaryData summaryData = new ActivitySummaryData(); + // TODO map all sports if (sport.getSport() != null) { switch (sport.getSport()) { case 2: @@ -268,13 +274,13 @@ public class FitImporter { break; default: LOG.warn("Unknown sub sport {}", sport.getSubSport()); - addSummaryData(summaryData, "Fit Sub Sport", sport.getSubSport(), ""); + summaryData.add("Fit Sub Sport", sport.getSubSport(), ""); } break; } default: LOG.warn("Unknown sport {}", sport.getSport()); - addSummaryData(summaryData, "Fit Sport", sport.getSport(), ""); + summaryData.add("Fit Sport", sport.getSport(), ""); } } @@ -292,24 +298,36 @@ public class FitImporter { summary.setEndTime(new Date(GarminTimeUtils.garminTimestampToJavaMillis(session.getStartTime().intValue() + session.getTotalElapsedTime().intValue() / 1000))); if (session.getTotalTimerTime() != null) { - addSummaryData(summaryData, ACTIVE_SECONDS, session.getTotalTimerTime() / 1000f, UNIT_SECONDS); + summaryData.add(ACTIVE_SECONDS, session.getTotalTimerTime() / 1000f, UNIT_SECONDS); } if (session.getTotalDistance() != null) { - addSummaryData(summaryData, DISTANCE_METERS, session.getTotalDistance() / 100f, UNIT_METERS); + summaryData.add(DISTANCE_METERS, session.getTotalDistance() / 100f, UNIT_METERS); } if (session.getTotalCalories() != null) { - addSummaryData(summaryData, CALORIES_BURNT, session.getTotalCalories(), UNIT_KCAL); + summaryData.add(CALORIES_BURNT, session.getTotalCalories(), UNIT_KCAL); } if (session.getTotalAscent() != null) { - addSummaryData(summaryData, ASCENT_DISTANCE, session.getTotalAscent(), UNIT_METERS); + summaryData.add(ASCENT_DISTANCE, session.getTotalAscent(), UNIT_METERS); } if (session.getTotalDescent() != null) { - addSummaryData(summaryData, DESCENT_DISTANCE, session.getTotalDescent(), UNIT_METERS); + summaryData.add(DESCENT_DISTANCE, session.getTotalDescent(), UNIT_METERS); } - summary.setSummaryData(summaryData.toString()); + //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) { + //} - // TODO fit path needs to reach here summary.setRawDetailsPath(null); + summary.setSummaryData(summaryData.toString()); + if (file != null) { + summary.setRawDetailsPath(file.getAbsolutePath()); + } try (DBHandler dbHandler = GBApplication.acquireDB()) { final DaoSession session = dbHandler.getDaoSession(); @@ -321,32 +339,22 @@ public class FitImporter { session.getBaseActivitySummaryDao().insertOrReplace(summary); } catch (final Exception e) { - GB.toast(context, "Error saving activity", Toast.LENGTH_LONG, GB.ERROR, e); + GB.toast(context, "Error saving workout", Toast.LENGTH_LONG, GB.ERROR, e); } } - protected void addSummaryData(final JSONObject summaryData, 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); - summaryData.put(key, innerData); - } catch (final JSONException ignore) { - } - } - } - - protected void addSummaryData(final JSONObject summaryData, final String key, final String value) { - if (key != null && !key.equals("") && value != null && !value.equals("")) { - try { - final JSONObject innerData = new JSONObject(); - innerData.put("value", value); - innerData.put("unit", "string"); - summaryData.put(key, innerData); - } catch (final JSONException ignore) { - } - } + 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() { @@ -377,6 +385,32 @@ public class FitImporter { } } + 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; @@ -384,9 +418,6 @@ public class FitImporter { LOG.debug("Will persist {} sleep stage samples", sleepStageSamples.size()); - // FIXME do not assume that last sample == awake - sleepStageSamples.get(sleepStageSamples.size() - 1).setStage(FieldDefinitionSleepStage.SleepStage.AWAKE.getId()); - try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/codegen/FitCodeGen.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/codegen/FitCodeGen.java index 8adebcbf4..0a36285f8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/codegen/FitCodeGen.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/codegen/FitCodeGen.java @@ -16,8 +16,12 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.GlobalFITMessage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; @@ -97,9 +101,12 @@ public class FitCodeGen { imports.add(RecordDefinition.class.getCanonicalName()); imports.add(RecordHeader.class.getCanonicalName()); //imports.add(GBToStringBuilder.class.getCanonicalName()); + imports.addAll(getImports(outputFile)); Collections.sort(imports); + final Set uniqueImports =new LinkedHashSet<>(imports); + for (final GlobalFITMessage.FieldDefinitionPrimitive primitive : globalFITMessage.getFieldDefinitionPrimitives()) { final Class fieldType = getFieldType(primitive); if (!Objects.requireNonNull(fieldType.getCanonicalName()).startsWith("java.lang")) { @@ -119,7 +126,7 @@ public class FitCodeGen { sb.append("\n"); boolean anyImport = false; - for (final String i : imports) { + for (final String i : uniqueImports) { if (i.startsWith("androidx")) { sb.append("import ").append(i).append(";\n"); anyImport = true; @@ -130,7 +137,7 @@ public class FitCodeGen { sb.append("\n"); anyImport = false; } - for (final String i : imports) { + for (final String i : uniqueImports) { if (i.startsWith("nodomain.freeyourgadget")) { sb.append("import ").append(i).append(";\n"); anyImport = true; @@ -141,7 +148,7 @@ public class FitCodeGen { sb.append("\n"); anyImport = false; } - for (final String i : imports) { + for (final String i : uniqueImports) { if (!i.startsWith("androidx") && !i.startsWith("nodomain.freeyourgadget")) { sb.append("import ").append(i).append(";\n"); anyImport = true; @@ -294,4 +301,20 @@ public class FitCodeGen { return ""; } + + public List getImports(final File file) throws IOException { + if (file.exists()) { + final String fileContents = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + final List imports = new ArrayList<>(); + + final Matcher m = Pattern.compile("import (.+);") + .matcher(fileContents); + while (m.find()) { + imports.add(m.group(1)); + } + return imports; + } + + return Collections.emptyList(); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitGoals.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitGoals.java index abdcf72db..e7ebf54c2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitGoals.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitGoals.java @@ -5,8 +5,8 @@ import androidx.annotation.Nullable; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader; -import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalType.Type; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalSource.Source; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalType.Type; // // WARNING: This class was auto-generated, please avoid modifying it directly. 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/service/devices/garmin/fit/messages/FitUserProfile.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitUserProfile.java index 9f69a1fac..6954f5d25 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitUserProfile.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitUserProfile.java @@ -7,11 +7,6 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDef import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionLanguage.Language; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem.Type; -import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem.Type; -import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem.Type; -import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem.Type; -import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem.Type; -import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem.Type; // // WARNING: This class was auto-generated, please avoid modifying it directly. 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()); + } +}