diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/hrm/GarminHrmProPlusCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/hrm/GarminHrmProPlusCoordinator.java
new file mode 100644
index 0000000000..592e0641fe
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/hrm/GarminHrmProPlusCoordinator.java
@@ -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 . */
+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;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
index d6c73787e7..a4dcaf61bc 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
@@ -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),
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/heartrate/HeartRateProfile.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/heartrate/HeartRateProfile.java
index d13d498e8d..1c6581bfa8 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/heartrate/HeartRateProfile.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/heartrate/HeartRateProfile.java
@@ -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 extends AbstractBleProfile {
private static final Logger LOG = LoggerFactory.getLogger(HeartRateProfile.class);
@@ -84,6 +86,21 @@ public class HeartRateProfile 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));
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileType.java
index 221d9abcc6..0e33ac014a 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileType.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/FileType.java
@@ -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),
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportHrm.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportHrm.java
new file mode 100644
index 0000000000..60ecabbd1e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportHrm.java
@@ -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 . */
+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 batteryInfoProfile;
+ private final HeartRateProfile 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);
+ }
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 49be6fbd04..344fbd1a1d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2129,6 +2129,7 @@
Vivitar HR & BP Monitor Activity Tracker
Hama Fit6900
Thermal Printer
+ Garmin HRM-Pro Plus
Headphones
Heart Rate Sensor
Weight Scale
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java
index 038c4721be..d5097b86f7 100644
--- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java
@@ -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