mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2025-01-10 09:01:55 +01:00
Colmi R0x: Add support for realtime heart rate meassurements and live activity tracking
This commit is contained in:
parent
d46a30aaf2
commit
9edbf160c7
@ -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<ChartsData> {
|
||||
@ -166,21 +167,33 @@ public class LiveActivityFragment extends AbstractActivityChartFragment<ChartsDa
|
||||
String action = intent.getAction();
|
||||
switch (action) {
|
||||
case DeviceService.ACTION_REALTIME_SAMPLES: {
|
||||
ActivitySample sample = (ActivitySample) intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE);
|
||||
addSample(sample);
|
||||
addSample(intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private void addSample(ActivitySample sample) {
|
||||
int heartRate = sample.getHeartRate();
|
||||
int timestamp = tsTranslation.shorten(sample.getTimestamp());
|
||||
private void addSample(Serializable serializedSample) {
|
||||
int heartRate = 0;
|
||||
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)) {
|
||||
setCurrentHeartRate(heartRate, timestamp);
|
||||
}
|
||||
int steps = sample.getSteps();
|
||||
if (steps > 0) {
|
||||
addEntries(steps, timestamp);
|
||||
}
|
||||
|
@ -240,4 +240,9 @@ public abstract class AbstractColmiR0xCoordinator extends AbstractBLEDeviceCoord
|
||||
}
|
||||
return deviceSpecificSettings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLiveActivityFragmentPulseInterval() {
|
||||
return 2000;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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());
|
||||
|
Loading…
Reference in New Issue
Block a user