Merge pull request 'MetricsSamples: add experimental UI visualisation paths for TrainingAcuteLoad, TrainingChronicLoad and RestingMetabolicRate' (#6076)

Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/6076
This commit is contained in:
José Rebelo
2026-06-01 00:42:44 +02:00
17 changed files with 677 additions and 68 deletions
@@ -1,5 +1,6 @@
/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
Gobbetti, Dikay900, José Rebelo, Pavel Elagin, Petr Vaněk, walkjivefly
/* Copyright (C) 2015-2026 Andreas Shimokawa, Carsten Pfeiffer, Daniele
Gobbetti, Dikay900, José Rebelo, Pavel Elagin, Petr Vaněk, walkjivefly,
Thomas Kuehne
This file is part of Gadgetbridge.
@@ -48,7 +49,9 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBFragment;
import nodomain.freeyourgadget.gadgetbridge.database.DBAccess;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
/**
@@ -422,4 +425,9 @@ public abstract class AbstractChartFragment<D extends ChartsData> extends Abstra
getChartsHost().setDateInfo(DateTimeUtils.formatDateRange(from, to, dateFlags));
}
}
public boolean supportsMetrics(MetricSample.Metric metric) {
final GBDevice device = getChartsHost().getDevice();
return GenericMetricSampleProvider.supportsMetrics(device, metric);
}
}
@@ -1,4 +1,4 @@
/* Copyright (C) 2024-2025 a0z, José Rebelo, Martin.JM, Thomas Kuehne
/* Copyright (C) 2024-2026 a0z, José Rebelo, Martin.JM, Thomas Kuehne
This file is part of Gadgetbridge.
@@ -16,6 +16,9 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.getLatestMetricSample;
import static nodomain.freeyourgadget.gadgetbridge.model.MetricSample.Metric.GENERIC_RESTING_METABOLIC_RATE;
import android.os.Bundle;
import android.util.TypedValue;
import android.view.LayoutInflater;
@@ -30,6 +33,8 @@ import androidx.core.content.ContextCompat;
import com.github.mikephil.charting.charts.Chart;
import org.apache.commons.lang3.EnumUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.util.Calendar;
@@ -48,9 +53,11 @@ import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFragment.CaloriesData> {
private static final Logger LOG = LoggerFactory.getLogger(CaloriesDailyFragment.class);
private ImageView caloriesGauge;
private TextView dateView;
@@ -67,6 +74,7 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
TOTAL_CALORIES_SEGMENT
}
private GaugeViewMode gaugeViewMode;
private boolean metricMetabolicResting;
@Override
public void onCreate(final Bundle savedInstanceState) {
@@ -95,6 +103,12 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
getChartsHost().enableSwipeRefresh(scrollY == 0);
});
metricMetabolicResting = GBApplication.getPrefs().experimentalMetrics()
&& supportsMetrics(GENERIC_RESTING_METABOLIC_RATE);
if (metricMetabolicResting) {
LOG.info("using experimental MetricSample for resting metabolic rate");
}
caloriesGauge = rootView.findViewById(R.id.calories_gauge);
dateView = rootView.findViewById(R.id.date_view);
caloriesResting = rootView.findViewById(R.id.calories_resting);
@@ -124,6 +138,9 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
}
public boolean supportsActiveCalories() {
if (metricMetabolicResting) {
return true;
}
final GBDevice device = getChartsHost().getDevice();
return device.getDeviceCoordinator().supportsActiveCalories(device);
}
@@ -167,8 +184,15 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
String formattedDate = new SimpleDateFormat("E, MMM dd").format(date);
dateView.setText(formattedDate);
List<? extends ActivitySample> samples = getActivitySamples(db, device, startTs, endTs);
RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, device);
if (metabolicRate == null) {
final Integer restingMetabolicRate;
if (metricMetabolicResting) {
MetricSample sample = getLatestMetricSample(db, device, GENERIC_RESTING_METABOLIC_RATE);
restingMetabolicRate = (sample == null) ? null : (int) sample.getMetricScore();
} else {
RestingMetabolicRateSample sample = getRestingMetabolicRate(db, device);
restingMetabolicRate = (sample == null) ? null : sample.getRestingMetabolicRate();
}
if (restingMetabolicRate == null) {
return new CaloriesData(0, 0, 0, 0);
}
int totalBurnt;
@@ -179,7 +203,7 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
if (sameDay) {
passedDayProportion = (double) (calendar.getTimeInMillis() - day.getTimeInMillis()) / (24L * 60 * 60 * 1000);
}
int restingBurnt = (int) (metabolicRate.getRestingMetabolicRate() * passedDayProportion);
int restingBurnt = (int) (restingMetabolicRate * passedDayProportion);
for (int i = 0; i <= samples.size() - 1; i++) {
ActivitySample sample = samples.get(i);
@@ -191,7 +215,7 @@ public class CaloriesDailyFragment extends AbstractChartFragment<CaloriesDailyFr
activeBurnt = activeBurnt / 1000;
totalBurnt = restingBurnt + activeBurnt;
return new CaloriesData(totalBurnt, activeBurnt, restingBurnt, metabolicRate.getRestingMetabolicRate());
return new CaloriesData(totalBurnt, activeBurnt, restingBurnt, restingMetabolicRate);
}
@Override
@@ -1,5 +1,24 @@
/* Copyright (C) 2025-2026 trentsuzuki, 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.activities.charts;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.getLatestMetricSample;
import static nodomain.freeyourgadget.gadgetbridge.model.MetricSample.Metric.GENERIC_RESTING_METABOLIC_RATE;
import android.app.Activity;
import org.slf4j.Logger;
@@ -19,6 +38,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmount;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityAmounts;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
@@ -47,8 +67,14 @@ abstract class CaloriesFragment<T extends ChartsData> extends AbstractChartFragm
}
protected List<CaloriesFragment.CaloriesDay> getMyCaloriesDaysData(DBHandler db, Calendar day, GBDevice device) {
RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, device);
int restingCalories = metabolicRate.getRestingMetabolicRate();
int restingCalories;
if (GBApplication.getPrefs().experimentalMetrics() && supportsMetrics(GENERIC_RESTING_METABOLIC_RATE)) {
MetricSample sample = getLatestMetricSample(db, device, GENERIC_RESTING_METABOLIC_RATE);
restingCalories = (sample == null) ? 0 : (int) sample.getMetricScore();
} else {
RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, device);
restingCalories = metabolicRate.getRestingMetabolicRate();
}
day = (Calendar) day.clone(); // do not modify the caller's argument
day.add(Calendar.DATE, -TOTAL_DAYS + 1);
@@ -1,4 +1,4 @@
/* Copyright (C) 2025 a0z, Thomas Kuehne
/* Copyright (C) 2025-2026 a0z, Thomas Kuehne
This file is part of Gadgetbridge.
@@ -16,6 +16,11 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.charts;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.getLatestMetricSampleBefore;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.getMetricSamples;
import static nodomain.freeyourgadget.gadgetbridge.model.MetricSample.Metric.GENERIC_TRAINING_LOAD_ACUTE;
import static nodomain.freeyourgadget.gadgetbridge.model.MetricSample.Metric.GENERIC_TRAINING_LOAD_CHRONIC;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Color;
@@ -67,6 +72,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadAcuteSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericTrainingLoadChronicSample;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.WorkoutLoadSample;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
@@ -92,6 +98,8 @@ public class LoadFragment extends AbstractChartFragment<LoadFragment.LoadsData>
protected int TEXT_COLOR;
protected int LOAD_COLOR;
private boolean metricTrainingLoad;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_load, container, false);
@@ -104,6 +112,13 @@ public class LoadFragment extends AbstractChartFragment<LoadFragment.LoadsData>
dailyLoadChart = rootView.findViewById(R.id.daily_load_chart);
thisWeekTotal = rootView.findViewById(R.id.this_week_total);
lastWeekTotal = rootView.findViewById(R.id.last_week_total);
metricTrainingLoad = GBApplication.getPrefs().experimentalMetrics()
&& supportsMetrics(GENERIC_TRAINING_LOAD_ACUTE);
if (metricTrainingLoad) {
LOG.info("using experimental MetricSample for training load");
}
if (supportsTrainingLoad()) {
acuteLoadChart = rootView.findViewById(R.id.acute_load_chart);
acuteLoad = rootView.findViewById(R.id.acute_load);
@@ -123,6 +138,9 @@ public class LoadFragment extends AbstractChartFragment<LoadFragment.LoadsData>
}
public boolean supportsTrainingLoad() {
if (metricTrainingLoad) {
return true;
}
final GBDevice device = getChartsHost().getDevice();
return device.getDeviceCoordinator().supportsTrainingLoad(device);
}
@@ -295,13 +313,24 @@ public class LoadFragment extends AbstractChartFragment<LoadFragment.LoadsData>
load = workoutLoadSamples.stream().mapToInt(WorkoutLoadSample::getValue).sum();
}
if (supportsTrainingLoad()) {
List<? extends GenericTrainingLoadAcuteSample> workoutTrainingAcuteLoadSamples = getTrainingLoadAcuteSamples(db, device, startTs, endTs);
if (!workoutTrainingAcuteLoadSamples.isEmpty()) {
acuteLoad = workoutTrainingAcuteLoadSamples.get(workoutTrainingAcuteLoadSamples.size() - 1).getValue();
}
List<? extends GenericTrainingLoadChronicSample> workoutTrainingChronicLoadSamples = getTrainingLoadChronicSamples(db, device, startTs, endTs);
if (!workoutTrainingChronicLoadSamples.isEmpty()) {
chronicLoad = workoutTrainingChronicLoadSamples.get(workoutTrainingChronicLoadSamples.size() - 1).getValue();
if (metricTrainingLoad) {
List<? extends MetricSample> workoutTrainingAcuteLoadSamples = getMetricSamples(db, device, GENERIC_TRAINING_LOAD_ACUTE, startTs * 1000L, endTs * 1000L);
if (!workoutTrainingAcuteLoadSamples.isEmpty()) {
acuteLoad = (int) workoutTrainingAcuteLoadSamples.get(workoutTrainingAcuteLoadSamples.size() - 1).getMetricScore();
}
List<? extends MetricSample> workoutTrainingChronicLoadSamples = getMetricSamples(db, device, GENERIC_TRAINING_LOAD_CHRONIC, startTs * 1000L, endTs * 1000L);
if (!workoutTrainingChronicLoadSamples.isEmpty()) {
chronicLoad = (int) workoutTrainingChronicLoadSamples.get(workoutTrainingChronicLoadSamples.size() - 1).getMetricScore();
}
} else {
List<? extends GenericTrainingLoadAcuteSample> workoutTrainingAcuteLoadSamples = getTrainingLoadAcuteSamples(db, device, startTs, endTs);
if (!workoutTrainingAcuteLoadSamples.isEmpty()) {
acuteLoad = workoutTrainingAcuteLoadSamples.get(workoutTrainingAcuteLoadSamples.size() - 1).getValue();
}
List<? extends GenericTrainingLoadChronicSample> workoutTrainingChronicLoadSamples = getTrainingLoadChronicSamples(db, device, startTs, endTs);
if (!workoutTrainingChronicLoadSamples.isEmpty()) {
chronicLoad = workoutTrainingChronicLoadSamples.get(workoutTrainingChronicLoadSamples.size() - 1).getValue();
}
}
}
data.add(new LoadData((Calendar) day.clone(), load, acuteLoad, chronicLoad, i));
@@ -320,13 +349,27 @@ public class LoadFragment extends AbstractChartFragment<LoadFragment.LoadsData>
int latestChronicLoad = 0;
if (supportsTrainingLoad()) {
final Date dayEnd = DateTimeUtils.dayEnd(getEndDate());
GenericTrainingLoadAcuteSample latestAcuteLoadSample = getLatestTrainingLoadAcuteSample(db, device, dayEnd.getTime());
if (latestAcuteLoadSample != null) {
latestAcuteLoad = latestAcuteLoadSample.getValue();
}
GenericTrainingLoadChronicSample latestChronicLoadSample = getLatestTrainingLoadChronicSample(db, device, dayEnd.getTime());
if (latestChronicLoadSample != null) {
latestChronicLoad = latestChronicLoadSample.getValue();
if (metricTrainingLoad) {
MetricSample latestAcuteLoadSample = getLatestMetricSampleBefore(db, device, GENERIC_TRAINING_LOAD_ACUTE, dayEnd.getTime());
if (latestAcuteLoadSample != null) {
latestAcuteLoad = (int) latestAcuteLoadSample.getMetricScore();
}
MetricSample latestChronicLoadSample = getLatestMetricSampleBefore(db, device, GENERIC_TRAINING_LOAD_CHRONIC, dayEnd.getTime());
if (latestChronicLoadSample != null) {
latestChronicLoad = (int) latestChronicLoadSample.getMetricScore();
}
} else {
GenericTrainingLoadAcuteSample latestAcuteLoadSample = getLatestTrainingLoadAcuteSample(db, device, dayEnd.getTime());
if (latestAcuteLoadSample != null) {
latestAcuteLoad = latestAcuteLoadSample.getValue();
}
GenericTrainingLoadChronicSample latestChronicLoadSample = getLatestTrainingLoadChronicSample(db, device, dayEnd.getTime());
if (latestChronicLoadSample != null) {
latestChronicLoad = latestChronicLoadSample.getValue();
}
}
}
return new LoadsData(data, latestAcuteLoad, latestChronicLoad, thisWeekLoad, lastWeekLoad);
@@ -0,0 +1,78 @@
/* 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.database.schema;
import android.database.sqlite.SQLiteDatabase;
import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericMetricSampleDao;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
public class GadgetbridgeUpdate_131 implements DBUpdateScript {
private static void fixTimeStampOfGenericMetricSample(SQLiteDatabase db) {
// GenericMetricSamples were originally stored with epoc seconds instead
// of the usual epoc milli seconds
// epoc 31536000000 ms = epoc 31536000 s = year 1971
String statement = String.format("UPDATE %1$s SET %2$s = %2$s * 1000 WHERE %2$s < 31536000000",
GenericMetricSampleDao.TABLENAME,
GenericMetricSampleDao.Properties.Timestamp.columnName);
db.execSQL(statement);
}
private static void copyTrainingLoadAcute(SQLiteDatabase db) {
// one-off copy existing data so that vizualizations
// and migration can be tested before removing old code paths
final String sql;
sql = "INSERT OR IGNORE INTO GENERIC_METRIC_SAMPLE(TIMESTAMP, DEVICE_ID, USER_ID, METRIC_TYPE ,METRIC_SCORE, METRIC_EXTRA) "
+ "SELECT TIMESTAMP, DEVICE_ID, USER_ID, " + MetricSample.Metric.GENERIC_TRAINING_LOAD_ACUTE.dbId + ", VALUE, NULL "
+ "FROM GENERIC_TRAINING_LOAD_ACUTE_SAMPLE";
db.execSQL(sql);
}
private static void copyTrainingLoadChronic(SQLiteDatabase db) {
// one-off copy existing data so that vizualizations
// and migration can be tested before removing old code paths
final String sql;
sql = "INSERT OR IGNORE INTO GENERIC_METRIC_SAMPLE(TIMESTAMP, DEVICE_ID, USER_ID, METRIC_TYPE ,METRIC_SCORE, METRIC_EXTRA) "
+ "SELECT TIMESTAMP, DEVICE_ID, USER_ID, " + MetricSample.Metric.GENERIC_TRAINING_LOAD_CHRONIC.dbId + ", VALUE, NULL "
+ "FROM GENERIC_TRAINING_LOAD_CHRONIC_SAMPLE";
db.execSQL(sql);
}
private static void copyRestingMetabolicRate(SQLiteDatabase db) {
// one-off copy existing data so that vizualizations
// and migration can be tested before removing old code paths
final String sql;
sql = "INSERT OR IGNORE INTO GENERIC_METRIC_SAMPLE(TIMESTAMP, DEVICE_ID, USER_ID, METRIC_TYPE ,METRIC_SCORE, METRIC_EXTRA) "
+ "SELECT TIMESTAMP, DEVICE_ID, USER_ID, " + MetricSample.Metric.GENERIC_RESTING_METABOLIC_RATE.dbId + ", RESTING_METABOLIC_RATE, NULL "
+ "FROM GARMIN_RESTING_METABOLIC_RATE_SAMPLE";
db.execSQL(sql);
}
@Override
public void upgradeSchema(final SQLiteDatabase db) {
fixTimeStampOfGenericMetricSample(db);
copyTrainingLoadAcute(db);
copyTrainingLoadChronic(db);
copyRestingMetabolicRate(db);
}
@Override
public void downgradeSchema(final SQLiteDatabase db) {
}
}
@@ -94,6 +94,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.GpxActivityTrackProvider;
import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
@@ -1208,4 +1209,15 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
public boolean supportsConnectionPriority() {
return true;
}
@Override
public GenericMetricSampleProvider getMetricsSampleProvider(@NonNull final GBDevice device, @NonNull final DaoSession session) {
return new GenericMetricSampleProvider(device, session);
}
@Override
@NonNull
public Set<MetricSample.Metric> supportsMetrics(@NonNull GBDevice device) {
return GenericMetricSampleProvider.getSupportedMetrics(device);
}
}
@@ -64,6 +64,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.PaiSample;
import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample;
import nodomain.freeyourgadget.gadgetbridge.model.RestingMetabolicRateSample;
@@ -418,14 +419,18 @@ public interface DeviceCoordinator {
/**
* Returns the sample provider for training acute load values, for the device being supported.
* @deprecated see {@link #getMetricsSampleProvider(GBDevice, DaoSession)} and {@link MetricSample.Metric#GENERIC_TRAINING_LOAD_ACUTE}
*/
@Deprecated
@Nullable
TimeSampleProvider<? extends GenericTrainingLoadAcuteSample> getTrainingAcuteLoadSampleProvider(@NonNull final GBDevice device, @NonNull final DaoSession session);
/**
* Returns the sample provider for training chronic load values, for the device being supported.
* @deprecated see {@link #getMetricsSampleProvider(GBDevice, DaoSession)} and {@link MetricSample.Metric#GENERIC_TRAINING_LOAD_CHRONIC}
*/
@Deprecated
@Nullable
TimeSampleProvider<? extends GenericTrainingLoadChronicSample> getTrainingChronicLoadSampleProvider(@NonNull final GBDevice device, @NonNull final DaoSession session);
@@ -504,6 +509,8 @@ public interface DeviceCoordinator {
@Nullable
TimeSampleProvider<? extends WeightSample> getWeightSampleProvider(@NonNull final GBDevice device, @NonNull final DaoSession session);
/// @deprecated see {@link #getMetricsSampleProvider(GBDevice, DaoSession)} and {@link MetricSample.Metric#GENERIC_RESTING_METABOLIC_RATE}
@Deprecated
@Nullable
TimeSampleProvider<? extends RestingMetabolicRateSample> getRestingMetabolicRateProvider(@NonNull final GBDevice device, @NonNull final DaoSession session);
@@ -876,7 +883,7 @@ public interface DeviceCoordinator {
/**
* Indicates which device specific settings the device supports (not per device type or family, but unique per device).
*
* @deprecated use getDeviceSpecificSettings
* @deprecated use {@link #getDeviceSpecificSettings(GBDevice)}
*/
@Deprecated
@Nullable
@@ -1002,4 +1009,14 @@ public interface DeviceCoordinator {
* @return
*/
boolean supportsConnectionPriority();
/// @see MetricSample
/// @see #getMetricsSampleProvider(GBDevice, DaoSession)
@NonNull
Set<MetricSample.Metric> supportsMetrics(@NonNull GBDevice device);
/// @see MetricSample
/// @see #supportsMetrics(GBDevice)
@Nullable
GenericMetricSampleProvider getMetricsSampleProvider(@NonNull GBDevice device, @NonNull DaoSession session);
}
@@ -0,0 +1,301 @@
/* 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.devices;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import de.greenrobot.dao.AbstractDao;
import de.greenrobot.dao.Property;
import de.greenrobot.dao.query.QueryBuilder;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.DeviceDao;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericMetricSample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericMetricSampleDao;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
public class GenericMetricSampleProvider extends AbstractTimeSampleProvider<GenericMetricSample> {
private static final Logger LOG = LoggerFactory.getLogger(GenericMetricSampleProvider.class);
public GenericMetricSampleProvider(@NonNull final GBDevice device, @NonNull final DaoSession session) {
super(device, session);
}
@NonNull
public static List<? extends MetricSample> getMetricSamples(final @NonNull DBHandler db, final @NonNull GBDevice device, @NonNull MetricSample.Metric metric, long tsFromMillis, long tsToMillis) {
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final GenericMetricSampleProvider sampleProvider = coordinator.getMetricsSampleProvider(device, db.getDaoSession());
if (sampleProvider == null) {
LOG.warn("Device {} does not implement MetricSample", device);
return new ArrayList<>();
}
final List<? extends MetricSample> samples = sampleProvider.getAllSamples(metric, tsFromMillis, tsToMillis);
return samples;
}
@Nullable
public static MetricSample getLatestMetricSampleBefore(@NonNull final DBHandler db, @NonNull final GBDevice device, @NonNull MetricSample.Metric metric, long tsToMillis) {
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final GenericMetricSampleProvider sampleProvider = coordinator.getMetricsSampleProvider(device, db.getDaoSession());
if (sampleProvider == null) {
LOG.warn("Device {} does not implement MetricSample", device);
return null;
}
final MetricSample sample = sampleProvider.getLatestSampleBefore(metric, tsToMillis);
return sample;
}
@Nullable
public static MetricSample getLatestMetricSample(@NonNull final DBHandler db, @NonNull final GBDevice device, @NonNull MetricSample.Metric metric, @NotNull Calendar day) {
long tsStart = day.getTimeInMillis();
long tsEnd = tsStart + 24 * 60 * 60 * 1000L;
final MetricSample sample = getLatestMetricSample(db, device, metric, tsStart, tsEnd);
return sample;
}
@Nullable
public static MetricSample getLatestMetricSample(@NonNull final DBHandler db, @NonNull final GBDevice device, @NonNull MetricSample.Metric metric, long tsFromMillis, long tsToMillis) {
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final GenericMetricSampleProvider sampleProvider = coordinator.getMetricsSampleProvider(device, db.getDaoSession());
if (sampleProvider == null) {
LOG.warn("Device {} does not implement MetricSample", device);
return null;
}
final MetricSample sample = sampleProvider.getLatestSample(metric, tsFromMillis, tsToMillis);
return sample;
}
@Nullable
public static MetricSample getLatestMetricSample(@NonNull final DBHandler db, @NonNull final GBDevice device, @NonNull MetricSample.Metric metric) {
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final GenericMetricSampleProvider sampleProvider = coordinator.getMetricsSampleProvider(device, db.getDaoSession());
if (sampleProvider == null) {
LOG.warn("Device {} does not implement MetricSample", device);
return null;
}
final MetricSample sample = sampleProvider.getLatestSample(metric);
return sample;
}
public static boolean supportsMetrics(@NonNull GBDevice device, @NonNull MetricSample.Metric metric) {
// check the database to see what if this metric has actually been recorded.
HashSet<MetricSample.Metric> recorded = new HashSet<>();
try (DBHandler handler = GBApplication.acquireDbReadOnly()) {
SQLiteDatabase db = handler.getDatabase();
String sql = String.format(
"SELECT %1$s.%2$s FROM %1$s INNER JOIN %4$s ON %1$s.%3$s = %4$s.%5$s WHERE %4$s.%6$s = ? AND %1$s.%2$s = ? LIMIT 1",
GenericMetricSampleDao.TABLENAME,
GenericMetricSampleDao.Properties.MetricType.columnName,
GenericMetricSampleDao.Properties.DeviceId.columnName,
DeviceDao.TABLENAME,
DeviceDao.Properties.Id.columnName,
DeviceDao.Properties.Identifier.columnName
);
try (final Cursor c = db.rawQuery(sql, new String[]{device.getAddress(), Integer.toString(metric.dbId)})) {
while (c.moveToNext()) {
return true;
}
}
} catch (Exception e) {
LOG.warn("failed to identify recorded metrics ", e);
}
return false;
}
public static Set<MetricSample.Metric> getSupportedMetrics(@NonNull GBDevice device) {
// check the database to see what metrics have actually been recorded
// instead of guessing firmware version and usage dependent capabilities
HashSet<MetricSample.Metric> recorded = new HashSet<>();
try (DBHandler handler = GBApplication.acquireDbReadOnly()) {
SQLiteDatabase db = handler.getDatabase();
String sql = String.format(
"SELECT DISTINCT %1$s.%2$s FROM %1$s WHERE %1$s.%3$s = (SELECT %4$s.%5$s FROM %4$s WHERE %4$s.%6$s = ? LIMIT 1) ORDER BY %1$s.%2$s",
GenericMetricSampleDao.TABLENAME,
GenericMetricSampleDao.Properties.MetricType.columnName,
GenericMetricSampleDao.Properties.DeviceId.columnName,
DeviceDao.TABLENAME,
DeviceDao.Properties.Id.columnName,
DeviceDao.Properties.Identifier.columnName
);
try (final Cursor c = db.rawQuery(sql, new String[]{device.getAddress()})) {
while (c.moveToNext()) {
final int dbId = c.getInt(0);
MetricSample.Metric metric = MetricSample.Metric.fromDbId(dbId);
if (metric == null) {
LOG.warn("unknown MetricSample.Metric id: {}", dbId);
} else {
recorded.add(metric);
}
}
}
} catch (Exception e) {
LOG.warn("failed to identify recorded metrics ", e);
}
return recorded;
}
public static Set<MetricSample.Metric> supportsMetrics(@NonNull GBDevice device) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
Set<MetricSample.Metric> supported = coordinator.supportsMetrics(device);
return supported;
}
@NonNull
@Override
public AbstractDao<GenericMetricSample, ?> getSampleDao() {
return getSession().getGenericMetricSampleDao();
}
@NonNull
@Override
protected Property getTimestampSampleProperty() {
return GenericMetricSampleDao.Properties.Timestamp;
}
@NonNull
@Override
protected Property getDeviceIdentifierSampleProperty() {
return GenericMetricSampleDao.Properties.DeviceId;
}
@NonNull
@Override
public GenericMetricSample createSample() {
return new GenericMetricSample();
}
@NonNull
public List<GenericMetricSample> getAllSamples(@NonNull final MetricSample.Metric metric, final long timestampFrom, final long timestampTo) {
final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
if (dbDevice == null) {
// no device, no samples
return Collections.emptyList();
}
final AbstractDao<GenericMetricSample, ?> dao = getSampleDao();
final QueryBuilder<GenericMetricSample> qb = dao.queryBuilder();
final List<GenericMetricSample> samples = qb
.where(
GenericMetricSampleDao.Properties.DeviceId.eq(dbDevice.getId()),
GenericMetricSampleDao.Properties.Timestamp.between(timestampFrom, timestampTo),
GenericMetricSampleDao.Properties.MetricType.eq(metric.dbId)
)
.orderAsc(GenericMetricSampleDao.Properties.Timestamp)
.list();
return samples;
}
@Nullable
public MetricSample getLatestSampleBefore(@NonNull final MetricSample.Metric metric, final long until) {
final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
if (dbDevice == null) {
// no device, no sample
return null;
}
final AbstractDao<GenericMetricSample, ?> dao = getSampleDao();
final QueryBuilder<GenericMetricSample> qb = dao.queryBuilder();
final List<GenericMetricSample> samples = qb
.where(
GenericMetricSampleDao.Properties.DeviceId.eq(dbDevice.getId()),
GenericMetricSampleDao.Properties.Timestamp.le(until),
GenericMetricSampleDao.Properties.MetricType.eq(metric.dbId)
)
.orderDesc(GenericMetricSampleDao.Properties.Timestamp)
.limit(1)
.list();
if (samples.isEmpty()) {
return null;
}
final GenericMetricSample sample = samples.getFirst();
return sample;
}
@Nullable
public GenericMetricSample getLatestSample(@NonNull final MetricSample.Metric metric) {
final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
if (dbDevice == null) {
// no device, no sample
return null;
}
final AbstractDao<GenericMetricSample, ?> dao = getSampleDao();
final QueryBuilder<GenericMetricSample> qb = dao.queryBuilder();
final List<GenericMetricSample> samples = qb
.where(
GenericMetricSampleDao.Properties.DeviceId.eq(dbDevice.getId()),
GenericMetricSampleDao.Properties.MetricType.eq(metric.dbId)
)
.orderDesc(GenericMetricSampleDao.Properties.Timestamp)
.limit(1)
.list();
if (samples.isEmpty()) {
return null;
}
final GenericMetricSample sample = samples.getFirst();
return sample;
}
@Nullable
public GenericMetricSample getLatestSample(@NonNull final MetricSample.Metric metric, long tsFromMillis, long tsToMillis) {
final Device dbDevice = DBHelper.findDevice(getDevice(), getSession());
if (dbDevice == null) {
// no device, no sample
return null;
}
final AbstractDao<GenericMetricSample, ?> dao = getSampleDao();
final QueryBuilder<GenericMetricSample> qb = dao.queryBuilder();
final List<GenericMetricSample> samples = qb
.where(
GenericMetricSampleDao.Properties.DeviceId.eq(dbDevice.getId()),
GenericMetricSampleDao.Properties.MetricType.eq(metric.dbId),
GenericMetricSampleDao.Properties.Timestamp.between(tsFromMillis, tsToMillis)
)
.orderDesc(GenericMetricSampleDao.Properties.Timestamp)
.limit(1)
.list();
if (samples.isEmpty()) {
return null;
}
final GenericMetricSample sample = samples.getFirst();
return sample;
}
}
@@ -43,11 +43,13 @@ import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.Activity
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryTableBuilder;
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryValue;
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericMetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
import nodomain.freeyourgadget.gadgetbridge.model.MetricSample;
import nodomain.freeyourgadget.gadgetbridge.model.workout.Workout;
import nodomain.freeyourgadget.gadgetbridge.model.workout.WorkoutChart;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.AntGadget;
@@ -116,6 +118,7 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
private Number ebikeBatteryEnd = null;
@Nullable
private FitWorkout workout = null;
private final List<GenericMetricSample> genericMetricSamples = new ArrayList<>();
public GarminWorkoutParser(final Context context) {
this.context = context;
@@ -126,6 +129,11 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
return (session != null) ? session.getStartTime() : null;
}
@NonNull
public List<GenericMetricSample> getGenericMetricSamples(){
return genericMetricSamples;
}
@Override
public BaseActivitySummary parseBinaryData(BaseActivitySummary summary, boolean forDetails) {
// FIXME Do not use this
@@ -740,7 +748,14 @@ public class GarminWorkoutParser implements ActivitySummaryParser {
summaryData.add(TRAINING_EFFECT_AEROBIC, physiologicalMetrics.getAerobicEffect(), UNIT_NONE, true);
}
if (physiologicalMetrics.getMetMax() != null) {
summaryData.add(MAXIMUM_OXYGEN_UPTAKE, physiologicalMetrics.getMetMax().floatValue() * 3.5f, UNIT_ML_KG_MIN);
float vo2Max = physiologicalMetrics.getMetMax().floatValue() * 3.5f;
if (vo2Max > 0.0f) {
summaryData.add(MAXIMUM_OXYGEN_UPTAKE, vo2Max, UNIT_ML_KG_MIN);
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(physiologicalMetrics.computedTimestamp * 1000L);
sample.setMetric(MetricSample.Metric.GENERIC_MAXIMUM_OXYGEN_UPTAKE, vo2Max);
genericMetricSamples.add(sample);
}
}
if (physiologicalMetrics.getRecoveryTime() != null) {
summaryData.add(RECOVERY_TIME, physiologicalMetrics.getRecoveryTime() * 60, UNIT_SECONDS);
@@ -215,6 +215,7 @@ public class ActivitySummaryEntries {
public static final String UNIT_CM = "cm";
public static final String UNIT_UNIX_EPOCH_SECONDS = "unix_epoch_seconds";
public static final String UNIT_KCAL = "calories_unit";
public static final String UNIT_KCAL_PER_DAY = "unit_kcal_per_day";
public static final String UNIT_ML = "ml";
public static final String UNIT_LAPS = "laps_unit";
public static final String UNIT_KILOMETERS = "km";
@@ -1,5 +1,5 @@
/* Copyright (C) 2019-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel
Dakhno, José Rebelo, Petr Vaněk
/* Copyright (C) 2019-2026 Andreas Shimokawa, Carsten Pfeiffer, Daniel
Dakhno, José Rebelo, Petr Vaněk, Thomas Kuehne
This file is part of Gadgetbridge.
@@ -17,6 +17,10 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.model;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.getLatestMetricSample;
import static nodomain.freeyourgadget.gadgetbridge.devices.GenericMetricSampleProvider.supportsMetrics;
import static nodomain.freeyourgadget.gadgetbridge.model.MetricSample.Metric.GENERIC_RESTING_METABOLIC_RATE;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -159,8 +163,15 @@ public class DailyTotals implements Serializable {
day.set(Calendar.MINUTE, 0);
day.set(Calendar.SECOND, 0);
day.add(Calendar.HOUR, 0);
RestingMetabolicRateSample metabolicRate = getRestingMetabolicRate(db, day, device);
if (metabolicRate == null) {
final Integer restingMetabolicRate;
if (GBApplication.getPrefs().experimentalMetrics() && supportsMetrics(device, GENERIC_RESTING_METABOLIC_RATE)) {
MetricSample sample = getLatestMetricSample(db, device, GENERIC_RESTING_METABOLIC_RATE, day);
restingMetabolicRate = (sample == null) ? null : (int) sample.getMetricScore();
} else {
RestingMetabolicRateSample sample = getRestingMetabolicRate(db, day, device);
restingMetabolicRate = (sample == null) ? null : sample.getRestingMetabolicRate();
}
if (restingMetabolicRate == null) {
// should never happen
return 0;
}
@@ -170,7 +181,7 @@ public class DailyTotals implements Serializable {
if (sameDay) {
passedDayProportion = (double) (calendar.getTimeInMillis() - day.getTimeInMillis()) / (24L * 60 * 60 * 1000);
}
return (int) (metabolicRate.getRestingMetabolicRate() * passedDayProportion);
return (int) (restingMetabolicRate * passedDayProportion);
}
public static List<? extends ActivitySample> getSamples(DBHandler db, GBDevice device, int tsFrom, int tsTo) {
@@ -16,6 +16,11 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.model;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KCAL_PER_DAY;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_ML_KG_MIN;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_NONE;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_WATT;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -24,8 +29,14 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.
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.FitMonitoringInfo;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitPhysiologicalMetrics;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitTrainingLoad;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages.FitTrainingReadiness;
/**
* This is for infrequent statistics samples - for example one record per day and metric.
*/
public interface MetricSample extends TimeSample {
default Metric getMetric() {
int dbId = getMetricType();
@@ -36,21 +47,25 @@ public interface MetricSample extends TimeSample {
setMetricType(type.dbId);
}
default void setMetric(@NonNull Metric type, @Nullable Double value, @Nullable Long extra) {
default void setMetric(@NonNull Metric type, double score) {
setMetric(type, score, null);
}
default void setMetric(@NonNull Metric type, double score, @Nullable Long extra) {
setMetricType(type.dbId);
setMetricScore(value);
setMetricScore(score);
setMetricExtra(extra);
}
@IntRange(from = 0, to = 12)
int getMetricType();
/// use {@link #setMetric(Metric)} or {@link #setMetric(Metric, Double, Long)} instead
void setMetricType(@IntRange(from = 0, to = 8) int type);
void setMetricType(@IntRange(from = 1, to = 12) int type);
@Nullable
Double getMetricScore();
double getMetricScore();
void setMetricScore(@Nullable Double value);
void setMetricScore(double score);
@Nullable
Long getMetricExtra();
@@ -58,40 +73,57 @@ public interface MetricSample extends TimeSample {
void setMetricExtra(@Nullable Long extra);
enum Metric {
UNKNOWN(0),
UNKNOWN(0, UNIT_NONE),
/// @see FitEnduranceScore#getEnduranceScore()
/// @see FitEnduranceScore#getLevel()
GARMIN_ENDURANCE_SCORE(1),
GARMIN_ENDURANCE_SCORE(1, UNIT_NONE),
/// @see FitFunctionalMetrics#getFunctionalThresholdPower()
/// @see FitFunctionalMetrics#getCyclingLactaceThresholdHr()
GARMIN_FUNCTIONAL_THRESHOLD_POWER(2),
GARMIN_FUNCTIONAL_THRESHOLD_POWER(2, UNIT_WATT),
/// @see FitHillScore#getHillEndurance()
/// @see FitHillScore#getLevel()
GARMIN_HILL_ENDURANCE(3),
GARMIN_HILL_ENDURANCE(3, UNIT_NONE),
/// @see FitHillScore#getHillScore()
/// @see FitHillScore#getLevel()
GARMIN_HILL_SCORE(4),
GARMIN_HILL_SCORE(4, UNIT_NONE),
/// @see FitHillScore#getHillStrength()
/// @see FitHillScore#getLevel()
GARMIN_HILL_STRENGTH(5),
GARMIN_HILL_STRENGTH(5, UNIT_NONE),
/// This is a metabolic equivalent (MET) version of {@link #GENERIC_MAXIMUM_OXYGEN_UPTAKE}.
/// Estimated using 24/7 monitoring instead of high resolution activity recordings.
///
/// @see FitMaxMetData#getVo2Max()
/// @see FitMaxMetData#getMaxMetCategory()
GARMIN_MET_MAX_VO2(6),
GARMIN_MET_MAX_VO2(6, UNIT_ML_KG_MIN),
/// @see FitFunctionalMetrics#getRunningLactateThresholdPower()
/// @see FitFunctionalMetrics#getRunningLactateThresholdHr()
GARMIN_RUNNING_LACTATE_THRESHOLD_POWER(7),
GARMIN_RUNNING_LACTATE_THRESHOLD_POWER(7, UNIT_WATT),
/// @see FitTrainingReadiness#getTrainingReadiness()
/// @see FitTrainingReadiness#getLevel()
GARMIN_TRAINING_READINESS(8);
GARMIN_TRAINING_READINESS(8, UNIT_NONE),
/// @see FitTrainingLoad#getTrainingLoadAcute
GENERIC_TRAINING_LOAD_ACUTE(9, UNIT_NONE),
/// @see FitTrainingLoad#getTrainingLoadChronic()
GENERIC_TRAINING_LOAD_CHRONIC(10, UNIT_NONE),
/// @see FitMonitoringInfo#getRestingMetabolicRate()
GENERIC_RESTING_METABOLIC_RATE(11, UNIT_KCAL_PER_DAY),
/// @see FitPhysiologicalMetrics#getMetMax()
GENERIC_MAXIMUM_OXYGEN_UPTAKE(12, UNIT_ML_KG_MIN),
;
final int dbId;
final public int dbId;
Metric(int dbId) {
/// @see nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries
@NonNull
final public String uomKey;
Metric(int dbId, @NonNull String uomKey) {
this.dbId = dbId;
this.uomKey = uomKey;
}
@Nullable
static Metric fromDbId(int dbId) {
public static Metric fromDbId(int dbId) {
for (Metric metric : values()) {
if (metric.dbId == dbId) {
return metric;
@@ -16,6 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.MAXIMUM_OXYGEN_UPTAKE;
import android.content.Context;
import android.widget.Toast;
@@ -90,6 +92,7 @@ import nodomain.freeyourgadget.gadgetbridge.export.AutoGpxExporter;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
import nodomain.freeyourgadget.gadgetbridge.model.FitActivityTrackProvider;
@@ -432,7 +435,7 @@ public class FitImporter {
final double endurance = (rawEndurance == null) ? Double.NaN : rawEndurance.doubleValue();
if (endurance > 0.0) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setTimestamp(ts * 1000L);
sample.setMetric(MetricSample.Metric.GARMIN_HILL_ENDURANCE, endurance, level);
genericMetricSamples.add(sample);
}
@@ -441,7 +444,7 @@ public class FitImporter {
final double score = (rawScore == null) ? Double.NaN : rawScore.doubleValue();
if (score > 0.0) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setTimestamp(ts * 1000L);
sample.setMetric(MetricSample.Metric.GARMIN_HILL_SCORE, score, level);
genericMetricSamples.add(sample);
}
@@ -450,7 +453,7 @@ public class FitImporter {
final double strength = (rawStrength == null) ? Double.NaN : rawStrength.doubleValue();
if (strength > 0.0) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setTimestamp(ts * 1000L);
sample.setMetric(MetricSample.Metric.GARMIN_HILL_STRENGTH, strength, level);
genericMetricSamples.add(sample);
}
@@ -463,7 +466,7 @@ public class FitImporter {
if ((level != null && level > 0) || (readiness != null && readiness > 0)) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setTimestamp(ts * 1000L);
sample.setMetric(MetricSample.Metric.GARMIN_TRAINING_READINESS, readiness, level);
genericMetricSamples.add(sample);
}
@@ -476,7 +479,7 @@ public class FitImporter {
if ((level != null && level > 0) || (score != null && score > 0)) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setTimestamp(ts * 1000L);
sample.setMetric(MetricSample.Metric.GARMIN_ENDURANCE_SCORE, score, level);
genericMetricSamples.add(sample);
}
@@ -488,7 +491,7 @@ public class FitImporter {
final Long hr = (rawHr == null) ? null : rawHr.longValue();
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setTimestamp(ts * 1000L);
sample.setMetric(MetricSample.Metric.GARMIN_FUNCTIONAL_THRESHOLD_POWER, ftp, hr);
genericMetricSamples.add(sample);
}
@@ -500,7 +503,7 @@ public class FitImporter {
final Long hr = (rawHr == null) ? null : rawHr.longValue();
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setTimestamp(ts * 1000L);
sample.setMetric(MetricSample.Metric.GARMIN_RUNNING_LACTATE_THRESHOLD_POWER, ltp, hr);
genericMetricSamples.add(sample);
}
@@ -513,7 +516,7 @@ public class FitImporter {
if ((vo2Max != null && vo2Max > 0) || (maxMetCategory != null && maxMetCategory > 0)) {
final GenericMetricSample sample = new GenericMetricSample();
sample.setTimestamp(ts);
sample.setTimestamp(ts * 1000L);
sample.setMetric(MetricSample.Metric.GARMIN_MET_MAX_VO2, vo2Max, maxMetCategory);
genericMetricSamples.add(sample);
}
@@ -619,7 +622,7 @@ public class FitImporter {
final long deviceId = DBHelper.getDevice(gbDevice, session).getId();
final long userId = DBHelper.getUser(session).getId();
persistAbstractSamples(batterySamples, new BatteryLevelProvider(gbDevice, session));
persistGenericMetrics(session, deviceId, userId);
persistMetricSamples(session);
} catch (final Exception e) {
GB.toast(context, "Error saving generic samples", Toast.LENGTH_LONG, GB.ERROR, e);
}
@@ -630,14 +633,35 @@ 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 persistMetricSamples(@NonNull final DaoSession session) {
// During the transition phase some samples are persisted twice:
// 1) to the old individual tables (see importFile)
// 2) to the new generic metrics table (here)
// this enables thorough testing without risking data loss.
for (final GenericTrainingLoadAcuteSample legacy : trainingLoadAcuteSamples) {
GenericMetricSample metric = new GenericMetricSample();
metric.setTimestamp(legacy.getTimestamp());
metric.setMetric(MetricSample.Metric.GENERIC_TRAINING_LOAD_ACUTE, legacy.getValue());
genericMetricSamples.add(metric);
}
for (final GenericTrainingLoadChronicSample legacy : trainingLoadChronicSamples) {
final GenericMetricSample metric = new GenericMetricSample();
metric.setTimestamp(legacy.getTimestamp());
metric.setMetric(MetricSample.Metric.GENERIC_TRAINING_LOAD_CHRONIC, legacy.getValue());
genericMetricSamples.add(metric);
}
for (final GarminRestingMetabolicRateSample legacy : restingMetabolicRateSamples) {
final GenericMetricSample metric = new GenericMetricSample();
metric.setTimestamp(legacy.getTimestamp());
metric.setMetric(MetricSample.Metric.GENERIC_RESTING_METABOLIC_RATE, legacy.getRestingMetabolicRate());
genericMetricSamples.add(metric);
}
final GenericMetricSampleProvider provider = new GenericMetricSampleProvider(gbDevice, session);
persistAbstractSamples(genericMetricSamples, provider);
}
private void persistWorkout(final File file, final DaoSession session, boolean isReprocessing, FitFile fitFile) {
@@ -681,6 +705,8 @@ public class FitImporter {
}
workoutParser.updateSummary(summary);
final GenericMetricSampleProvider provider = new GenericMetricSampleProvider(gbDevice, session);
persistAbstractSamples(workoutParser.getGenericMetricSamples(), provider);
summary.setRawDetailsPath(file.getAbsolutePath());
@@ -238,4 +238,8 @@ public class GBPrefs extends Prefs {
public boolean experimentalSettings() {
return getBoolean("experimental_settings", false);
}
public boolean experimentalMetrics() {
return getBoolean("experimental_metrics", false);
}
}
+3
View File
@@ -773,6 +773,8 @@
<string name="pref_cache_weather">Cache weather information</string>
<string name="pref_experimental_settings_title">Experimental settings</string>
<string name="pref_experimental_settings_summary">Experimental settings might be unstable or not work as expected. Proceed at your own risk.</string>
<string name="pref_experimental_metrics_title">Generic Metrics</string>
<string name="pref_experimental_metrics_summary">Enable experimental UI logic for generic metrics. Currently only relevant for training load and calories recorded by Garmin devices.</string>
<string name="pref_cache_weather_summary">Weather information will be cached across application restarts.</string>
<string name="pref_write_logfiles_not_available">File logging initialization failed, writing log files is currently not available. Restart the application to attempt to initialize the log files again.</string>
<string name="initializing">Initializing</string>
@@ -2773,6 +2775,7 @@
<string name="unit_volt">V</string>
<string name="unit_bar">bar</string>
<string name="unit_liter">L</string>
<string name="unit_kcal_per_day">kcal/day</string>
<string name="diving_tank">Tank</string>
<!-- consumed volume or pressure from a diving gas tank -->
<string name="diving_tank_used">Used</string>
+8
View File
@@ -540,6 +540,14 @@
android:summary="@string/pref_experimental_settings_summary"
android:title="@string/pref_experimental_settings_title"
app:iconSpaceReserved="false" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="experimental_metrics"
android:layout="@layout/preference_checkbox"
android:summary="@string/pref_experimental_metrics_summary"
android:title="@string/pref_experimental_metrics_title"
app:iconSpaceReserved="false" />
</PreferenceScreen>
<PreferenceScreen