Garmin: Parse max met data and training load

This commit is contained in:
José Rebelo
2025-08-02 21:58:22 +01:00
parent f17497b140
commit 57d6398c50
9 changed files with 412 additions and 1 deletions
@@ -58,7 +58,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception {
final Schema schema = new Schema(109, MAIN_PACKAGE + ".entities");
final Schema schema = new Schema(110, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes);
@@ -209,6 +209,8 @@ public class GBDaoGenerator {
addGenericHrvValueSample(schema, user, device);
addGenericTemperatureSample(schema, user, device);
addGenericSleepStageSample(schema, user, device);
addGenericTrainingLoadAcuteSample(schema, user, device);
addGenericTrainingLoadChronicSample(schema, user, device);
new DaoGenerator().generateAll(schema, "app/src/main/java");
}
@@ -1948,4 +1950,18 @@ public class GBDaoGenerator {
sleepStageSample.addIntProperty("stage").notNull();
return sleepStageSample;
}
private static Entity addGenericTrainingLoadAcuteSample(Schema schema, Entity user, Entity device) {
Entity sample = addEntity(schema, "GenericTrainingLoadAcuteSample");
addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
sample.addIntProperty("value").notNull();
return sample;
}
private static Entity addGenericTrainingLoadChronicSample(Schema schema, Entity user, Entity device) {
Entity sample = addEntity(schema, "GenericTrainingLoadChronicSample");
addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
sample.addIntProperty("value").notNull();
return sample;
}
}
@@ -592,6 +592,16 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
return false;
}
@Override
public boolean supportsTrainingLoad() {
return false;
}
@Override
public boolean supportsWorkoutLoad() {
return false;
}
@Override
public boolean supportsActivityTabs() {
return supportsActivityTracking();
@@ -257,6 +257,8 @@ public interface DeviceCoordinator {
boolean supportsSpeedzones();
boolean supportsActivityTabs();
boolean supportsActiveCalories();
boolean supportsTrainingLoad();
boolean supportsWorkoutLoad();
/**
* Returns true if measurement and fetching of body temperature is supported by the device
@@ -0,0 +1,56 @@
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices;
import androidx.annotation.NonNull;
import de.greenrobot.dao.AbstractDao;
import de.greenrobot.dao.Property;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadAcuteSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadAcuteSampleDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class GenericTrainingLoadAcuteSampleProvider extends AbstractTimeSampleProvider<GenericTrainingLoadAcuteSample> {
public GenericTrainingLoadAcuteSampleProvider(final GBDevice device, final DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<GenericTrainingLoadAcuteSample, ?> getSampleDao() {
return getSession().getGenericTrainingLoadAcuteSampleDao();
}
@NonNull
@Override
protected Property getTimestampSampleProperty() {
return GenericTrainingLoadAcuteSampleDao.Properties.Timestamp;
}
@NonNull
@Override
protected Property getDeviceIdentifierSampleProperty() {
return GenericTrainingLoadAcuteSampleDao.Properties.DeviceId;
}
@Override
public GenericTrainingLoadAcuteSample createSample() {
return new GenericTrainingLoadAcuteSample();
}
}
@@ -0,0 +1,56 @@
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices;
import androidx.annotation.NonNull;
import de.greenrobot.dao.AbstractDao;
import de.greenrobot.dao.Property;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadChronicSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadChronicSampleDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class GenericTrainingLoadChronicSampleProvider extends AbstractTimeSampleProvider<GenericTrainingLoadChronicSample> {
public GenericTrainingLoadChronicSampleProvider(final GBDevice device, final DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<GenericTrainingLoadChronicSample, ?> getSampleDao() {
return getSession().getGenericTrainingLoadChronicSampleDao();
}
@NonNull
@Override
protected Property getTimestampSampleProperty() {
return GenericTrainingLoadChronicSampleDao.Properties.Timestamp;
}
@NonNull
@Override
protected Property getDeviceIdentifierSampleProperty() {
return GenericTrainingLoadChronicSampleDao.Properties.DeviceId;
}
@Override
public GenericTrainingLoadChronicSample createSample() {
return new GenericTrainingLoadChronicSample();
}
}
@@ -0,0 +1,211 @@
/* 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collection;
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.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
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.ActivitySummaryData;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.WorkoutLoadSample;
/**
* An implementation of {@link WorkoutLoadSampleProvider} that extracts VO2 Max values from
* workouts, for devices that provide them as part of the workout data.
*/
public class WorkoutLoadSampleProvider implements TimeSampleProvider<WorkoutLoadSample> {
private static final Logger LOG = LoggerFactory.getLogger(WorkoutLoadSampleProvider.class);
private final GBDevice device;
private final DaoSession session;
public WorkoutLoadSampleProvider(final GBDevice device, final DaoSession session) {
this.device = device;
this.session = session;
}
@NonNull
@Override
public List<WorkoutLoadSample> 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 DeviceCoordinator coordinator = device.getDeviceCoordinator();
final QueryBuilder<BaseActivitySummary> 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<BaseActivitySummary> samples = qb.build().list();
summaryDao.detachAll();
fillSummaryData(coordinator, samples);
return samples.stream()
.map(GarminWorkoutLoadSample::fromActivitySummary)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
@Override
public void addSample(final WorkoutLoadSample timeSample) {
throw new UnsupportedOperationException("Read-only sample provider");
}
@Override
public void addSamples(final List<WorkoutLoadSample> timeSamples) {
throw new UnsupportedOperationException("Read-only sample provider");
}
@Override
public WorkoutLoadSample createSample() {
throw new UnsupportedOperationException("Read-only sample provider");
}
@Nullable
@Override
public WorkoutLoadSample getLatestSample(final long until) {
final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao();
final Device dbDevice = DBHelper.findDevice(device, session);
if (dbDevice == null) {
// no device, no samples
return null;
}
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final QueryBuilder<BaseActivitySummary> qb = summaryDao.queryBuilder();
if (until != 0) {
qb.where(BaseActivitySummaryDao.Properties.StartTime.le(new Date(until)));
}
qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(dbDevice.getId()))
.orderDesc(BaseActivitySummaryDao.Properties.StartTime)
.limit(1);
final List<BaseActivitySummary> samples = qb.build().list();
summaryDao.detachAll();
fillSummaryData(coordinator, samples);
return !samples.isEmpty() ? GarminWorkoutLoadSample.fromActivitySummary(samples.get(0)) : null;
}
private void fillSummaryData(final DeviceCoordinator coordinator,
final Collection<BaseActivitySummary> summaries) {
ActivitySummaryParser activitySummaryParser = coordinator.getActivitySummaryParser(device, GBApplication.getContext());
for (final BaseActivitySummary summary : summaries) {
if (summary.getSummaryData() == null) {
activitySummaryParser.parseBinaryData(summary, true);
}
}
}
@Nullable
@Override
public WorkoutLoadSample getLatestSample() {
return getLatestSample(0);
}
@Nullable
@Override
public WorkoutLoadSample getFirstSample() {
final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao();
final Device dbDevice = DBHelper.findDevice(device, session);
if (dbDevice == null) {
// no device, no samples
return null;
}
final QueryBuilder<BaseActivitySummary> qb = summaryDao.queryBuilder();
qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(dbDevice.getId()))
.orderAsc(BaseActivitySummaryDao.Properties.StartTime)
.limit(1);
final List<BaseActivitySummary> samples = qb.build().list();
summaryDao.detachAll();
return !samples.isEmpty() ? GarminWorkoutLoadSample.fromActivitySummary(samples.get(0)) : null;
}
public static class GarminWorkoutLoadSample implements WorkoutLoadSample {
private final long timestamp;
private final int value;
public GarminWorkoutLoadSample(final long timestamp, final int value) {
this.timestamp = timestamp;
this.value = value;
}
@Override
public long getTimestamp() {
return timestamp;
}
@Override
public int getValue() {
return value;
}
@Nullable
public static GarminWorkoutLoadSample fromActivitySummary(final BaseActivitySummary summary) {
final String summaryDataJson = summary.getSummaryData();
if (summaryDataJson == null) {
return null;
}
if (!summaryDataJson.contains(ActivitySummaryEntries.TRAINING_LOAD)) {
return null;
}
final ActivitySummaryData summaryData = ActivitySummaryData.fromJson(summaryDataJson);
if (summaryData == null) {
return null;
}
final int value = summaryData.getNumber(ActivitySummaryEntries.TRAINING_LOAD, 0).intValue();
if (value == 0) {
return null;
}
return new GarminWorkoutLoadSample(summary.getStartTime().getTime(), value);
}
}
}
@@ -145,6 +145,17 @@ public abstract class GarminWatchCoordinator extends GarminCoordinator {
return 150;
}
@Override
public boolean supportsTrainingLoad() {
// TODO: Not all devices support it
return false;
}
@Override
public boolean supportsWorkoutLoad() {
return true;
}
@Override
public boolean supportsFindDevice() {
return true;
@@ -0,0 +1,21 @@
/* Copyright (C) 2025 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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.model;
public interface WorkoutLoadSample extends TimeSample {
int getValue();
}
@@ -23,6 +23,8 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.GenericTrainingLoadAcuteSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.GenericTrainingLoadChronicSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminActivitySampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminBodyEnergySampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminEventSampleProvider;
@@ -56,6 +58,8 @@ import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStatsSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2Sample;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadAcuteSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadChronicSample;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
@@ -85,6 +89,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.
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.service.devices.garmin.fit.messages.FitTrainingLoad;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitUserProfile;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@@ -108,6 +113,8 @@ public class FitImporter {
private final List<GarminHrvSummarySample> hrvSummarySamples = new ArrayList<>();
private final List<GarminHrvValueSample> hrvValueSamples = new ArrayList<>();
private final List<GarminRestingMetabolicRateSample> restingMetabolicRateSamples = new ArrayList<>();
private final List<GenericTrainingLoadAcuteSample> trainingLoadAcuteSamples = new ArrayList<>();
private final List<GenericTrainingLoadChronicSample> trainingLoadChronicSamples = new ArrayList<>();
private final Map<Integer, Integer> unknownRecords = new HashMap<>();
private FitSleepDataInfo fitSleepDataInfo = null;
private final List<FitSleepDataRaw> fitSleepDataRawSamples = new ArrayList<>();
@@ -318,6 +325,21 @@ public class FitImporter {
sample.setTimestamp(ts * 1000L);
sample.setRestingMetabolicRate(monitoringInfo.getRestingMetabolicRate());
restingMetabolicRateSamples.add(sample);
} else if (record instanceof FitTrainingLoad) {
final FitTrainingLoad trainingLoad = (FitTrainingLoad) record;
LOG.trace("Training load at {}: {}", ts, record);
if (trainingLoad.getTrainingLoadAcute() != null) {
final GenericTrainingLoadAcuteSample sample = new GenericTrainingLoadAcuteSample();
sample.setTimestamp(ts * 1000L);
sample.setValue(trainingLoad.getTrainingLoadAcute());
trainingLoadAcuteSamples.add(sample);
}
if (trainingLoad.getTrainingLoadChronic() != null) {
final GenericTrainingLoadChronicSample sample = new GenericTrainingLoadChronicSample();
sample.setTimestamp(ts * 1000L);
sample.setValue(trainingLoad.getTrainingLoadChronic());
trainingLoadChronicSamples.add(sample);
}
} else if (record instanceof FitMonitoringHrData) {
final FitMonitoringHrData monitoringHrData = (FitMonitoringHrData) record;
if (monitoringHrData.getRestingHeartRate() == null) {
@@ -396,6 +418,10 @@ public class FitImporter {
persistAbstractSamples(bodyEnergySamples, new GarminBodyEnergySampleProvider(gbDevice, session));
persistAbstractSamples(restingMetabolicRateSamples, new GarminRestingMetabolicRateSampleProvider(gbDevice, session));
break;
case METRICS:
persistAbstractSamples(trainingLoadAcuteSamples, new GenericTrainingLoadAcuteSampleProvider(gbDevice, session));
persistAbstractSamples(trainingLoadChronicSamples, new GenericTrainingLoadChronicSampleProvider(gbDevice, session));
break;
case SLEEP:
persistAbstractSamples(events, new GarminEventSampleProvider(gbDevice, session));
persistAbstractSamples(sleepStatsSamples, new GarminSleepStatsSampleProvider(gbDevice, session));
@@ -475,6 +501,8 @@ public class FitImporter {
hrvSummarySamples.clear();
hrvValueSamples.clear();
restingMetabolicRateSamples.clear();
trainingLoadAcuteSamples.clear();
trainingLoadChronicSamples.clear();
unknownRecords.clear();
fitSleepDataInfo = null;
fitSleepDataRawSamples.clear();