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 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