Garmin: extract and store metrics (#5708, #5709, #5710)

This extracts the following metrics and stores them in a new GenericMetricSample / GENERIC_METRIC_SAMPLE table:
- endurance score
- functional threshold power
- hill endurance
- hill score
- hill strength
- MET max VO2
- running lactate threshold power
- training readiness

Visualisation and charts aren't part of this PR.
This commit is contained in:
Thomas Kuehne
2026-04-12 17:13:10 +00:00
parent bec7846657
commit f9d9ecce09
6 changed files with 241 additions and 10 deletions
@@ -50,6 +50,7 @@ import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStageSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminSleepStatsSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminSpo2SampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminStressSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericMetricSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadAcuteSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadAcuteSampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadChronicSample;
@@ -126,6 +127,7 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
put(session.getPendingFileDao(), PendingFileDao.Properties.DeviceId);
put(session.getGenericTrainingLoadAcuteSampleDao(), GenericTrainingLoadAcuteSampleDao.Properties.DeviceId);
put(session.getGenericTrainingLoadChronicSampleDao(), GenericTrainingLoadChronicSampleDao.Properties.DeviceId);
put(session.getGenericMetricSampleDao(), GenericMetricSampleDao.Properties.DeviceId);
}};
}
@@ -0,0 +1,103 @@
/* Copyright (C) 2026 Thomas Kuehne
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;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitEnduranceScore;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitFunctionalMetrics;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitHillScore;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitMaxMetData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitTrainingReadiness;
public interface MetricSample extends TimeSample {
default Metric getMetric() {
int dbId = getMetricType();
return Metric.fromDbId(dbId);
}
default void setMetric(@NonNull Metric type) {
setMetricType(type.dbId);
}
default void setMetric(@NonNull Metric type, @Nullable Double value, @Nullable Long extra) {
setMetricType(type.dbId);
setMetricScore(value);
setMetricExtra(extra);
}
int getMetricType();
/// use {@link #setMetric(Metric)} or {@link #setMetric(Metric, Double, Long)} instead
void setMetricType(@IntRange(from = 0, to = 8) int type);
@Nullable
Double getMetricScore();
void setMetricScore(@Nullable Double value);
@Nullable
Long getMetricExtra();
void setMetricExtra(@Nullable Long extra);
enum Metric {
UNKNOWN(0),
/// @see FitEnduranceScore#getEnduranceScore()
/// @see FitEnduranceScore#getLevel()
GARMIN_ENDURANCE_SCORE(1),
/// @see FitFunctionalMetrics#getFunctionalThresholdPower()
/// @see FitFunctionalMetrics#getCyclingLactaceThresholdHr()
GARMIN_FUNCTIONAL_THRESHOLD_POWER(2),
/// @see FitHillScore#getHillEndurance()
/// @see FitHillScore#getLevel()
GARMIN_HILL_ENDURANCE(3),
/// @see FitHillScore#getHillScore()
/// @see FitHillScore#getLevel()
GARMIN_HILL_SCORE(4),
/// @see FitHillScore#getHillStrength()
/// @see FitHillScore#getLevel()
GARMIN_HILL_STRENGTH(5),
/// @see FitMaxMetData#getVo2Max()
/// @see FitMaxMetData#getMaxMetCategory()
GARMIN_MET_MAX_VO2(6),
/// @see FitFunctionalMetrics#getRunningLactateThresholdPower()
/// @see FitFunctionalMetrics#getRunningLactateThresholdHr()
GARMIN_RUNNING_LACTATE_THRESHOLD_POWER(7),
/// @see FitTrainingReadiness#getTrainingReadiness()
/// @see FitTrainingReadiness#getLevel()
GARMIN_TRAINING_READINESS(8);
final int dbId;
Metric(int dbId) {
this.dbId = dbId;
}
@Nullable
static Metric fromDbId(int dbId) {
for (Metric metric : values()) {
if (metric.dbId == dbId) {
return metric;
}
}
return null;
}
}
}
@@ -77,6 +77,7 @@ 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.GenericMetricSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadAcuteSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadChronicSample;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
@@ -87,16 +88,21 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
import nodomain.freeyourgadget.gadgetbridge.model.FitActivityTrackProvider;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.exception.FitParseException;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionHrvStatus;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionSleepStage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitDeviceStatus;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitEnduranceScore;
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.FitFunctionalMetrics;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitHillScore;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitHrvSummary;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitHrvValue;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitMaxMetData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitMonitoring;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitMonitoringHrData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitMonitoringInfo;
@@ -115,6 +121,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.
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.FitTrainingReadiness;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitUserProfile;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@@ -147,6 +154,7 @@ public class FitImporter {
private final List<GarminSleepRestlessMomentsSample> sleepRestlessMomentsSamples = new ArrayList<>();
private final List<BatteryLevel> batterySamples = new ArrayList<>();
private FitFileId fileId = null;
private final List<GenericMetricSample> genericMetricSamples = new ArrayList<>();
private final GarminWorkoutParser workoutParser;
@@ -406,6 +414,99 @@ public class FitImporter {
batteryLevel.setLevel(level);
batterySamples.add(batteryLevel);
}
} else if (record instanceof FitHillScore fitHillScore) {
final Integer rawLevel = fitHillScore.getLevel();
final Long level = (rawLevel == null) ? null : rawLevel.longValue();
final Integer rawEndurance = fitHillScore.getHillEndurance();
final double endurance = (rawEndurance == null) ? Double.NaN : rawEndurance.doubleValue();
if (endurance > 0.0) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setMetric(MetricSample.Metric.GARMIN_HILL_ENDURANCE, endurance, level);
genericMetricSamples.add(sample);
}
final Integer rawScore = fitHillScore.getHillScore();
final double score = (rawScore == null) ? Double.NaN : rawScore.doubleValue();
if (score > 0.0) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setMetric(MetricSample.Metric.GARMIN_HILL_SCORE, score, level);
genericMetricSamples.add(sample);
}
final Integer rawStrength = fitHillScore.getHillStrength();
final double strength = (rawStrength == null) ? Double.NaN : rawStrength.doubleValue();
if (strength > 0.0) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setMetric(MetricSample.Metric.GARMIN_HILL_STRENGTH, strength, level);
genericMetricSamples.add(sample);
}
} else if (record instanceof FitTrainingReadiness fitTrainingReadiness) {
final Integer rawReadiness = fitTrainingReadiness.getTrainingReadiness();
final Integer rawLevel = fitTrainingReadiness.getLevel();
final Double readiness = (rawReadiness == null) ? null : rawReadiness.doubleValue();
final Long level = (rawLevel == null) ? null : rawLevel.longValue();
if ((level != null && level > 0) || (readiness != null && readiness > 0)) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setMetric(MetricSample.Metric.GARMIN_TRAINING_READINESS, readiness, level);
genericMetricSamples.add(sample);
}
} else if (record instanceof FitEnduranceScore fitEnduranceScore) {
final Integer rawScore = fitEnduranceScore.getEnduranceScore();
final Integer rawLevel = fitEnduranceScore.getLevel();
final Double score = (rawScore == null) ? null : rawScore.doubleValue();
final Long level = (rawLevel == null) ? null : rawLevel.longValue();
if ((level != null && level > 0) || (score != null && score > 0)) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setMetric(MetricSample.Metric.GARMIN_ENDURANCE_SCORE, score, level);
genericMetricSamples.add(sample);
}
} else if (record instanceof FitFunctionalMetrics fitFunctionalMetrics) {
final Integer rawFtp = fitFunctionalMetrics.getFunctionalThresholdPower();
if (rawFtp != null && rawFtp > 0) {
final Double ftp = rawFtp.doubleValue();
final Integer rawHr = fitFunctionalMetrics.getCyclingLactaceThresholdHr();
final Long hr = (rawHr == null) ? null : rawHr.longValue();
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setMetric(MetricSample.Metric.GARMIN_FUNCTIONAL_THRESHOLD_POWER, ftp, hr);
genericMetricSamples.add(sample);
}
final Integer rawLtp = fitFunctionalMetrics.getRunningLactateThresholdPower();
if (rawLtp != null && rawLtp > 0) {
final Double ltp = rawLtp.doubleValue();
final Integer rawHr = fitFunctionalMetrics.getRunningLactateThresholdHr();
final Long hr = (rawHr == null) ? null : rawHr.longValue();
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setMetric(MetricSample.Metric.GARMIN_RUNNING_LACTATE_THRESHOLD_POWER, ltp, hr);
genericMetricSamples.add(sample);
}
}else if(record instanceof FitMaxMetData fitMaxMetData){
final Float rawVo2Max = fitMaxMetData.getVo2Max();
final Integer rawMaxMetCategory = fitMaxMetData.getMaxMetCategory();
final Double vo2Max = (rawVo2Max == null) ? null : rawVo2Max.doubleValue();
final Long maxMetCategory = (rawMaxMetCategory == null) ? null : rawMaxMetCategory.longValue();
if((vo2Max != null && vo2Max > 0) || (maxMetCategory != null && maxMetCategory > 0)){
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setMetric(MetricSample.Metric.GARMIN_MET_MAX_VO2, vo2Max, maxMetCategory);
genericMetricSamples.add(sample);
}
} else {
LOG.trace("Unknown record: {}", record);
@@ -506,7 +607,9 @@ public class FitImporter {
try (DBHandler handler = GBApplication.acquireDB()) {
final DaoSession session = handler.getDaoSession();
final long deviceId = DBHelper.getDevice(gbDevice, session).getId();
final long userId = DBHelper.getUser(session).getId();
persistBattery(session, deviceId);
persistGenericMetrics(session, deviceId, userId);
} catch (final Exception e) {
GB.toast(context, "Error saving generic samples", Toast.LENGTH_LONG, GB.ERROR, e);
}
@@ -526,6 +629,16 @@ public class FitImporter {
}
}
private void persistGenericMetrics(final DaoSession session, final long deviceId, final long userId) {
if (!genericMetricSamples.isEmpty()) {
genericMetricSamples.forEach(sample -> {
sample.setUserId(userId);
sample.setDeviceId(deviceId);
});
session.getGenericMetricSampleDao().insertOrReplaceInTx(genericMetricSamples);
}
}
private void persistWorkout(final File file, final DaoSession session, boolean isReprocessing) {
LOG.debug("Persisting workout for {}", fileId);
@@ -613,6 +726,7 @@ public class FitImporter {
batterySamples.clear();
fileId = null;
workoutParser.reset();
genericMetricSamples.clear();
}
private void persistActivitySamples(final DaoSession session) {
@@ -1855,12 +1855,12 @@ public class NativeFITMessage {
new FieldDefinitionPrimitive(0, BaseType.UINT8, "unknown_0"),
new FieldDefinitionPrimitive(2, BaseType.UINT32, "unknown_2"),
new FieldDefinitionPrimitive(3, BaseType.UINT32, "unknown_3"),
new FieldDefinitionPrimitive(4, BaseType.UINT16, "functional_threshold_power"), // W
new FieldDefinitionPrimitive(4, BaseType.UINT16, "functional_threshold_power"), // Watt
new FieldDefinitionPrimitive(5, BaseType.UINT8, "unknown_5"),
new FieldDefinitionPrimitive(6, BaseType.UINT8, "unknown_6"),
new FieldDefinitionPrimitive(7, BaseType.UINT16, "running_lactate_threshold_power"),
new FieldDefinitionPrimitive(8, BaseType.UINT8, "running_lactate_threshold_hr"),
new FieldDefinitionPrimitive(9, BaseType.UINT8, "cycling_lactace_threshold_hr"),
new FieldDefinitionPrimitive(7, BaseType.UINT16, "running_lactate_threshold_power"), // Watt
new FieldDefinitionPrimitive(8, BaseType.UINT8, "running_lactate_threshold_hr"), // BPM
new FieldDefinitionPrimitive(9, BaseType.UINT8, "cycling_lactace_threshold_hr"), // BPM
new FieldDefinitionPrimitive(10, BaseType.UINT16, "unknown_10"),
new FieldDefinitionPrimitive(11, BaseType.ENUM, "unknown_11"),
new FieldDefinitionPrimitive(12, BaseType.ENUM, "unknown_12"),
@@ -1870,7 +1870,7 @@ public class NativeFITMessage {
// Source: #5710
public static NativeFITMessage TRAINING_READINESS = new NativeFITMessage(369, "TRAINING_READINESS", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT8, "training_readiness"),
new FieldDefinitionPrimitive(1, BaseType.ENUM, "level"), // 4 high, 5 prime
new FieldDefinitionPrimitive(1, BaseType.ENUM, "level"), // 1=poor, 2=low, 3=moderate, 4=high, 5=prime
new FieldDefinitionPrimitive(2, BaseType.ENUM, "unknown_2"),
new FieldDefinitionPrimitive(3, BaseType.ENUM, "unknown_3"),
new FieldDefinitionPrimitive(4, BaseType.UINT8, "unknown_4"),
@@ -2028,7 +2028,7 @@ public class NativeFITMessage {
new FieldDefinitionPrimitive(1, BaseType.UINT8, "hill_strength"),
new FieldDefinitionPrimitive(2, BaseType.UINT8, "hill_endurance"),
new FieldDefinitionPrimitive(3, BaseType.ENUM, "unknown_3"), // 2?
new FieldDefinitionPrimitive(4, BaseType.ENUM, "unknown_4"), // 2?
new FieldDefinitionPrimitive(4, BaseType.ENUM, "level"), // 1=recreational, 2=challenger, 3=trained, 4=skilled, 5=expert, 6=elite
new FieldDefinitionPrimitive(5, BaseType.ENUM, "unknown_5"),
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
));
@@ -2036,7 +2036,7 @@ public class NativeFITMessage {
// Source: #5708
public static NativeFITMessage ENDURANCE_SCORE = new NativeFITMessage(403, "ENDURANCE_SCORE", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT16, "endurance_score"),
new FieldDefinitionPrimitive(1, BaseType.ENUM, "level"), // 6 superior 7 elite
new FieldDefinitionPrimitive(1, BaseType.ENUM, "level"), // 1=recreational, 2=intermediate, 3=trained, 4=well-trained, 5=expert, 6=superior, 7=elite
new FieldDefinitionPrimitive(2, BaseType.ENUM, "unknown_2"),
// Matches the boundaries from https://www8.garmin.com/manuals/webhelp/GUID-C001C335-A8EC-4A41-AB0E-BAC434259F92/EN-US/GUID-573861DC-64B1-4120-847F-A944BA683DBA.html
new FieldDefinitionPrimitive(3, BaseType.UINT16, "lower_bound_intermediate"),
@@ -60,7 +60,7 @@ public class FitHillScore extends RecordData {
}
@Nullable
public Integer getUnknown4() {
public Integer getLevel() {
return getFieldByNumber(4, Integer.class);
}
@@ -102,7 +102,7 @@ public class FitHillScore extends RecordData {
return this;
}
public Builder setUnknown4(final Integer value) {
public Builder setLevel(final Integer value) {
setFieldByNumber(4, value);
return this;
}