Garmin: HRM-Pro Plus - native support (#5353)

This commit is contained in:
Thomas Kuehne
2025-09-14 16:49:15 +02:00
parent bd2bac38f0
commit 1507553247
7 changed files with 261 additions and 2 deletions
@@ -0,0 +1,91 @@
/* Copyright (C) 2025 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.garmin.hrm;
import androidx.annotation.NonNull;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupportHrm;
public class GarminHrmProPlusCoordinator extends GarminCoordinator {
@Override
public int getDefaultIconResource() {
return R.drawable.ic_device_lovetoy;
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_hrm_pro_plus;
}
@NonNull
@Override
public Class<? extends DeviceSupport> getDeviceSupportClass(final GBDevice device) {
return GarminSupportHrm.class;
}
@Override
public Pattern getSupportedDeviceName() {
return Pattern.compile("^HRMPro[+]:\\d+$");
}
@Override
public boolean isExperimental() {
return true;
}
@Override
public boolean suggestUnbindBeforePair() {
// Not needed
return false;
}
@Override
public boolean supportsActivityDataFetching(@NonNull final GBDevice device) {
return true;
}
@Override
public boolean supportsActivityTracking(@NonNull final GBDevice device) {
return true;
}
@Override
public boolean supportsHeartRateMeasurement(@NonNull final GBDevice device) {
return true;
}
@Override
public boolean supportsManualHeartRateMeasurement(@NonNull final GBDevice device) {
return true;
}
@Override
public boolean supportsRealtimeData(@NonNull final GBDevice device) {
return true;
}
@Override
public boolean supportsSleepMeasurement(@NonNull final GBDevice device){
return false;
}
}
@@ -64,6 +64,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBudsProDev
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.bike.GarminEdge130PlusCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.bike.GarminEdge540Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.bike.GarminEdgeExplore2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.hrm.GarminHrmProPlusCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.gps.GarminETrexSeCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.gps.GarminInReachMini2;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.hrm.GarminHrm200Coordinator;
@@ -562,6 +563,7 @@ public enum DeviceType {
IKEA_IDASEN(IdasenCoordinator.class),
NUTMINI(NutCoordinator.class),
VIVOMOVE_HR(GarminVivomoveHrCoordinator.class),
GARMIN_HRM_PRO_PLUS(GarminHrmProPlusCoordinator.class),
GARMIN_HRM_200(GarminHrm200Coordinator.class),
GARMIN_EDGE_130_PLUS(GarminEdge130PlusCoordinator.class),
GARMIN_EDGE_540(GarminEdge540Coordinator.class),
@@ -1,5 +1,5 @@
/* Copyright (C) 2016-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniele
Gobbetti
/* Copyright (C) 2016-2025 Andreas Shimokawa, Carsten Pfeiffer, Daniele
Gobbetti, Thomas Kuehne
This file is part of Gadgetbridge.
@@ -27,11 +27,13 @@ import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic;
import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.AbstractBleProfile;
/**
* https://www.bluetooth.com/specifications/gatt/viewer?attributeXmlFile=org.bluetooth.service.heart_rate.xml
* @see GattService#UUID_SERVICE_HEART_RATE
*/
public class HeartRateProfile<T extends AbstractBTLESingleDeviceSupport> extends AbstractBleProfile<T> {
private static final Logger LOG = LoggerFactory.getLogger(HeartRateProfile.class);
@@ -84,6 +86,21 @@ public class HeartRateProfile<T extends AbstractBTLESingleDeviceSupport> extends
heartRate = BLETypeConversions.toUnsigned(value, 1);
}
if ((flag & 0x04) != 0){
// Sensor Contact supported
if ((flag & 0x02) == 0){
// Sensor Contact NOT detected - no or poor contact with the skin
LOG.debug("Got poor contact heartRate: {}", heartRate);
return true;
}
}
if ((flag & 0x08) != 0){
// TODO: Energy Expended present (UINT16, unit: kilo Joules since last reset)
}
if ((flag & 0x10) != 0){
// TODO: RR-Interval present (UINT16 array, unit: 1/1024 second)
}
LOG.debug("Got heartRate: {}", heartRate);
notify(createIntent(heartRate));
@@ -52,6 +52,7 @@ public class FileType {
GOALS(128, 11),
SUMMARY(128, 20),
RECORDS(128, 29),
UNKNOWN_31(128, 31), // sent by HRM Pro Plus
MONITOR(128, 32),
MLT_SPORT(128, 33),
SEGMENTS(128, 34),
@@ -0,0 +1,146 @@
/* Copyright (C) 2025 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.service.devices.garmin;
import android.content.Intent;
import android.widget.Toast;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminActivitySampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySample;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic;
import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.IntentListener;
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.battery.BatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.battery.BatteryInfoProfile;
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.heartrate.HeartRateProfile;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class GarminSupportHrm extends GarminSupport {
private final BatteryInfoProfile<GarminSupportHrm> batteryInfoProfile;
private final HeartRateProfile<GarminSupportHrm> heartRateProfile;
public GarminSupportHrm() {
addSupportedService(BatteryInfoProfile.SERVICE_UUID);
batteryInfoProfile = new BatteryInfoProfile<>(this);
batteryInfoProfile.addListener(new BatteryListener());
addSupportedProfile(batteryInfoProfile);
addSupportedService(GattService.UUID_SERVICE_HEART_RATE);
heartRateProfile = new HeartRateProfile<>(this);
heartRateProfile.addListener(new HeartRateListener());
addSupportedProfile(heartRateProfile);
}
@Override
protected TransactionBuilder initializeDevice(final TransactionBuilder builder) {
super.initializeDevice(builder);
if (null != getCharacteristic(BatteryInfoProfile.UUID_CHARACTERISTIC_BATTERY_LEVEL)) {
batteryInfoProfile.requestBatteryInfo(builder);
batteryInfoProfile.enableNotify(builder, true);
}
if (null != getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_HEART_RATE_MEASUREMENT)) {
heartRateProfile.enableNotify(builder, true);
}
return builder;
}
final class BatteryListener implements IntentListener {
@Override
public void notify(Intent intent) {
BatteryInfo info = intent.getParcelableExtra(BatteryInfoProfile.EXTRA_BATTERY_INFO);
if (info != null) {
GBDeviceEventBatteryInfo event = new GBDeviceEventBatteryInfo();
event.state = BatteryState.BATTERY_NORMAL;
event.level = info.getPercentCharged();
handleGBDeviceEvent(event);
}
}
}
@Override
public void onHeartRateTest() {
// noop - device publishes HR ca every second
}
@Override
public void onEnableRealtimeHeartRateMeasurement(final boolean enable) {
// noop - HR is always enabled
}
final class HeartRateListener implements IntentListener {
@Override
public void notify(Intent intent) {
int hr = intent.getIntExtra(HeartRateProfile.EXTRA_HEART_RATE, -1);
if (hr > 0) {
final GarminActivitySample sample;
try (DBHandler handler = GBApplication.acquireDB()) {
final DaoSession session = handler.getDaoSession();
final GarminActivitySampleProvider provider = new GarminActivitySampleProvider(getDevice(), session);
final Device device = DBHelper.getDevice(gbDevice, session);
final User user = DBHelper.getUser(session);
sample = provider.createActivitySample();
sample.setActiveCalories(ActivitySample.NOT_MEASURED);
sample.setDevice(device);
sample.setDistanceCm(ActivitySample.NOT_MEASURED);
sample.setHeartRate(hr);
sample.setRawIntensity(ActivitySample.NOT_MEASURED);
sample.setRawKind(ActivityKind.UNKNOWN.getCode());
sample.setSteps(ActivitySample.NOT_MEASURED);
sample.setTimestamp((int) (System.currentTimeMillis() / 1000));
sample.setUser(user);
provider.addGBActivitySample(sample);
} catch (Exception e) {
GB.toast(getContext(), "Error saving activity samples", Toast.LENGTH_LONG, GB.ERROR, e);
return;
}
publish(sample);
}
}
private void publish(final GarminActivitySample sample) {
final Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES);
intent.setPackage(BuildConfig.APPLICATION_ID);
intent.putExtra(GBDevice.EXTRA_DEVICE, getDevice());
intent.putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, sample);
LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent);
}
}
}
+1
View File
@@ -2129,6 +2129,7 @@
<string name="devicetype_vivitar_hr_bp_monitor_activity_tracker" translatable="false">Vivitar HR &amp; BP Monitor Activity Tracker</string>
<string name="devicetype_hama_fit6900" translatable="false">Hama Fit6900</string>
<string name="devicetype_generic_thermal_printer">Thermal Printer</string>
<string name="devicetype_garmin_hrm_pro_plus" translatable="false">Garmin HRM-Pro Plus</string>
<string name="devicetype_generic_headphones">Headphones</string>
<string name="devicetype_generic_heart_rate_sensor">Heart Rate Sensor</string>
<string name="devicetype_generic_weight_scale">Weight Scale</string>
@@ -121,6 +121,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase {
put("CASIO STB-1000", DeviceType.CASIOGB6900); // #1902
put("Amazfit Bip 3 Pro", DeviceType.AMAZFITBIP3PRO); // #3249
put("Redmi Band Pro AB01", DeviceType.REDMISMARTBANDPRO); // #3069
put("HRMPro+:123456", DeviceType.GARMIN_HRM_PRO_PLUS); // #5364
put("Instinct 2S Solar", DeviceType.GARMIN_INSTINCT_2S_SOLAR); // #3063
put("LE_WH-1000XM5", DeviceType.SONY_WH_1000XM5); // #2969
put("WH-1000XM2", DeviceType.SONY_WH_1000XM2); // #2935