Zepp OS: Add HRV support

Support is determined by whether the display item hrv exists
This commit is contained in:
José Rebelo
2025-03-23 16:00:43 +00:00
parent 849a7dc996
commit ce3e11d07b
16 changed files with 166 additions and 22 deletions
@@ -122,7 +122,7 @@ public class ActivityChartsActivity extends AbstractChartsActivity {
if (!coordinator.supportsWeightMeasurement()) {
tabList.remove("weight");
}
if (!coordinator.supportsHrvMeasurement()) {
if (!coordinator.supportsHrvMeasurement(device)) {
tabList.remove("hrvstatus");
}
if (!coordinator.supportsHeartRateMeasurement(device)) {
@@ -169,7 +169,7 @@ public class HRVStatusFragment extends AbstractChartFragment<HRVStatusFragment.H
List<Entry> lineEntries = new ArrayList<>();
final List<ILineDataSet> lineDataSets = new ArrayList<>();
weeklyData.getDaysData().forEach((HRVStatusDayData day) -> {
if (day.status.getNum() > 0) {
if (day.dayAvg > 0) {
lineEntries.add(new Entry(day.i, day.dayAvg));
} else {
if (!lineEntries.isEmpty()) {
@@ -297,12 +297,20 @@ public class HRVStatusFragment extends AbstractChartFragment<HRVStatusFragment.H
private List<? extends HrvSummarySample> getSamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) {
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final TimeSampleProvider<? extends HrvSummarySample> sampleProvider = coordinator.getHrvSummarySampleProvider(device, db.getDaoSession());
if (sampleProvider == null) {
LOG.warn("Device {} does not implement HrvSummarySampleProvider", device);
return new ArrayList<>();
}
return sampleProvider.getAllSamples(tsFrom * 1000L, tsTo * 1000L);
}
public List<? extends HrvValueSample> getHrvValueSamples(final DBHandler db, final GBDevice device, int tsFrom, int tsTo) {
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
final TimeSampleProvider<? extends HrvValueSample> sampleProvider = coordinator.getHrvValueSampleProvider(device, db.getDaoSession());
if (sampleProvider == null) {
LOG.warn("Device {} does not implement HrvValueSampleProvider", device);
return new ArrayList<>();
}
return sampleProvider.getAllSamples(tsFrom * 1000L, tsTo * 1000L);
}
@@ -30,6 +30,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.DashboardFragment;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.HrvSummarySample;
@@ -50,7 +51,7 @@ public class DashboardHrvWidget extends AbstractGaugeWidget {
@Override
protected boolean isSupportedBy(final GBDevice device) {
return device.getDeviceCoordinator().supportsHrvMeasurement();
return device.getDeviceCoordinator().supportsHrvMeasurement(device);
}
@Override
@@ -61,8 +62,17 @@ public class DashboardHrvWidget extends AbstractGaugeWidget {
try (DBHandler dbHandler = GBApplication.acquireDB()) {
for (GBDevice dev : devices) {
final List<? extends HrvSummarySample> deviceLatestSummaries = dev.getDeviceCoordinator().getHrvSummarySampleProvider(dev, dbHandler.getDaoSession())
.getAllSamples(dashboardData.timeFrom * 1000L, dashboardData.timeTo * 1000L);
final TimeSampleProvider<? extends HrvSummarySample> hrvSummarySampleProvider = dev.getDeviceCoordinator().getHrvSummarySampleProvider(dev, dbHandler.getDaoSession());
if (hrvSummarySampleProvider == null) {
LOG.warn("Device {} does not has an hrv summary sample provider", dev);
continue;
}
final List<? extends HrvSummarySample> deviceLatestSummaries = hrvSummarySampleProvider.getAllSamples(
dashboardData.timeFrom * 1000L,
dashboardData.timeTo * 1000L
);
if (!deviceLatestSummaries.isEmpty() && (latestSummary == null || latestSummary.getTimestamp() < deviceLatestSummaries.get(deviceLatestSummaries.size() - 1).getTimestamp())) {
latestSummary = deviceLatestSummaries.get(deviceLatestSummaries.size() - 1);
@@ -94,7 +104,7 @@ public class DashboardHrvWidget extends AbstractGaugeWidget {
final HrvData hrvData = (HrvData) dashboardData.get("hrv");
final float value = hrvData != null ? calculateGaugeValue(hrvData.weeklyAverage, hrvData.baselineLowUpper, hrvData.baselineBalancedLower, hrvData.baselineBalancedUpper) : -1;
final String valueText;
valueText = value > 0 ? getString(R.string.hrv_status_unit, hrvData.weeklyAverage) : getString(R.string.stats_empty_value);
valueText = hrvData != null && hrvData.weeklyAverage > 0 ? getString(R.string.hrv_status_unit, hrvData.weeklyAverage) : getString(R.string.stats_empty_value);
setText(valueText);
drawSegmentedGauge(
colors,
@@ -508,7 +508,7 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
}
@Override
public boolean supportsHrvMeasurement() {
public boolean supportsHrvMeasurement(final GBDevice device) {
return false;
}
@@ -243,7 +243,7 @@ public interface DeviceCoordinator {
boolean supportsStressMeasurement();
boolean supportsBodyEnergy();
boolean supportsHrvMeasurement();
boolean supportsHrvMeasurement(GBDevice device);
boolean supportsVO2Max();
boolean supportsVO2MaxCycling();
boolean supportsVO2MaxRunning();
@@ -174,7 +174,7 @@ public abstract class AbstractColmiR0xCoordinator extends AbstractBLEDeviceCoord
}
@Override
public boolean supportsHrvMeasurement() {
public boolean supportsHrvMeasurement(final GBDevice device) {
return true;
}
@@ -266,7 +266,7 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
}
@Override
public boolean supportsHrvMeasurement() {
public boolean supportsHrvMeasurement(final GBDevice device) {
return true;
}
@@ -42,6 +42,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSett
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.GenericHrvValueSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.miband.DateTimeDisplay;
@@ -172,6 +173,11 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator {
return new HuamiSleepRespiratoryRateSampleProvider(device, session);
}
@Override
public GenericHrvValueSampleProvider getHrvValueSampleProvider(GBDevice device, DaoSession session) {
return new GenericHrvValueSampleProvider(device, session);
}
@Override
public TimeSampleProvider<? extends SleepScoreSample> getSleepScoreProvider(final GBDevice device, final DaoSession session) {
return new HuamiSleepSessionSampleProvider(device, session);
@@ -35,6 +35,7 @@ import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
@@ -45,6 +46,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.Vo2MaxSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.WorkoutVo2MaxSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiExtendedSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
@@ -226,6 +228,11 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
return true;
}
@Override
public boolean supportsHrvMeasurement(final GBDevice device) {
return supportsDisplayItem(device, "hrv");
}
@Override
public Set<SleepAsAndroidFeature> getSleepAsAndroidFeatures() {
return EnumSet.of(SleepAsAndroidFeature.ACCELEROMETER, SleepAsAndroidFeature.HEART_RATE, SleepAsAndroidFeature.ALARMS, SleepAsAndroidFeature.NOTIFICATIONS);
@@ -628,6 +635,13 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
return ZeppOsConfigService.deviceHasConfig(getPrefs(device), config);
}
private boolean supportsDisplayItem(final GBDevice device, final String item) {
return getPrefs(device).getList(
DeviceSettingsUtils.getPrefPossibleValuesKey(HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE),
Collections.emptyList()
).contains(item);
}
public static boolean experimentalFeatures(final GBDevice device) {
return getPrefs(device).getBoolean("zepp_os_experimental_features", false);
}
@@ -129,13 +129,12 @@ public class TestDeviceCoordinator extends AbstractDeviceCoordinator {
@Override
public TimeSampleProvider<? extends HrvSummarySample> getHrvSummarySampleProvider(final GBDevice device, final DaoSession session) {
return supportsBodyEnergy() ? new TestHrvSummarySampleProvider() : super.getHrvSummarySampleProvider(device ,session);
return supportsHrvMeasurement(device) ? new TestHrvSummarySampleProvider() : super.getHrvSummarySampleProvider(device ,session);
}
@Override
public TimeSampleProvider<? extends HrvValueSample> getHrvValueSampleProvider(final GBDevice device, final DaoSession session) {
return supportsBodyEnergy() ? new TestHrvValueSampleProvider() : super.getHrvValueSampleProvider(device ,session);
return supportsHrvMeasurement(device) ? new TestHrvValueSampleProvider() : super.getHrvValueSampleProvider(device ,session);
}
@Override
@@ -346,8 +345,8 @@ public class TestDeviceCoordinator extends AbstractDeviceCoordinator {
}
@Override
public boolean supportsHrvMeasurement() {
return supports(getTestDevice(), TestFeature.HRV_MEASUREMENT);
public boolean supportsHrvMeasurement(final GBDevice device) {
return supports(device, TestFeature.HRV_MEASUREMENT);
}
@Override
@@ -19,9 +19,6 @@ package nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -62,8 +59,6 @@ import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.ultrahuman.UltrahumanDeviceSupport;
public class UltrahumanDeviceCoordinator extends AbstractBLEDeviceCoordinator {
private static final Logger LOG = LoggerFactory.getLogger(UltrahumanDeviceCoordinator.class);
@Override
public List<DeviceCardAction> getCustomActions() {
// TODO - use an airplane icon instead
@@ -187,7 +182,7 @@ public class UltrahumanDeviceCoordinator extends AbstractBLEDeviceCoordinator {
}
@Override
public boolean supportsHrvMeasurement() {
public boolean supportsHrvMeasurement(final GBDevice device) {
// TODO - needs getHrvSummarySampleProvider in addition to the implemented getHrvValueSampleProvider
return false;
}
@@ -30,6 +30,7 @@ public class RecordedDataTypes {
public static final int TYPE_SLEEP_RESPIRATORY_RATE = 0x00000200;
public static final int TYPE_HUAMI_STATISTICS = 0x00000400;
public static final int TYPE_SLEEP = 0x00000800;
public static final int TYPE_HRV = 0x00001000;
public static final int TYPE_ALL = (int)0xffffffff;
@@ -38,5 +39,6 @@ public class RecordedDataTypes {
public static final int TYPE_SYNC = TYPE_ACTIVITY | TYPE_GPS_TRACKS | TYPE_SPO2 | TYPE_STRESS |
TYPE_TEMPERATURE |
TYPE_SLEEP |
TYPE_HRV |
TYPE_HEART_RATE | TYPE_PAI | TYPE_SLEEP_RESPIRATORY_RATE;
}
@@ -130,6 +130,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.SleepState;
import nodomain.freeyourgadget.gadgetbridge.model.WearingState;
import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.AbstractFetchOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchHrvOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchSleepSessionOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchStatisticsOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchTemperatureOperation;
@@ -1713,6 +1714,10 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements
this.fetchOperationQueue.add(new FetchSpo2NormalOperation(this));
}
if ((dataTypes & RecordedDataTypes.TYPE_HRV) != 0 && coordinator.supportsHrvMeasurement(gbDevice)) {
this.fetchOperationQueue.add(new FetchHrvOperation(this));
}
if (ZeppOsCoordinator.experimentalFeatures(getDevice())) {
if ((dataTypes & RecordedDataTypes.TYPE_HEART_RATE) != 0 && coordinator.supportsHeartRateStats()) {
this.fetchOperationQueue.add(new FetchHeartRateManualOperation(this));
@@ -0,0 +1,104 @@
/* 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.service.devices.huami.operations.fetch;
import android.widget.Toast;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.GregorianCalendar;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.GenericHrvValueSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericHrvValueSample;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
/**
* An operation that fetches HRV data.
*/
public class FetchHrvOperation extends AbstractRepeatingFetchOperation {
private static final Logger LOG = LoggerFactory.getLogger(FetchHrvOperation.class);
public FetchHrvOperation(final HuamiSupport support) {
super(support, HuamiFetchDataType.HRV);
}
@Override
protected String taskDescription() {
return getContext().getString(R.string.busy_task_fetch_hrv_data);
}
@Override
protected boolean handleActivityData(final GregorianCalendar timestamp, final byte[] bytes) {
if (bytes.length % 6 != 0) {
LOG.error("Unexpected length for hrv data {}, not divisible by 6", bytes.length);
return false;
}
final ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
final List<GenericHrvValueSample> samples = new ArrayList<>();
while (buf.hasRemaining()) {
final long timestampSeconds = buf.getInt();
final byte unk1 = buf.get();
final int hrv = buf.get() & 0xff;
timestamp.setTimeInMillis(timestampSeconds * 1000L);
LOG.trace("HRV at {}: {} unk1={}", timestamp.getTime(), hrv, unk1);
final GenericHrvValueSample sample = new GenericHrvValueSample();
sample.setTimestamp(timestamp.getTimeInMillis());
sample.setValue(hrv);
samples.add(sample);
}
return persistSamples(samples);
}
protected boolean persistSamples(final List<GenericHrvValueSample> samples) {
try (DBHandler handler = GBApplication.acquireDB()) {
final DaoSession session = handler.getDaoSession();
final HuamiCoordinator coordinator = (HuamiCoordinator) getDevice().getDeviceCoordinator();
final GenericHrvValueSampleProvider sampleProvider = coordinator.getHrvValueSampleProvider(getDevice(), session);
sampleProvider.persistForDevice(getContext(), getDevice(), samples);
} catch (final Exception e) {
GB.toast(getContext(), "Error saving hrv samples", Toast.LENGTH_LONG, GB.ERROR, e);
return false;
}
return true;
}
@Override
protected String getLastSyncTimeKey() {
return "lastHrvTimeMillis";
}
}
@@ -33,6 +33,7 @@ public enum HuamiFetchDataType {
RESTING_HEART_RATE(0x3a),
MAX_HEART_RATE(0x3d),
SLEEP_SESSION(0x48),
HRV(0x49),
;
private final byte code;
@@ -172,7 +172,7 @@ public class Prefs {
*/
public List<String> getList(final String key, final List<String> defaultValue, final String separatorRegex) {
final String stringValue = preferences.getString(key, null);
if (stringValue == null) {
if (stringValue == null || stringValue.isEmpty()) {
return defaultValue;
}
return Arrays.asList(stringValue.split(separatorRegex));