Colmi R0x: Add support for realtime heart rate meassurements and live activity tracking

This commit is contained in:
René Vögeli 2024-10-08 22:07:47 +02:00 committed by José Rebelo
parent d46a30aaf2
commit 9edbf160c7
6 changed files with 301 additions and 9 deletions

View File

@ -47,7 +47,7 @@ import com.github.mikephil.charting.utils.Utils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat; import java.io.Serializable;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.Executors; 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.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class LiveActivityFragment extends AbstractActivityChartFragment<ChartsData> { public class LiveActivityFragment extends AbstractActivityChartFragment<ChartsData> {
@ -166,21 +167,33 @@ public class LiveActivityFragment extends AbstractActivityChartFragment<ChartsDa
String action = intent.getAction(); String action = intent.getAction();
switch (action) { switch (action) {
case DeviceService.ACTION_REALTIME_SAMPLES: { case DeviceService.ACTION_REALTIME_SAMPLES: {
ActivitySample sample = (ActivitySample) intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE); addSample(intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE));
addSample(sample);
break; break;
} }
} }
} }
}; };
private void addSample(ActivitySample sample) { private void addSample(Serializable serializedSample) {
int heartRate = sample.getHeartRate(); int heartRate = 0;
int timestamp = tsTranslation.shorten(sample.getTimestamp()); int timestamp = 0;
int steps = 0;
if (serializedSample instanceof ActivitySample) {
ActivitySample activitySample = (ActivitySample) serializedSample;
heartRate = activitySample.getHeartRate();
timestamp = tsTranslation.shorten(activitySample.getTimestamp());
steps = activitySample.getSteps();
}
if (serializedSample instanceof HeartRateSample) {
HeartRateSample heartRateSample = (HeartRateSample) serializedSample;
heartRate = heartRateSample.getHeartRate();
timestamp = tsTranslation.shorten((int)(heartRateSample.getTimestamp() / 1000));
}
if (HeartRateUtils.getInstance().isValidHeartRateValue(heartRate)) { if (HeartRateUtils.getInstance().isValidHeartRateValue(heartRate)) {
setCurrentHeartRate(heartRate, timestamp); setCurrentHeartRate(heartRate, timestamp);
} }
int steps = sample.getSteps();
if (steps > 0) { if (steps > 0) {
addEntries(steps, timestamp); addEntries(steps, timestamp);
} }

View File

@ -240,4 +240,9 @@ public abstract class AbstractColmiR0xCoordinator extends AbstractBLEDeviceCoord
} }
return deviceSpecificSettings; return deviceSpecificSettings;
} }
@Override
public int getLiveActivityFragmentPulseInterval() {
return 2000;
}
} }

View File

@ -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;
}
}

View File

@ -33,6 +33,7 @@ public class ColmiR0xConstants {
public static final byte CMD_PREFERENCES = 0x0a; public static final byte CMD_PREFERENCES = 0x0a;
public static final byte CMD_SYNC_HEART_RATE = 0x15; public static final byte CMD_SYNC_HEART_RATE = 0x15;
public static final byte CMD_AUTO_HR_PREF = 0x16; 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_GOALS = 0x21;
public static final byte CMD_AUTO_SPO2_PREF = 0x2c; public static final byte CMD_AUTO_SPO2_PREF = 0x2c;
public static final byte CMD_PACKET_SIZE = 0x2f; public static final byte CMD_PACKET_SIZE = 0x2f;

View File

@ -20,6 +20,7 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.slf4j.Logger; 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 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 calories = BLETypeConversions.toUint32(value[7], value[6], value[5], (byte) 0) / 10;
int distance = BLETypeConversions.toUint32(value[10], value[9], value[8], (byte) 0); 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); 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) { public static void historicalActivity(GBDevice device, Context context, byte[] value) {

View File

@ -34,6 +34,9 @@ import java.util.Calendar;
import java.util.Date; import java.util.Date;
import java.util.GregorianCalendar; import java.util.GregorianCalendar;
import java.util.UUID; 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.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R; 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.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; 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.ColmiR0xConstants;
import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR0xPacketHandler; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR0xPacketHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHeartRateSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHeartRateSampleProvider;
@ -78,6 +82,9 @@ public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport {
private int bigDataPacketSize; private int bigDataPacketSize;
private ByteBuffer bigDataPacket; private ByteBuffer bigDataPacket;
private static final int LIVE_ACTIVITY_BUFFER_INTERVAL = 2000;
private final ColmiLiveActivityContext liveActivityContext = new ColmiLiveActivityContext();
public ColmiR0xDeviceSupport() { public ColmiR0xDeviceSupport() {
super(LOG); super(LOG);
addSupportedService(ColmiR0xConstants.CHARACTERISTIC_SERVICE_V1); addSupportedService(ColmiR0xConstants.CHARACTERISTIC_SERVICE_V1);
@ -100,6 +107,12 @@ public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport {
public void dispose() { public void dispose() {
backgroundTasksHandler.removeCallbacksAndMessages(null); backgroundTasksHandler.removeCallbacksAndMessages(null);
LOG.info("Stopping live activity timeout scheduler");
if(liveActivityContext.getRealtimeStepsScheduler() != null) {
liveActivityContext.getRealtimeStepsScheduler().shutdown();
liveActivityContext.setRealtimeStepsScheduler(null);
}
super.dispose(); super.dispose();
} }
@ -314,6 +327,20 @@ public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport {
break; break;
case ColmiR0xConstants.CMD_MANUAL_HEART_RATE: case ColmiR0xConstants.CMD_MANUAL_HEART_RATE:
ColmiR0xPacketHandler.liveHeartRate(getDevice(), getContext(), value); 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; break;
case ColmiR0xConstants.CMD_NOTIFICATION: case ColmiR0xConstants.CMD_NOTIFICATION:
switch (value[1]) { switch (value[1]) {
@ -334,7 +361,7 @@ public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport {
evaluateGBDeviceEvent(batteryNotifEvent); evaluateGBDeviceEvent(batteryNotifEvent);
break; break;
case ColmiR0xConstants.NOTIFICATION_LIVE_ACTIVITY: case ColmiR0xConstants.NOTIFICATION_LIVE_ACTIVITY:
ColmiR0xPacketHandler.liveActivity(value); ColmiR0xPacketHandler.liveActivity(getDevice(), getContext(), liveActivityContext, value);
break; break;
default: default:
LOG.info("Received unrecognized notification: {}", StringUtils.bytesToHex(value)); LOG.info("Received unrecognized notification: {}", StringUtils.bytesToHex(value));
@ -625,6 +652,42 @@ public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport {
sendWrite("measureHRRequest", measureHeartRatePacket); 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 @Override
public void onFetchRecordedData(int dataTypes) { public void onFetchRecordedData(int dataTypes) {
GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext()); GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext());