diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java index 7e1db994a..56967f889 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -80,6 +80,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.SleepRespiratoryRateSample; import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; import nodomain.freeyourgadget.gadgetbridge.model.StressSample; import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample; +import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample; import nodomain.freeyourgadget.gadgetbridge.model.WeightSample; import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; @@ -220,6 +221,11 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { return null; } + @Override + public Vo2MaxSampleProvider getVo2MaxSampleProvider(GBDevice device, DaoSession session) { + return null; + } + @Override public int[] getStressRanges() { // 0-39 = relaxed @@ -469,6 +475,11 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { return false; } + @Override + public boolean supportsVo2Max() { + return false; + } + @Override public boolean supportsActivityTabs() { return supportsActivityTracking(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index c18db349f..315da6094 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -60,6 +60,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.SleepRespiratoryRateSample; import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; import nodomain.freeyourgadget.gadgetbridge.model.StressSample; import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample; +import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample; import nodomain.freeyourgadget.gadgetbridge.model.WeightSample; import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport; @@ -220,6 +221,8 @@ public interface DeviceCoordinator { boolean supportsHrvMeasurement(); + boolean supportsVo2Max(); + boolean supportsSleepMeasurement(); boolean supportsStepCounter(); boolean supportsSpeedzones(); @@ -310,6 +313,11 @@ public interface DeviceCoordinator { */ TimeSampleProvider getHrvValueSampleProvider(GBDevice device, DaoSession session); + /** + * Returns the sample provider for VO2 max values, for the device being supported. + */ + TimeSampleProvider getVo2MaxSampleProvider(GBDevice device, DaoSession session); + /** * Returns the stress ranges (relaxed, mild, moderate, high), so that stress can be categorized. */ diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/Vo2MaxSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/Vo2MaxSampleProvider.java new file mode 100644 index 000000000..87dbf6d8d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/Vo2MaxSampleProvider.java @@ -0,0 +1,10 @@ +package nodomain.freeyourgadget.gadgetbridge.devices; + +import androidx.annotation.Nullable; + +import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample; + +public interface Vo2MaxSampleProvider extends TimeSampleProvider { + @Nullable + T getLatestSample(Vo2MaxSample.Type type); +} 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 6f8d3d4d2..4f909c2eb 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 @@ -23,6 +23,7 @@ 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.devices.Vo2MaxSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability; import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; @@ -44,6 +45,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample; import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample; import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; import nodomain.freeyourgadget.gadgetbridge.model.StressSample; +import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample; import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; @@ -134,6 +136,11 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator { return new GarminHrvValueSampleProvider(device, session); } + @Override + public Vo2MaxSampleProvider getVo2MaxSampleProvider(final GBDevice device, final DaoSession session) { + return new GarminVo2MaxSampleProvider(device, session); + } + @Override public TimeSampleProvider getSpo2SampleProvider(final GBDevice device, final DaoSession session) { return new GarminSpo2SampleProvider(device, session); @@ -210,6 +217,11 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator { return true; } + @Override + public boolean supportsVo2Max() { + return true; + } + @Override public int[] getStressRanges() { // 1-25 = relaxed diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminVo2MaxSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminVo2MaxSampleProvider.java new file mode 100644 index 000000000..fe9d8fc5f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminVo2MaxSampleProvider.java @@ -0,0 +1,239 @@ +/* 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.json.JSONException; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import de.greenrobot.dao.query.QueryBuilder; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.Vo2MaxSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries; +import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample; + +public class GarminVo2MaxSampleProvider implements Vo2MaxSampleProvider { + private static final Logger LOG = LoggerFactory.getLogger(GarminVo2MaxSampleProvider.class); + + private final GBDevice device; + private final DaoSession session; + + public GarminVo2MaxSampleProvider(final GBDevice device, final DaoSession session) { + this.device = device; + this.session = session; + } + + @NonNull + @Override + public List getAllSamples(final long timestampFrom, final long timestampTo) { + final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao(); + final Device dbDevice = DBHelper.findDevice(device, session); + if (dbDevice == null) { + // no device, no samples + return Collections.emptyList(); + } + + final QueryBuilder qb = summaryDao.queryBuilder(); + qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(dbDevice.getId())) + .where(BaseActivitySummaryDao.Properties.StartTime.gt(new Date(timestampFrom))) + .where(BaseActivitySummaryDao.Properties.StartTime.lt(new Date(timestampTo))) + .orderAsc(BaseActivitySummaryDao.Properties.StartTime); + + final List samples = qb.build().list(); + summaryDao.detachAll(); + + return samples.stream() + .map(GarminVo2maxSample::fromActivitySummary) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + @Override + public void addSample(final Vo2MaxSample timeSample) { + throw new UnsupportedOperationException("Read-only sample provider"); + } + + @Override + public void addSamples(final List timeSamples) { + throw new UnsupportedOperationException("Read-only sample provider"); + } + + @Override + public Vo2MaxSample createSample() { + throw new UnsupportedOperationException("Read-only sample provider"); + } + + @Nullable + @Override + public Vo2MaxSample getLatestSample(final Vo2MaxSample.Type type) { + final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao(); + final Device dbDevice = DBHelper.findDevice(device, session); + if (dbDevice == null) { + // no device, no samples + return null; + } + + final QueryBuilder qb = summaryDao.queryBuilder(); + + switch (type) { + case RUNNING: + qb.where(BaseActivitySummaryDao.Properties.ActivityKind.in(Arrays.asList( + ActivityKind.INDOOR_RUNNING.getCode(), + ActivityKind.OUTDOOR_RUNNING.getCode(), + ActivityKind.CROSS_COUNTRY_RUNNING.getCode(), + ActivityKind.RUNNING.getCode() + ))); + break; + case CYCLING: + qb.where(BaseActivitySummaryDao.Properties.ActivityKind.in(Arrays.asList( + ActivityKind.CYCLING.getCode(), + ActivityKind.INDOOR_CYCLING.getCode(), + ActivityKind.HANDCYCLING.getCode(), + ActivityKind.HANDCYCLING_INDOOR.getCode(), + ActivityKind.MOTORCYCLING.getCode(), + ActivityKind.OUTDOOR_CYCLING.getCode() + ))); + break; + default: + break; + } + + qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(dbDevice.getId())) + .orderDesc(BaseActivitySummaryDao.Properties.StartTime) + .limit(1); + + final List samples = qb.build().list(); + summaryDao.detachAll(); + + return !samples.isEmpty() ? GarminVo2maxSample.fromActivitySummary(samples.get(0)) : null; + } + + @Nullable + @Override + public Vo2MaxSample getLatestSample() { + return getLatestSample(Vo2MaxSample.Type.GENERAL); + } + + @Nullable + @Override + public Vo2MaxSample getFirstSample() { + final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao(); + final Device dbDevice = DBHelper.findDevice(device, session); + if (dbDevice == null) { + // no device, no samples + return null; + } + + final QueryBuilder qb = summaryDao.queryBuilder(); + qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(dbDevice.getId())) + .orderAsc(BaseActivitySummaryDao.Properties.StartTime) + .limit(1); + + final List samples = qb.build().list(); + summaryDao.detachAll(); + + return !samples.isEmpty() ? GarminVo2maxSample.fromActivitySummary(samples.get(0)) : null; + } + + public static class GarminVo2maxSample implements Vo2MaxSample { + private final long timestamp; + private final Type type; + private final float value; + + public GarminVo2maxSample(final long timestamp, final Type type, final float value) { + this.timestamp = timestamp; + this.type = type; + this.value = value; + } + + @Override + public long getTimestamp() { + return timestamp; + } + + @Override + public Type getType() { + return type; + } + + @Override + public float getValue() { + return value; + } + + @Nullable + public static GarminVo2maxSample fromActivitySummary(final BaseActivitySummary summary) { + if (summary.getSummaryData() == null) { + return null; + } + + if (!summary.getSummaryData().contains(ActivitySummaryEntries.MAXIMUM_OXYGEN_UPTAKE)) { + return null; + } + + try { + final JSONObject summaryDataObject = new JSONObject(summary.getSummaryData()); + final JSONObject vo2jsonObj = summaryDataObject.getJSONObject(ActivitySummaryEntries.MAXIMUM_OXYGEN_UPTAKE); + final double value = vo2jsonObj.optDouble("value", 0); + if (value == 0) { + return null; + } + + final Vo2MaxSample.Type type; + switch (ActivityKind.fromCode(summary.getActivityKind())) { + case INDOOR_RUNNING: + case OUTDOOR_RUNNING: + case CROSS_COUNTRY_RUNNING: + case RUNNING: + type = Vo2MaxSample.Type.RUNNING; + break; + case CYCLING: + case INDOOR_CYCLING: + case HANDCYCLING: + case HANDCYCLING_INDOOR: + case MOTORCYCLING: + case OUTDOOR_CYCLING: + type = Vo2MaxSample.Type.CYCLING; + break; + default: + type = Vo2MaxSample.Type.GENERAL; + } + return new GarminVo2maxSample(summary.getStartTime().getTime(), type, (float) value); + } catch (final JSONException e) { + LOG.error("Failed to parse summary data json", e); + return null; + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Vo2MaxSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Vo2MaxSample.java new file mode 100644 index 000000000..330eef764 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Vo2MaxSample.java @@ -0,0 +1,32 @@ +/* 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; + +public interface Vo2MaxSample extends TimeSample { + enum Type { + GENERAL, + RUNNING, + CYCLING + } + + Type getType(); + + /** + * The VO2 Max value, in ml/kg/min. + */ + float getValue(); +}