From 9edbf160c76707a637cab3f733b490c14199f600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20V=C3=B6geli?= Date: Tue, 8 Oct 2024 22:07:47 +0200 Subject: [PATCH] Colmi R0x: Add support for realtime heart rate meassurements and live activity tracking --- .../charts/LiveActivityFragment.java | 27 +++-- .../colmi/AbstractColmiR0xCoordinator.java | 5 + .../colmi/ColmiLiveActivityContext.java | 105 +++++++++++++++++ .../devices/colmi/ColmiR0xConstants.java | 1 + .../devices/colmi/ColmiR0xPacketHandler.java | 107 +++++++++++++++++- .../devices/colmi/ColmiR0xDeviceSupport.java | 65 ++++++++++- 6 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiLiveActivityContext.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java index adac32b3a..30cfe75ac 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/charts/LiveActivityFragment.java @@ -47,7 +47,7 @@ import com.github.mikephil.charting.utils.Utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.text.SimpleDateFormat; +import java.io.Serializable; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executors; @@ -65,6 +65,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; +import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class LiveActivityFragment extends AbstractActivityChartFragment { @@ -166,21 +167,33 @@ public class LiveActivityFragment extends AbstractActivityChartFragment 0) { addEntries(steps, timestamp); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/AbstractColmiR0xCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/AbstractColmiR0xCoordinator.java index 44f0038b2..71d1a01da 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/AbstractColmiR0xCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/AbstractColmiR0xCoordinator.java @@ -240,4 +240,9 @@ public abstract class AbstractColmiR0xCoordinator extends AbstractBLEDeviceCoord } return deviceSpecificSettings; } + + @Override + public int getLiveActivityFragmentPulseInterval() { + return 2000; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiLiveActivityContext.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiLiveActivityContext.java new file mode 100644 index 000000000..e71a9041e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiLiveActivityContext.java @@ -0,0 +1,105 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.colmi; + +import java.util.concurrent.ScheduledExecutorService; + +public class ColmiLiveActivityContext { + private int bufferedSteps = 0; + private int bufferedCalories = 0; + private int bufferedDistance = 0; + private int lastTotalSteps = 0; + private int lastTotalCalories = 0; + private int lastTotalDistance = 0; + private int lastRealtimeHeartRateTimestamp = 0; + private boolean realtimeHrm = false; + private int realtimeHrmPacketCount = 0; + private boolean realtimeSteps = false; + private ScheduledExecutorService realtimeStepsScheduler; + + public boolean isRealtimeSteps() { + return realtimeSteps; + } + + public void setRealtimeSteps(boolean realtimeSteps) { + this.realtimeSteps = realtimeSteps; + } + + public ScheduledExecutorService getRealtimeStepsScheduler() { + return realtimeStepsScheduler; + } + + public void setRealtimeStepsScheduler(ScheduledExecutorService realtimeStepsScheduler) { + this.realtimeStepsScheduler = realtimeStepsScheduler; + } + + public int getBufferedSteps() { + return bufferedSteps; + } + + public void setBufferedSteps(int bufferedSteps) { + this.bufferedSteps = bufferedSteps; + } + + public int getBufferedCalories() { + return bufferedCalories; + } + + public void setBufferedCalories(int bufferedCalories) { + this.bufferedCalories = bufferedCalories; + } + + public int getBufferedDistance() { + return bufferedDistance; + } + + public void setBufferedDistance(int bufferedDistance) { + this.bufferedDistance = bufferedDistance; + } + + public int getLastTotalSteps() { + return lastTotalSteps; + } + + public void setLastTotalSteps(int lastTotalSteps) { + this.lastTotalSteps = lastTotalSteps; + } + + public int getLastTotalCalories() { + return lastTotalCalories; + } + + public void setLastTotalCalories(int lastTotalCalories) { + this.lastTotalCalories = lastTotalCalories; + } + + public int getLastTotalDistance() { + return lastTotalDistance; + } + + public void setLastTotalDistance(int lastTotalDistance) { + this.lastTotalDistance = lastTotalDistance; + } + + public int getLastRealtimeHeartRateTimestamp() { + return lastRealtimeHeartRateTimestamp; + } + + public void setLastRealtimeHeartRateTimestamp(int lastRealtimeHeartRateTimestamp) { + this.lastRealtimeHeartRateTimestamp = lastRealtimeHeartRateTimestamp; + } + + public boolean isRealtimeHrm() { + return realtimeHrm; + } + + public void setRealtimeHrm(boolean realtimeHrm) { + this.realtimeHrm = realtimeHrm; + } + + public int getRealtimeHrmPacketCount() { + return realtimeHrmPacketCount; + } + + public void setRealtimeHrmPacketCount(int realtimeHrmPacketCount) { + this.realtimeHrmPacketCount = realtimeHrmPacketCount; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xConstants.java index c5e465b94..852650405 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xConstants.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xConstants.java @@ -33,6 +33,7 @@ public class ColmiR0xConstants { public static final byte CMD_PREFERENCES = 0x0a; public static final byte CMD_SYNC_HEART_RATE = 0x15; public static final byte CMD_AUTO_HR_PREF = 0x16; + public static final byte CMD_REALTIME_HEART_RATE = 0x1e; public static final byte CMD_GOALS = 0x21; public static final byte CMD_AUTO_SPO2_PREF = 0x2c; public static final byte CMD_PACKET_SIZE = 0x2f; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xPacketHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xPacketHandler.java index 469fabd42..2cb53effd 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xPacketHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xPacketHandler.java @@ -20,6 +20,7 @@ import android.content.Context; import android.content.Intent; import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.slf4j.Logger; @@ -174,11 +175,115 @@ public class ColmiR0xPacketHandler { } } - public static void liveActivity(byte[] value) { + public static void realtimeHeartRate(GBDevice device, Context context, ColmiLiveActivityContext hrmContext, byte[] value) { + int hrResponse = value[1] & 0xff; + LOG.info("Received realtime heart rate response: {} bpm", hrResponse); + + // Ignore realtime heart rate data if it arrives too fast + Calendar calendar = Calendar.getInstance(); + int sampleTimestamp = (int)(calendar.getTimeInMillis() / 1000); + if (sampleTimestamp <= hrmContext.getLastRealtimeHeartRateTimestamp()) { + LOG.info("Ignoring realtime heart rate data with same timestamp as last packet"); + return; + } + + hrmContext.setLastRealtimeHeartRateTimestamp(sampleTimestamp); + + if (hrResponse > 0) { + // Build sample object, send intent and save in database + try (DBHandler db = GBApplication.acquireDB()) { + Long userId = DBHelper.getUser(db.getDaoSession()).getId(); + Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId(); + + // Build heart rate sample object and save in database + ColmiHeartRateSampleProvider heartRateSampleProvider = new ColmiHeartRateSampleProvider(device, db.getDaoSession()); + ColmiHeartRateSample heartRateSample = heartRateSampleProvider.createSample(); + heartRateSample.setDeviceId(deviceId); + heartRateSample.setUserId(userId); + heartRateSample.setTimestamp(calendar.getTimeInMillis()); + heartRateSample.setHeartRate(hrResponse); + + // Send local intent with sample for listeners like the live activity tab + Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES) + .putExtra(GBDevice.EXTRA_DEVICE, device) + .putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, heartRateSample); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + + // Save heart rate sample to the database + heartRateSampleProvider.addSample(heartRateSample); + } catch (Exception e) { + LOG.error("Error acquiring database for recording heart rate samples", e); + } + } + } + + public static void liveActivity(GBDevice device, Context context, ColmiLiveActivityContext liveActivityContext, byte[] value) { + // Live activity will report cumulative values over the day int steps = BLETypeConversions.toUint32(value[4], value[3], value[2], (byte) 0); int calories = BLETypeConversions.toUint32(value[7], value[6], value[5], (byte) 0) / 10; int distance = BLETypeConversions.toUint32(value[10], value[9], value[8], (byte) 0); LOG.info("Received live activity notification: {} steps, {} calories, {}m distance", steps, calories, distance); + + + // Calculate difference to last values + if (liveActivityContext.getLastTotalSteps() == 0) liveActivityContext.setLastTotalSteps(steps); + if (liveActivityContext.getLastTotalCalories() == 0) liveActivityContext.setLastTotalCalories(calories); + if (liveActivityContext.getLastTotalDistance() == 0) liveActivityContext.setLastTotalDistance(distance); + + int deltaSteps = steps - liveActivityContext.getLastTotalSteps(); + int deltaCalories = calories - liveActivityContext.getLastTotalCalories(); + int deltaDistance = distance - liveActivityContext.getLastTotalDistance(); + + liveActivityContext.setLastTotalSteps(steps); + liveActivityContext.setLastTotalCalories(calories); + liveActivityContext.setLastTotalDistance(distance); + + + // Buffer live activity data + liveActivityContext.setBufferedSteps(liveActivityContext.getBufferedSteps() + deltaSteps); + liveActivityContext.setBufferedCalories(liveActivityContext.getBufferedCalories() + deltaCalories); + liveActivityContext.setBufferedDistance(liveActivityContext.getBufferedDistance() + deltaDistance); + + LOG.info("Buffered live activity data: {} steps (+{}), {} calories (+{}), {}m distance (+{})", liveActivityContext.getBufferedSteps(), deltaSteps, liveActivityContext.getBufferedCalories(), deltaCalories, liveActivityContext.getBufferedDistance(), deltaDistance); + } + + @NonNull + public static Runnable liveActivityPulse(GBDevice device, Context context, ColmiLiveActivityContext liveActivityContext) { + return () -> { + Calendar calendar = Calendar.getInstance(); + int sampleTimestamp = (int) (calendar.getTimeInMillis() / 1000); + + try (DBHandler db = GBApplication.acquireDB()) { + Long userId = DBHelper.getUser(db.getDaoSession()).getId(); + Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId(); + + // Build activity sample object + ColmiActivitySampleProvider sampleProvider = new ColmiActivitySampleProvider(device, db.getDaoSession()); + ColmiActivitySample activitySample = sampleProvider.createActivitySample(); + activitySample.setProvider(sampleProvider); + activitySample.setDeviceId(deviceId); + activitySample.setUserId(userId); + activitySample.setRawKind(ActivityKind.ACTIVITY.getCode()); + activitySample.setTimestamp(sampleTimestamp); + activitySample.setCalories(liveActivityContext.getBufferedCalories()); + activitySample.setSteps(liveActivityContext.getBufferedSteps()); + activitySample.setDistance(liveActivityContext.getBufferedDistance()); + + // Send local intent with sample for listeners like the live activity tab + Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES) + .putExtra(GBDevice.EXTRA_DEVICE, device) + .putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, activitySample); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + LOG.info("Sent live activity notification: {} steps, {} calories, {}m distance", liveActivityContext.getBufferedSteps(), liveActivityContext.getBufferedCalories(), liveActivityContext.getBufferedDistance()); + + // Reset buffered data + liveActivityContext.setBufferedSteps(0); + liveActivityContext.setBufferedCalories(0); + liveActivityContext.setBufferedDistance(0); + } catch (Exception e) { + LOG.error("Error acquiring database for recording activity samples", e); + } + }; } public static void historicalActivity(GBDevice device, Context context, byte[] value) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/colmi/ColmiR0xDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/colmi/ColmiR0xDeviceSupport.java index 572d79620..1a8fac9ba 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/colmi/ColmiR0xDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/colmi/ColmiR0xDeviceSupport.java @@ -34,6 +34,9 @@ import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; @@ -43,6 +46,7 @@ import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; +import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiLiveActivityContext; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR0xConstants; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR0xPacketHandler; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHeartRateSampleProvider; @@ -78,6 +82,9 @@ public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport { private int bigDataPacketSize; private ByteBuffer bigDataPacket; + private static final int LIVE_ACTIVITY_BUFFER_INTERVAL = 2000; + private final ColmiLiveActivityContext liveActivityContext = new ColmiLiveActivityContext(); + public ColmiR0xDeviceSupport() { super(LOG); addSupportedService(ColmiR0xConstants.CHARACTERISTIC_SERVICE_V1); @@ -100,6 +107,12 @@ public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport { public void dispose() { backgroundTasksHandler.removeCallbacksAndMessages(null); + LOG.info("Stopping live activity timeout scheduler"); + if(liveActivityContext.getRealtimeStepsScheduler() != null) { + liveActivityContext.getRealtimeStepsScheduler().shutdown(); + liveActivityContext.setRealtimeStepsScheduler(null); + } + super.dispose(); } @@ -314,6 +327,20 @@ public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport { break; case ColmiR0xConstants.CMD_MANUAL_HEART_RATE: ColmiR0xPacketHandler.liveHeartRate(getDevice(), getContext(), value); + break; + case ColmiR0xConstants.CMD_REALTIME_HEART_RATE: + ColmiR0xPacketHandler.realtimeHeartRate(getDevice(), getContext(), liveActivityContext, value); + + // The realtime measurement has a timeout of 60 seconds. + // Send a "continue" command every 30 packets (= every 30 seconds) + liveActivityContext.setRealtimeHrmPacketCount((liveActivityContext.getRealtimeHrmPacketCount()+1) % 30); + + if(liveActivityContext.isRealtimeHrm() && liveActivityContext.getRealtimeHrmPacketCount() == 0) { + byte[] measureHeartRatePacket = buildPacket(new byte[]{ColmiR0xConstants.CMD_REALTIME_HEART_RATE, 0x03}); + LOG.info("Continue realtime HRM request sent: {}", StringUtils.bytesToHex(measureHeartRatePacket)); + sendWrite("continueRealtimeHRMRequest", measureHeartRatePacket); + } + break; case ColmiR0xConstants.CMD_NOTIFICATION: switch (value[1]) { @@ -334,7 +361,7 @@ public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport { evaluateGBDeviceEvent(batteryNotifEvent); break; case ColmiR0xConstants.NOTIFICATION_LIVE_ACTIVITY: - ColmiR0xPacketHandler.liveActivity(value); + ColmiR0xPacketHandler.liveActivity(getDevice(), getContext(), liveActivityContext, value); break; default: LOG.info("Received unrecognized notification: {}", StringUtils.bytesToHex(value)); @@ -625,6 +652,42 @@ public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport { sendWrite("measureHRRequest", measureHeartRatePacket); } + @Override + public void onEnableRealtimeHeartRateMeasurement(boolean enable) { + if (enable == liveActivityContext.isRealtimeHrm()) return; + liveActivityContext.setRealtimeHrm(enable); + liveActivityContext.setRealtimeHrmPacketCount(0); + + byte enableByte; + if(enable) enableByte = 0x01; + else enableByte = 0x02; + + byte[] measureHeartRatePacket = buildPacket(new byte[]{ColmiR0xConstants.CMD_REALTIME_HEART_RATE, enableByte}); + LOG.info("Enable realtime HRM request sent: {}", StringUtils.bytesToHex(measureHeartRatePacket)); + sendWrite("enableRealtimeHRMRequest", measureHeartRatePacket); + } + + @Override + public void onEnableRealtimeSteps(boolean enable) { + if (enable == liveActivityContext.isRealtimeSteps()) return; + liveActivityContext.setRealtimeSteps(enable); + + if(enable) { + liveActivityContext.setBufferedSteps(0); + liveActivityContext.setBufferedCalories(0); + liveActivityContext.setBufferedDistance(0); + + LOG.info("Starting live activity timeout scheduler"); + ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor(); + service.scheduleWithFixedDelay(ColmiR0xPacketHandler.liveActivityPulse(getDevice(), getContext(), liveActivityContext), 0, LIVE_ACTIVITY_BUFFER_INTERVAL, TimeUnit.MILLISECONDS); + liveActivityContext.setRealtimeStepsScheduler(service); + } else { + LOG.info("Stopping live activity timeout scheduler"); + liveActivityContext.getRealtimeStepsScheduler().shutdown(); + liveActivityContext.setRealtimeStepsScheduler(null); + } + } + @Override public void onFetchRecordedData(int dataTypes) { GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext());