diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
index 21671886e..db389239d 100644
--- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
+++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
@@ -46,7 +46,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception {
- final Schema schema = new Schema(77, MAIN_PACKAGE + ".entities");
+ final Schema schema = new Schema(78, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes);
@@ -127,6 +127,12 @@ public class GBDaoGenerator {
addWena3StressSample(schema, user, device);
addFemometerVinca2TemperatureSample(schema, user, device);
addMiScaleWeightSample(schema, user, device);
+ addColmiActivitySample(schema, user, device);
+ addColmiHeartRateSample(schema, user, device);
+ addColmiSpo2Sample(schema, user, device);
+ addColmiStressSample(schema, user, device);
+ addColmiSleepSessionSample(schema, user, device);
+ addColmiSleepStageSample(schema, user, device);
addHuaweiActivitySample(schema, user, device);
@@ -484,6 +490,55 @@ public class GBDaoGenerator {
return sample;
}
+ private static Entity addColmiActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "ColmiActivitySample");
+ addCommonActivitySampleProperties("AbstractColmiActivitySample", activitySample, user, device);
+ activitySample.implementsSerializable();
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("distance").notNull();
+ activitySample.addIntProperty("calories").notNull();
+ return activitySample;
+ }
+
+ private static Entity addColmiHeartRateSample(Schema schema, Entity user, Entity device) {
+ Entity heartRateSample = addEntity(schema, "ColmiHeartRateSample");
+ heartRateSample.implementsSerializable();
+ addCommonTimeSampleProperties("AbstractHeartRateSample", heartRateSample, user, device);
+ heartRateSample.addIntProperty(SAMPLE_HEART_RATE).notNull();
+ return heartRateSample;
+ }
+
+ private static Entity addColmiStressSample(Schema schema, Entity user, Entity device) {
+ Entity stressSample = addEntity(schema, "ColmiStressSample");
+ addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
+ stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ return stressSample;
+ }
+
+ private static Entity addColmiSpo2Sample(Schema schema, Entity user, Entity device) {
+ Entity spo2sample = addEntity(schema, "ColmiSpo2Sample");
+ addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
+ spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ return spo2sample;
+ }
+
+ private static Entity addColmiSleepSessionSample(Schema schema, Entity user, Entity device) {
+ Entity sleepSessionSample = addEntity(schema, "ColmiSleepSessionSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepSessionSample, user, device);
+ sleepSessionSample.addLongProperty("wakeupTime");
+ return sleepSessionSample;
+ }
+
+ private static Entity addColmiSleepStageSample(Schema schema, Entity user, Entity device) {
+ Entity sleepStageSample = addEntity(schema, "ColmiSleepStageSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device);
+ sleepStageSample.addIntProperty("duration").notNull();
+ sleepStageSample.addIntProperty("stage").notNull();
+ return sleepStageSample;
+ }
+
private static void addHeartRateProperties(Entity activitySample) {
activitySample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetterAndSetter(OVERRIDE);
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateDialog.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateDialog.java
index c508cb93a..6e03852e4 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateDialog.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/HeartRateDialog.java
@@ -19,7 +19,6 @@ package nodomain.freeyourgadget.gadgetbridge.activities;
import android.app.Dialog;
import android.content.BroadcastReceiver;
import android.content.Context;
-import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
@@ -41,6 +40,7 @@ import java.util.Objects;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
public class HeartRateDialog extends Dialog {
protected static final Logger LOG = LoggerFactory.getLogger(HeartRateDialog.class);
@@ -80,11 +80,18 @@ public class HeartRateDialog extends Dialog {
heart_rate_dialog_loading_layout.setVisibility(View.GONE);
heart_rate_dialog_label.setText(getContext().getString(R.string.heart_rate_result));
+ int heartRate = 0;
if (result instanceof ActivitySample) {
ActivitySample sample = (ActivitySample) result;
+ heartRate = sample.getHeartRate();
+ }
+ if (result instanceof HeartRateSample) {
+ HeartRateSample sample = (HeartRateSample) result;
+ heartRate = sample.getHeartRate();
+ }
+ if (HeartRateUtils.getInstance().isValidHeartRateValue(heartRate)) {
heart_rate_hr.setVisibility(View.VISIBLE);
- if (HeartRateUtils.getInstance().isValidHeartRateValue(sample.getHeartRate()))
- heart_rate_widget_hr_value.setText(String.valueOf(sample.getHeartRate()));
+ heart_rate_widget_hr_value.setText(String.valueOf(heartRate));
}
}
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
new file mode 100644
index 000000000..d871b094e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/AbstractColmiR0xCoordinator.java
@@ -0,0 +1,196 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi;
+
+import androidx.annotation.NonNull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+import java.util.List;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiActivitySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSpo2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiStressSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepSessionSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.colmi.ColmiR0xDeviceSupport;
+
+public abstract class AbstractColmiR0xCoordinator extends AbstractBLEDeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(AbstractColmiR0xCoordinator.class);
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ Long deviceId = device.getId();
+ QueryBuilder> qb;
+
+ qb = session.getColmiActivitySampleDao().queryBuilder();
+ qb.where(ColmiActivitySampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getColmiHeartRateSampleDao().queryBuilder();
+ qb.where(ColmiHeartRateSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getColmiSpo2SampleDao().queryBuilder();
+ qb.where(ColmiSpo2SampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getColmiStressSampleDao().queryBuilder();
+ qb.where(ColmiStressSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getColmiSleepSessionSampleDao().queryBuilder();
+ qb.where(ColmiSleepSessionSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ qb = session.getColmiSleepStageSampleDao().queryBuilder();
+ qb.where(ColmiSleepStageSampleDao.Properties.DeviceId.eq(deviceId)).buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Colmi";
+ }
+
+ @NonNull
+ @Override
+ public Class extends DeviceSupport> getDeviceSupportClass() {
+ return ColmiR0xDeviceSupport.class;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_smartring;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_smartring_disabled;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_NONE;
+ }
+
+ @Override
+ public boolean supportsPowerOff() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsRealtimeData() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateStats() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public SampleProvider extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new ColmiActivitySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session) {
+ return new ColmiSpo2SampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider extends StressSample> getStressSampleProvider(GBDevice device, DaoSession session) {
+ return new ColmiStressSampleProvider(device, session);
+ }
+
+ @Override
+ public List getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.MINUTES_5,
+ HeartRateCapability.MeasurementInterval.MINUTES_10,
+ HeartRateCapability.MeasurementInterval.MINUTES_15,
+ HeartRateCapability.MeasurementInterval.MINUTES_30,
+ HeartRateCapability.MeasurementInterval.MINUTES_45,
+ HeartRateCapability.MeasurementInterval.HOUR_1
+ );
+ }
+
+ @Override
+ public int[] getStressRanges() {
+ // 1-29 = relaxed
+ // 30-59 = normal
+ // 60-79 = medium
+ // 80-99 = high
+ return new int[]{1, 30, 60, 80};
+ }
+
+ @Override
+ public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
+ final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
+ final List health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH);
+ health.add(R.xml.devicesettings_colmi_r0x);
+ return deviceSpecificSettings;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR02Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR02Coordinator.java
new file mode 100644
index 000000000..e56f2ffa9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR02Coordinator.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class ColmiR02Coordinator extends AbstractColmiR0xCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR02Coordinator.class);
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("R02_.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_colmi_r02;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR03Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR03Coordinator.java
new file mode 100644
index 000000000..0ab8a741e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR03Coordinator.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class ColmiR03Coordinator extends AbstractColmiR0xCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR03Coordinator.class);
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("R03_.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_colmi_r03;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR06Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR06Coordinator.java
new file mode 100644
index 000000000..46ea27b79
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR06Coordinator.java
@@ -0,0 +1,38 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+
+public class ColmiR06Coordinator extends AbstractColmiR0xCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR06Coordinator.class);
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("R06_.*");
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_colmi_r06;
+ }
+}
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
new file mode 100644
index 000000000..cb1cee038
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xConstants.java
@@ -0,0 +1,63 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi;
+
+import java.util.UUID;
+
+public class ColmiR0xConstants {
+ public static final UUID CHARACTERISTIC_SERVICE_V1 = UUID.fromString("6e40fff0-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID CHARACTERISTIC_SERVICE_V2 = UUID.fromString("de5bf728-d711-4e47-af26-65e3012a5dc7");
+ public static final UUID CHARACTERISTIC_WRITE = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID CHARACTERISTIC_COMMAND = UUID.fromString("de5bf72a-d711-4e47-af26-65e3012a5dc7");
+ public static final UUID CHARACTERISTIC_NOTIFY_V1 = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID CHARACTERISTIC_NOTIFY_V2 = UUID.fromString("de5bf729-d711-4e47-af26-65e3012a5dc7");
+
+ public static final byte CMD_SET_DATE_TIME = 0x01;
+ public static final byte CMD_BATTERY = 0x03;
+ public static final byte CMD_PHONE_NAME = 0x04;
+ public static final byte CMD_POWER_OFF = 0x08;
+ 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_GOALS = 0x21;
+ public static final byte CMD_AUTO_SPO2_PREF = 0x2c;
+ public static final byte CMD_PACKET_SIZE = 0x2f;
+ public static final byte CMD_AUTO_STRESS_PREF = 0x36;
+ public static final byte CMD_SYNC_STRESS = 0x37;
+ public static final byte CMD_SYNC_ACTIVITY = 0x43;
+ public static final byte CMD_FIND_DEVICE = 0x50;
+ public static final byte CMD_MANUAL_HEART_RATE = 0x69;
+ public static final byte CMD_NOTIFICATION = 0x73;
+ public static final byte CMD_BIG_DATA_V2 = (byte) 0xbc;
+
+ public static final byte PREF_READ = 0x01;
+ public static final byte PREF_WRITE = 0x02;
+ public static final byte PREF_DELETE = 0x03;
+
+ public static final byte NOTIFICATION_NEW_HR_DATA = 0x01;
+ public static final byte NOTIFICATION_NEW_SPO2_DATA = 0x03;
+ public static final byte NOTIFICATION_NEW_STEPS_DATA = 0x04;
+ public static final byte NOTIFICATION_BATTERY_LEVEL = 0x0c;
+ public static final byte NOTIFICATION_LIVE_ACTIVITY = 0x12;
+
+ public static final byte BIG_DATA_TYPE_SLEEP = 0x27;
+ public static final byte BIG_DATA_TYPE_SPO2 = 0x2a;
+
+ public static final byte SLEEP_TYPE_LIGHT = 0x02;
+ public static final byte SLEEP_TYPE_DEEP = 0x03;
+ public static final byte SLEEP_TYPE_AWAKE = 0x05;
+}
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
new file mode 100644
index 000000000..f5752e9df
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/ColmiR0xPacketHandler.java
@@ -0,0 +1,406 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi;
+
+import android.content.Context;
+import android.content.Intent;
+import android.widget.Toast;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiActivitySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHeartRateSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSleepSessionSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSleepStageSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSpo2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiStressSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepSessionSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.colmi.ColmiR0xDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+public class ColmiR0xPacketHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR0xPacketHandler.class);
+
+ public static void hrIntervalSettings(ColmiR0xDeviceSupport support, byte[] value) {
+ if (value[1] == ColmiR0xConstants.PREF_WRITE) return; // ignore empty response when writing setting
+ boolean enabled = value[2] == 0x01;
+ int minutes = value[3];
+ LOG.info("Received HR interval preference: {} minutes, enabled={}", minutes, enabled);
+ GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences();
+ eventUpdatePreferences.withPreference(
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL,
+ String.valueOf(minutes * 60)
+ );
+ support.evaluateGBDeviceEvent(eventUpdatePreferences);
+ }
+
+ public static void spo2Settings(ColmiR0xDeviceSupport support, byte[] value) {
+ boolean enabled = value[2] == 0x01;
+ LOG.info("Received SpO2 preference: {}", enabled ? "enabled" : "disabled");
+ GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences();
+ eventUpdatePreferences.withPreference(
+ DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING,
+ enabled
+ );
+ support.evaluateGBDeviceEvent(eventUpdatePreferences);
+ }
+
+ public static void stressSettings(ColmiR0xDeviceSupport support, byte[] value) {
+ boolean enabled = value[2] == 0x01;
+ LOG.info("Received stress preference: {}", enabled ? "enabled" : "disabled");
+ GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences();
+ eventUpdatePreferences.withPreference(
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING,
+ enabled
+ );
+ support.evaluateGBDeviceEvent(eventUpdatePreferences);
+ }
+
+ public static void goalsSettings(byte[] value) {
+ int steps = BLETypeConversions.toUint32(value[2], value[3], value[4], (byte) 0);
+ int calories = BLETypeConversions.toUint32(value[5], value[6], value[7], (byte) 0);
+ int distance = BLETypeConversions.toUint32(value[8], value[9], value[10], (byte) 0);
+ int sport = BLETypeConversions.toUint16(value[11], value[12]);
+ int sleep = BLETypeConversions.toUint16(value[13], value[14]);
+ LOG.info("Received goals preferences: {} steps, {} calories, {}m distance, {}min sport, {}min sleep", steps, calories, distance, sport, sleep);
+ }
+
+ public static void liveHeartRate(GBDevice device, Context context, byte[] value) {
+ int errorCode = value[2];
+ int hrResponse = value[3] & 0xff;
+ switch (errorCode) {
+ case 0:
+ LOG.info("Received live heart rate response: {} bpm", hrResponse);
+ break;
+ case 1:
+ GB.toast(context.getString(R.string.smart_ring_measurement_error_worn_incorrectly), Toast.LENGTH_LONG, GB.ERROR);
+ LOG.warn("Live HR error code {} received from ring", errorCode);
+ return;
+ case 2:
+ LOG.warn("Live HR error 2 (temporary error / missing data) received");
+ return;
+ default:
+ GB.toast(String.format(context.getString(R.string.smart_ring_measurement_error_unknown), errorCode), Toast.LENGTH_LONG, GB.ERROR);
+ LOG.warn("Live HR error code {} received from ring", errorCode);
+ return;
+ }
+ if (hrResponse > 0) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ // Build sample object and save in database
+ ColmiHeartRateSampleProvider sampleProvider = new ColmiHeartRateSampleProvider(device, db.getDaoSession());
+ Long userId = DBHelper.getUser(db.getDaoSession()).getId();
+ Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId();
+ ColmiHeartRateSample gbSample = new ColmiHeartRateSample();
+ gbSample.setDeviceId(deviceId);
+ gbSample.setUserId(userId);
+ gbSample.setTimestamp(Calendar.getInstance().getTimeInMillis());
+ gbSample.setHeartRate(hrResponse);
+ sampleProvider.addSample(gbSample);
+ // Send local intent with sample for listeners like the heart rate dialog
+ Intent liveIntent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES);
+ liveIntent.putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, gbSample);
+ LocalBroadcastManager.getInstance(context)
+ .sendBroadcast(liveIntent);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording heart rate samples", e);
+ }
+ }
+ }
+
+ public static void liveActivity(byte[] value) {
+ 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);
+ }
+
+ public static void historicalActivity(GBDevice device, Context context, byte[] value) {
+ if ((value[1] & 0xff) == 0xff) {
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(context);
+ LOG.info("Empty activity history, sync aborted");
+ } else if ((value[1] & 0xff) == 0xf0) {
+ // initial packet, doesn't contain anything interesting
+ } else {
+ // Unpack timestamp and data
+ Calendar sampleCal = Calendar.getInstance();
+ // The code below converts the raw hex value to a date. That seems wrong, but is correct,
+ // because this date is for some reason transmitted as ints used as literal bytes:
+ // A date like 2024-08-18 would be transmitted as 0x24 0x08 0x18.
+ sampleCal.set(Calendar.YEAR, 2000 + Integer.valueOf(String.format("%02x", value[1])));
+ sampleCal.set(Calendar.MONTH, Integer.valueOf(String.format("%02x", value[2])) - 1);
+ sampleCal.set(Calendar.DAY_OF_MONTH, Integer.valueOf(String.format("%02x", value[3])));
+ sampleCal.set(Calendar.HOUR_OF_DAY, value[4] / 4); // And the hour is transmitted as nth quarter of the day...
+ sampleCal.set(Calendar.MINUTE, 0);
+ sampleCal.set(Calendar.SECOND, 0);
+ int calories = BLETypeConversions.toUint16(value[7], value[8]);
+ int steps = BLETypeConversions.toUint16(value[9], value[10]);
+ int distance = BLETypeConversions.toUint16(value[11], value[12]);
+ LOG.info("Received activity sample: {} - {} calories, {} steps, {} distance", sampleCal.getTime(), calories, steps, distance);
+ // Build sample object and save in database
+ try (DBHandler db = GBApplication.acquireDB()) {
+ ColmiActivitySampleProvider sampleProvider = new ColmiActivitySampleProvider(device, db.getDaoSession());
+ Long userId = DBHelper.getUser(db.getDaoSession()).getId();
+ Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId();
+ ColmiActivitySample gbSample = sampleProvider.createActivitySample();
+ gbSample.setProvider(sampleProvider);
+ gbSample.setDeviceId(deviceId);
+ gbSample.setUserId(userId);
+ gbSample.setRawKind(ActivityKind.ACTIVITY.getCode());
+ gbSample.setTimestamp((int) (sampleCal.getTimeInMillis() / 1000));
+ gbSample.setCalories(calories);
+ gbSample.setSteps(steps);
+ gbSample.setDistance(distance);
+ sampleProvider.addGBActivitySample(gbSample);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording activity samples", e);
+ }
+ // Determine if this sync is done
+ int currentActivityPacket = value[5];
+ int totalActivityPackets = value[6];
+ if (currentActivityPacket == totalActivityPackets - 1) {
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(context);
+ }
+ }
+ }
+
+ public static void historicalStress(GBDevice device, Context context, byte[] value) {
+ ArrayList stressSamples = new ArrayList<>();
+ int stressPacketNr = value[1] & 0xff;
+ if (stressPacketNr == 0xff) {
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(context);
+ LOG.info("Empty stress history, sync aborted");
+ } else if (stressPacketNr == 0) {
+ LOG.info("Received initial stress history response");
+ } else {
+ Calendar sampleCal = Calendar.getInstance();
+ int startValue = stressPacketNr == 1 ? 3 : 2; // packet 1 data starts at byte 3, others at byte 2
+ int minutesInPreviousPackets = 0;
+ if (stressPacketNr > 1) {
+ // 30 is the interval in minutes between values/measurements
+ minutesInPreviousPackets = 12 * 30; // 12 values in packet 1
+ minutesInPreviousPackets += (stressPacketNr - 2) * 13 * 30; // 13 values per packet
+ }
+ for (int i = startValue; i < value.length - 1; i++) {
+ if (value[i] != 0x00) {
+ // Determine time of day
+ int minuteOfDay = minutesInPreviousPackets + (i - startValue) * 30;
+ sampleCal.set(Calendar.HOUR_OF_DAY, minuteOfDay / 60);
+ sampleCal.set(Calendar.MINUTE, minuteOfDay % 60);
+ LOG.info("Stress level is {} at {}", value[i] & 0xff, sampleCal.getTime());
+ // Build sample object and save in database
+ ColmiStressSample gbSample = new ColmiStressSample();
+ gbSample.setTimestamp(sampleCal.getTimeInMillis());
+ gbSample.setStress(value[i] & 0xff);
+ stressSamples.add(gbSample);
+ }
+ }
+ if (!stressSamples.isEmpty()) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ ColmiStressSampleProvider sampleProvider = new ColmiStressSampleProvider(device, db.getDaoSession());
+ Long userId = DBHelper.getUser(db.getDaoSession()).getId();
+ Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId();
+ for (final ColmiStressSample sample : stressSamples) {
+ sample.setDeviceId(deviceId);
+ sample.setUserId(userId);
+ }
+ LOG.info("Will persist {} stress samples", stressSamples.size());
+ sampleProvider.addSamples(stressSamples);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording stress samples", e);
+ }
+ }
+ if (stressPacketNr == 4) {
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(context);
+ }
+ }
+ }
+
+ public static void historicalSpo2(GBDevice device, byte[] value) {
+ ArrayList spo2Samples = new ArrayList<>();
+ int length = BLETypeConversions.toUint16(value[2], value[3]);
+ int index = 6; // start of data (day nr, followed by values)
+ int spo2_days_ago = -1;
+ while (spo2_days_ago != 0 && index - 6 < length) {
+ spo2_days_ago = value[index];
+ Calendar syncingDay = Calendar.getInstance();
+ syncingDay.add(Calendar.DAY_OF_MONTH, 0 - spo2_days_ago);
+ syncingDay.set(Calendar.MINUTE, 0);
+ syncingDay.set(Calendar.SECOND, 0);
+ index++;
+ for (int hour=0; hour<=23; hour++) {
+ syncingDay.set(Calendar.HOUR_OF_DAY, hour);
+ float spo2_min = value[index];
+ index++;
+ float spo2_max = value[index];
+ index++;
+ if (spo2_min > 0 && spo2_max > 0) {
+ LOG.info("Received SpO2 data from {} days ago at {}:00: min={}, max={}", spo2_days_ago, hour, spo2_min, spo2_max);
+ ColmiSpo2Sample spo2Sample = new ColmiSpo2Sample();
+ spo2Sample.setTimestamp(syncingDay.getTimeInMillis());
+ spo2Sample.setSpo2(Math.round((spo2_min + spo2_max) / 2.0f));
+ spo2Samples.add(spo2Sample);
+ }
+ if (index - 6 >= length) {
+ break;
+ }
+ }
+ }
+ if (!spo2Samples.isEmpty()) {
+ try (DBHandler db = GBApplication.acquireDB()) {
+ ColmiSpo2SampleProvider sampleProvider = new ColmiSpo2SampleProvider(device, db.getDaoSession());
+ Long userId = DBHelper.getUser(db.getDaoSession()).getId();
+ Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId();
+ for (final ColmiSpo2Sample sample : spo2Samples) {
+ sample.setDeviceId(deviceId);
+ sample.setUserId(userId);
+ }
+ LOG.info("Will persist {} SpO2 samples", spo2Samples.size());
+ sampleProvider.addSamples(spo2Samples);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording SpO2 samples", e);
+ }
+ }
+ }
+
+ public static void historicalSleep(GBDevice gbDevice, Context context, byte[] value) {
+ int packetLength = BLETypeConversions.toUint16(value[2], value[3]);
+ if (packetLength < 2) {
+ LOG.info("Received empty sleep data packet: {}", StringUtils.bytesToHex(value));
+ } else {
+ int daysInPacket = value[6];
+ LOG.debug("Received sleep data packet for {} days: {}", daysInPacket, StringUtils.bytesToHex(value));
+ int index = 7;
+ for (int i = 1; i <= daysInPacket; i++) {
+ // Parse sleep session
+ int daysAgo = value[index];
+ index++;
+ int dayBytes = value[index];
+ index++;
+ int sleepStart = BLETypeConversions.toUint16(value[index], value[index + 1]);
+ index += 2;
+ int sleepEnd = BLETypeConversions.toUint16(value[index], value[index + 1]);
+ index += 2;
+ // Calculate sleep start timestamp
+ Calendar sessionStart = Calendar.getInstance();
+ sessionStart.add(Calendar.DAY_OF_MONTH, 0 - daysAgo);
+ sessionStart.set(Calendar.HOUR_OF_DAY, 0);
+ sessionStart.set(Calendar.MINUTE, 0);
+ sessionStart.set(Calendar.SECOND, 0);
+ if (sleepStart > sleepEnd) {
+ // Sleep started a day earlier, so before midnight
+ sessionStart.add(Calendar.DAY_OF_MONTH, -1);
+ sessionStart.add(Calendar.MINUTE, sleepStart);
+ } else {
+ // Sleep started this day, so after midnight
+ sessionStart.add(Calendar.MINUTE, sleepStart);
+ }
+ // Calculate sleep end timestamp
+ Calendar sessionEnd = Calendar.getInstance();
+ sessionEnd.add(Calendar.DAY_OF_MONTH, 0 - daysAgo);
+ sessionEnd.set(Calendar.HOUR_OF_DAY, 0);
+ sessionEnd.set(Calendar.MINUTE, sleepEnd);
+ sessionEnd.set(Calendar.SECOND, 0);
+ LOG.info("Sleep session starts at {} and ends at {}", sessionStart.getTime(), sessionEnd.getTime());
+ // Build sample object to persist
+ final ColmiSleepSessionSample sessionSample = new ColmiSleepSessionSample();
+ sessionSample.setTimestamp(sessionStart.getTimeInMillis());
+ sessionSample.setWakeupTime(sessionEnd.getTimeInMillis());
+ // Handle sleep stages
+ final List stageSamples = new ArrayList<>();
+ Calendar sleepStage = (Calendar) sessionStart.clone();
+ for (int j = 4; j < dayBytes; j += 2) {
+ int sleepMinutes = value[index + 1];
+ LOG.info("Sleep stage type={} starts at {} and lasts for {} minutes", value[index], sleepStage.getTime(), sleepMinutes);
+ final ColmiSleepStageSample sample = new ColmiSleepStageSample();
+ sample.setTimestamp(sleepStage.getTimeInMillis());
+ sample.setDuration(value[index + 1]);
+ sample.setStage(value[index]);
+ stageSamples.add(sample);
+ // Prepare for next sample
+ index += 2;
+ sleepStage.add(Calendar.MINUTE, sleepMinutes);
+ }
+ // Persist sleep session
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(gbDevice, session);
+ final User user = DBHelper.getUser(session);
+
+ final ColmiSleepSessionSampleProvider sampleProvider = new ColmiSleepSessionSampleProvider(gbDevice, session);
+
+ sessionSample.setDevice(device);
+ sessionSample.setUser(user);
+
+ LOG.debug("Will persist 1 sleep session sample from {} to {}", sessionSample.getTimestamp(), sessionSample.getWakeupTime());
+ sampleProvider.addSample(sessionSample);
+ } catch (final Exception e) {
+ GB.toast(context, "Error saving sleep session sample", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ // Persist sleep stages
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(gbDevice, session);
+ final User user = DBHelper.getUser(session);
+
+ final ColmiSleepStageSampleProvider sampleProvider = new ColmiSleepStageSampleProvider(gbDevice, session);
+
+ for (final ColmiSleepStageSample sample : stageSamples) {
+ sample.setDevice(device);
+ sample.setUser(user);
+ }
+
+ LOG.debug("Will persist {} sleep stage samples", stageSamples.size());
+ sampleProvider.addSamples(stageSamples);
+ } catch (final Exception e) {
+ GB.toast(context, "Error saving sleep stage samples", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiActivitySampleProvider.java
new file mode 100644
index 000000000..459f8784e
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiActivitySampleProvider.java
@@ -0,0 +1,197 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR0xConstants;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+
+public class ColmiActivitySampleProvider extends AbstractSampleProvider {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiActivitySampleProvider.class);
+
+ public ColmiActivitySampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getColmiActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return null;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return Math.min(rawIntensity / 7000f, 1f);
+ }
+
+ @Override
+ public ColmiActivitySample createActivitySample() {
+ return new ColmiActivitySample();
+ }
+
+ @Override
+ protected List getGBActivitySamples(final int timestamp_from, final int timestamp_to) {
+ LOG.trace(
+ "Getting Colmi activity samples between {} and {}",
+ timestamp_from,
+ timestamp_to
+ );
+ final long nanoStart = System.nanoTime();
+
+ final List samples = super.getGBActivitySamples(timestamp_from, timestamp_to);
+ final Map sampleByTs = new HashMap<>();
+ for (final ColmiActivitySample sample : samples) {
+ sampleByTs.put(sample.getTimestamp(), sample);
+ }
+
+ overlayHeartRate(sampleByTs, timestamp_from, timestamp_to);
+ overlaySleep(sampleByTs, timestamp_from, timestamp_to);
+
+ // Add empty dummy samples every 5 min to make sure the charts and stats aren't too malformed
+ // This is necessary due to the Colmi rings just reporting steps/calories/distance aggregates per hour
+ for (int i=timestamp_from; i<=timestamp_to; i+=300) {
+ ColmiActivitySample sample = sampleByTs.get(i);
+ if (sample == null) {
+ sample = new ColmiActivitySample();
+ sample.setTimestamp(i);
+ sample.setProvider(this);
+ sample.setRawKind(ActivitySample.NOT_MEASURED);
+ sampleByTs.put(i, sample);
+ }
+ }
+
+ final List finalSamples = new ArrayList<>(sampleByTs.values());
+ Collections.sort(finalSamples, (a, b) -> Integer.compare(a.getTimestamp(), b.getTimestamp()));
+
+ final long nanoEnd = System.nanoTime();
+ final long executionTime = (nanoEnd - nanoStart) / 1000000;
+ LOG.trace("Getting Colmi samples took {}ms", executionTime);
+
+ return finalSamples;
+ }
+
+ private void overlayHeartRate(final Map sampleByTs, final int timestamp_from, final int timestamp_to) {
+ final ColmiHeartRateSampleProvider heartRateSampleProvider = new ColmiHeartRateSampleProvider(getDevice(), getSession());
+ final List hrSamples = heartRateSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L);
+
+ for (final ColmiHeartRateSample hrSample : hrSamples) {
+ // round to the nearest minute, we don't need per-second granularity
+ final int tsSeconds = (int) ((hrSample.getTimestamp() / 1000) / 60) * 60;
+ ColmiActivitySample sample = sampleByTs.get(tsSeconds);
+ if (sample == null) {
+ sample = new ColmiActivitySample();
+ sample.setTimestamp(tsSeconds);
+ sample.setProvider(this);
+ sampleByTs.put(tsSeconds, sample);
+ }
+
+ sample.setHeartRate(hrSample.getHeartRate());
+ }
+ }
+
+ private void overlaySleep(final Map sampleByTs, final int timestamp_from, final int timestamp_to) {
+ final ColmiSleepStageSampleProvider sleepStageSampleProvider = new ColmiSleepStageSampleProvider(getDevice(), getSession());
+ final List sleepStageSamples = sleepStageSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L);
+
+ for (final ColmiSleepStageSample sleepStageSample : sleepStageSamples) {
+ final ActivityKind sleepRawKind = sleepStageToActivityKind(sleepStageSample.getStage());
+ if (sleepRawKind == ActivityKind.AWAKE_SLEEP) continue;
+ // round to the nearest minute, we don't need per-second granularity
+ final int tsSeconds = (int) ((sleepStageSample.getTimestamp() / 1000) / 60) * 60;
+ for (int i = tsSeconds; i < tsSeconds + sleepStageSample.getDuration() * 60; i += 60) {
+ ColmiActivitySample sample = sampleByTs.get(i);
+ if (sample == null) {
+ sample = new ColmiActivitySample();
+ sample.setTimestamp(i);
+ sample.setProvider(this);
+ sampleByTs.put(i, sample);
+ }
+ sample.setRawKind(sleepRawKind.getCode());
+
+ switch (sleepRawKind) {
+ case LIGHT_SLEEP:
+ sample.setRawIntensity(1400);
+ break;
+ case DEEP_SLEEP:
+ sample.setRawIntensity(700);
+ break;
+ }
+ }
+ }
+ }
+
+ final ActivityKind sleepStageToActivityKind(final int sleepStage) {
+ switch (sleepStage) {
+ case ColmiR0xConstants.SLEEP_TYPE_LIGHT:
+ return ActivityKind.LIGHT_SLEEP;
+ case ColmiR0xConstants.SLEEP_TYPE_DEEP:
+ return ActivityKind.DEEP_SLEEP;
+ case ColmiR0xConstants.SLEEP_TYPE_AWAKE:
+ return ActivityKind.AWAKE_SLEEP;
+ default:
+ return ActivityKind.UNKNOWN;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHeartRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHeartRateSampleProvider.java
new file mode 100644
index 000000000..b9b266061
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiHeartRateSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiHeartRateSampleProvider extends AbstractTimeSampleProvider {
+ public ColmiHeartRateSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getColmiHeartRateSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiHeartRateSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiHeartRateSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiHeartRateSample createSample() {
+ return new ColmiHeartRateSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepSessionSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepSessionSampleProvider.java
new file mode 100644
index 000000000..2d237cdd6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepSessionSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepSessionSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepSessionSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiSleepSessionSampleProvider extends AbstractTimeSampleProvider {
+ public ColmiSleepSessionSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getColmiSleepSessionSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiSleepSessionSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiSleepSessionSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiSleepSessionSample createSample() {
+ return new ColmiSleepSessionSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepStageSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepStageSampleProvider.java
new file mode 100644
index 000000000..06db0e961
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSleepStageSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiSleepStageSampleProvider extends AbstractTimeSampleProvider {
+ public ColmiSleepStageSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getColmiSleepStageSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiSleepStageSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiSleepStageSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiSleepStageSample createSample() {
+ return new ColmiSleepStageSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSpo2SampleProvider.java
new file mode 100644
index 000000000..10e9eb15a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiSpo2SampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiSpo2SampleProvider extends AbstractTimeSampleProvider {
+ public ColmiSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getColmiSpo2SampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiSpo2SampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiSpo2SampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiSpo2Sample createSample() {
+ return new ColmiSpo2Sample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiStressSampleProvider.java
new file mode 100644
index 000000000..4b6966ff8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/colmi/samples/ColmiStressSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class ColmiStressSampleProvider extends AbstractTimeSampleProvider {
+ public ColmiStressSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getColmiStressSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return ColmiStressSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return ColmiStressSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ColmiStressSample createSample() {
+ return new ColmiStressSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractColmiActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractColmiActivitySample.java
new file mode 100644
index 000000000..995f311e8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractColmiActivitySample.java
@@ -0,0 +1,37 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.entities;
+
+public abstract class AbstractColmiActivitySample extends AbstractActivitySample {
+ private int rawIntensity = 0;
+
+ abstract public int getCalories();
+
+ @Override
+ public void setRawIntensity(int rawIntensity) {
+ this.rawIntensity = rawIntensity;
+ }
+
+ @Override
+ public int getRawIntensity() {
+ if (rawIntensity > 0) {
+ return rawIntensity;
+ } else {
+ return getCalories();
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTimeSample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTimeSample.java
index 281513b56..5e312916c 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTimeSample.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractTimeSample.java
@@ -22,6 +22,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.TimeSample;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
public abstract class AbstractTimeSample implements TimeSample {
+ // Unix timestamp in milliseconds
public abstract void setTimestamp(long timestamp);
public abstract long getUserId();
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 6a25f5a04..1499c9d66 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
@@ -38,6 +38,9 @@ import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGMWB5000D
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGWB5600DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.CmfWatchPro2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.CmfWatchProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR02Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR03Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR06Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.cycling_sensor.coordinator.CyclingSensorCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.divoom.PixooCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.domyos.DomyosT540Coordinator;
@@ -56,8 +59,8 @@ import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminF
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix5PlusCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix6Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix6SapphireCoordinator;
-import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix7SCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix7ProCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.fenix.GarminFenix7SCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner245Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner255Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner255MusicCoordinator;
@@ -65,15 +68,15 @@ import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.Ga
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner255SMusicCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner265Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.forerunner.GarminForerunner965Coordinator;
-import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2XSolarCoordinator;
-import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinctCoordinator;
-import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.swim.GarminSwim2Coordinator;
-import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinctSolarCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2SCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2SSolarCoordinator;
-import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2SolarCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2SolTacCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2SolarCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinct2XSolarCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinctCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinctCrossoverCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.instinct.GarminInstinctSolarCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.swim.GarminSwim2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenu2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenu2PlusCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.venu.GarminVenu2SCoordinator;
@@ -482,6 +485,9 @@ public enum DeviceType {
FEMOMETER_VINCA2(FemometerVinca2DeviceCoordinator.class),
PIXOO(PixooCoordinator.class),
HAMA_FIT6900(HamaFit6900DeviceCoordinator.class),
+ COLMI_R02(ColmiR02Coordinator.class),
+ COLMI_R03(ColmiR03Coordinator.class),
+ COLMI_R06(ColmiR06Coordinator.class),
SCANNABLE(ScannableDeviceCoordinator.class),
CYCLING_SENSOR(CyclingSensorCoordinator.class),
TEST(TestDeviceCoordinator.class);
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
new file mode 100644
index 000000000..2ad033bc6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/colmi/ColmiR0xDeviceSupport.java
@@ -0,0 +1,653 @@
+/* Copyright (C) 2024 Arjan Schrijver
+
+ 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.colmi;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+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.ColmiR0xConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.ColmiR0xPacketHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHeartRateSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.IntentListener;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfoProfile;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+public class ColmiR0xDeviceSupport extends AbstractBTLEDeviceSupport {
+ private static final Logger LOG = LoggerFactory.getLogger(ColmiR0xDeviceSupport.class);
+ Handler backgroundTasksHandler = new Handler(Looper.getMainLooper());
+ Runnable backgroundTask;
+
+ private final DeviceInfoProfile deviceInfoProfile;
+ private String cachedFirmwareVersion = null;
+
+ private int daysAgo;
+ private int packetsTotalNr;
+ private Calendar syncingDay;
+
+ private int bigDataPacketSize;
+ private ByteBuffer bigDataPacket;
+
+ public ColmiR0xDeviceSupport() {
+ super(LOG);
+ addSupportedService(ColmiR0xConstants.CHARACTERISTIC_SERVICE_V1);
+ addSupportedService(ColmiR0xConstants.CHARACTERISTIC_SERVICE_V2);
+ addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION);
+
+ IntentListener mListener = intent -> {
+ String action = intent.getAction();
+ if (DeviceInfoProfile.ACTION_DEVICE_INFO.equals(action)) {
+ handleDeviceInfo(intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO));
+ }
+ };
+
+ deviceInfoProfile = new DeviceInfoProfile<>(this);
+ deviceInfoProfile.addListener(mListener);
+ addSupportedProfile(deviceInfoProfile);
+
+// try (DBHandler db = GBApplication.acquireDB()) {
+// db.getDatabase().execSQL("DROP TABLE IF EXISTS 'COLMI_ACTIVITY_SAMPLE'");
+// db.getDatabase().execSQL("DROP TABLE IF EXISTS 'COLMI_HEART_RATE_SAMPLE'");
+// db.getDatabase().execSQL("DROP TABLE IF EXISTS 'COLMI_SPO2_SAMPLE'");
+// db.getDatabase().execSQL("DROP TABLE IF EXISTS 'COLMI_STRESS_SAMPLE'");
+// } catch (Exception e) {
+// LOG.error("Error acquiring database", e);
+// }
+ }
+
+ @Override
+ public void dispose() {
+ if (backgroundTasksHandler != null) {
+ backgroundTasksHandler.removeCallbacks(backgroundTask);
+ }
+
+ super.dispose();
+ }
+
+ @Override
+ public void setContext(GBDevice gbDevice, BluetoothAdapter btAdapter, Context context) {
+ if (gbDevice.getFirmwareVersion() != null) {
+ setCachedFirmwareVersion(gbDevice.getFirmwareVersion());
+ }
+ super.setContext(gbDevice, btAdapter, context);
+ }
+
+ public String getCachedFirmwareVersion() {
+ return this.cachedFirmwareVersion;
+ }
+
+ public void setCachedFirmwareVersion(String version) {
+ this.cachedFirmwareVersion = version;
+ }
+
+ private void handleDeviceInfo(DeviceInfo info) {
+ LOG.debug("Device info: " + info);
+
+ GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo();
+ versionCmd.hwVersion = info.getHardwareRevision();
+ versionCmd.fwVersion = info.getFirmwareRevision();
+ handleGBDeviceEvent(versionCmd);
+ }
+
+ @Override
+ public boolean useAutoConnect() {
+ return false;
+ }
+
+ @Override
+ protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
+
+ if (getDevice().getFirmwareVersion() == null) {
+ getDevice().setFirmwareVersion(getCachedFirmwareVersion() != null ? getCachedFirmwareVersion() : "N/A");
+ }
+ deviceInfoProfile.requestDeviceInfo(builder);
+
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext()));
+
+ builder.notify(getCharacteristic(ColmiR0xConstants.CHARACTERISTIC_NOTIFY_V1), true);
+ builder.notify(getCharacteristic(ColmiR0xConstants.CHARACTERISTIC_NOTIFY_V2), true);
+
+ // Delay initialization with 2 seconds to give the ring time to settle
+ backgroundTask = new Runnable() {
+ @Override
+ public void run() {
+ postConnectInitialization();
+ }
+ };
+ backgroundTasksHandler.postDelayed(backgroundTask, 2000);
+
+ return builder;
+ }
+
+ private void postConnectInitialization() {
+ setPhoneName();
+ setDateTime();
+ setUserPreferences();
+ requestBatteryInfo();
+ requestSettingsFromRing();
+ }
+
+ @Override
+ public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+ if (super.onCharacteristicChanged(gatt, characteristic)) {
+ return true;
+ }
+
+ UUID characteristicUUID = characteristic.getUuid();
+ byte[] value = characteristic.getValue();
+
+ LOG.debug("Characteristic {} changed, value: {}", characteristicUUID, StringUtils.bytesToHex(characteristic.getValue()));
+
+ if (characteristicUUID.equals(ColmiR0xConstants.CHARACTERISTIC_NOTIFY_V1)) {
+ switch (value[0]) {
+ case ColmiR0xConstants.CMD_SET_DATE_TIME:
+ LOG.info("Received set date/time response: {}", StringUtils.bytesToHex(value));
+ break;
+ case ColmiR0xConstants.CMD_BATTERY:
+ int levelResponse = value[1];
+ boolean charging = value[2] == 1;
+ LOG.info("Received battery level response: {}% (charging: {})", levelResponse, charging);
+ GBDeviceEventBatteryInfo batteryEvent = new GBDeviceEventBatteryInfo();
+ batteryEvent.level = levelResponse;
+ batteryEvent.state = charging ? BatteryState.BATTERY_CHARGING : BatteryState.BATTERY_NORMAL;
+ evaluateGBDeviceEvent(batteryEvent);
+ break;
+ case ColmiR0xConstants.CMD_PHONE_NAME:
+ LOG.info("Received phone name response: {}", StringUtils.bytesToHex(value));
+ break;
+ case ColmiR0xConstants.CMD_PREFERENCES:
+ LOG.info("Received user preferences response: {}", StringUtils.bytesToHex(value));
+ break;
+ case ColmiR0xConstants.CMD_SYNC_HEART_RATE:
+ LOG.info("Received HR history sync packet: {}", StringUtils.bytesToHex(value));
+ int hrPacketNr = value[1] & 0xff;
+ if (hrPacketNr == 0xff) {
+ LOG.info("Empty HR history, sync aborted");
+ getDevice().unsetBusyTask();
+ getDevice().sendDeviceUpdateIntent(getContext());
+ } else if (hrPacketNr == 0) {
+ packetsTotalNr = value[2];
+ LOG.info("HR history packet {} out of total {}", hrPacketNr, packetsTotalNr);
+ } else {
+ Calendar sampleCal = (Calendar) syncingDay.clone();
+ int startValue = hrPacketNr == 1 ? 6 : 2; // packet 1 contains the sync-from timestamp in bytes 2-5
+ int minutesInPreviousPackets = 0;
+ if (hrPacketNr > 1) {
+ minutesInPreviousPackets = 9 * 5; // packet 1
+ minutesInPreviousPackets += (hrPacketNr - 2) * 13 * 5;
+ }
+ for (int i = startValue; i < value.length - 1; i++) {
+ if (value[i] != 0x00) {
+ // Determine time of day
+ int minuteOfDay = minutesInPreviousPackets + (i - startValue) * 5;
+ sampleCal.set(Calendar.HOUR_OF_DAY, minuteOfDay / 60);
+ sampleCal.set(Calendar.MINUTE, minuteOfDay % 60);
+ LOG.info("Value {} is {} bpm, time of day is {}", i, value[i] & 0xff, sampleCal.getTime());
+ // Build sample object and save in database
+ try (DBHandler db = GBApplication.acquireDB()) {
+ ColmiHeartRateSampleProvider sampleProvider = new ColmiHeartRateSampleProvider(getDevice(), db.getDaoSession());
+ Long userId = DBHelper.getUser(db.getDaoSession()).getId();
+ Long deviceId = DBHelper.getDevice(getDevice(), db.getDaoSession()).getId();
+ ColmiHeartRateSample gbSample = new ColmiHeartRateSample();
+ gbSample.setDeviceId(deviceId);
+ gbSample.setUserId(userId);
+ gbSample.setTimestamp(sampleCal.getTimeInMillis());
+ gbSample.setHeartRate(value[i] & 0xff);
+ sampleProvider.addSample(gbSample);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording heart rate samples", e);
+ }
+ }
+ }
+ LOG.info("HR history packet {}", hrPacketNr);
+ if (hrPacketNr == packetsTotalNr - 1) {
+ getDevice().unsetBusyTask();
+ getDevice().sendDeviceUpdateIntent(getContext());
+ }
+ }
+ if (!getDevice().isBusy()) {
+ if (daysAgo < 7) {
+ daysAgo++;
+ fetchHistoryHR();
+ } else {
+ fetchHistoryStress();
+ }
+ }
+ break;
+ case ColmiR0xConstants.CMD_AUTO_HR_PREF:
+ ColmiR0xPacketHandler.hrIntervalSettings(this, value);
+ break;
+ case ColmiR0xConstants.CMD_GOALS:
+ ColmiR0xPacketHandler.goalsSettings(value);
+ break;
+ case ColmiR0xConstants.CMD_AUTO_SPO2_PREF:
+ ColmiR0xPacketHandler.spo2Settings(this, value);
+ break;
+ case ColmiR0xConstants.CMD_PACKET_SIZE:
+ LOG.info("Received packet size indicator: {} bytes", value[1] & 0xff);
+ break;
+ case ColmiR0xConstants.CMD_AUTO_STRESS_PREF:
+ ColmiR0xPacketHandler.stressSettings(this, value);
+ break;
+ case ColmiR0xConstants.CMD_SYNC_STRESS:
+ ColmiR0xPacketHandler.historicalStress(getDevice(), getContext(), value);
+ if (!getDevice().isBusy()) {
+ fetchHistorySpo2();
+ }
+ break;
+ case ColmiR0xConstants.CMD_SYNC_ACTIVITY:
+ ColmiR0xPacketHandler.historicalActivity(getDevice(), getContext(), value);
+ if (!getDevice().isBusy()) {
+ if (daysAgo < 7) {
+ daysAgo++;
+ fetchHistoryActivity();
+ } else {
+ daysAgo = 0;
+ fetchHistoryHR();
+ }
+ }
+ break;
+ case ColmiR0xConstants.CMD_FIND_DEVICE:
+ LOG.info("Received find device response: {}", StringUtils.bytesToHex(value));
+ break;
+ case ColmiR0xConstants.CMD_MANUAL_HEART_RATE:
+ ColmiR0xPacketHandler.liveHeartRate(getDevice(), getContext(), value);
+ break;
+ case ColmiR0xConstants.CMD_NOTIFICATION:
+ switch (value[1]) {
+ case ColmiR0xConstants.NOTIFICATION_NEW_HR_DATA:
+ LOG.info("Received notification from ring that new HR data is available to sync");
+ break;
+ case ColmiR0xConstants.NOTIFICATION_NEW_SPO2_DATA:
+ LOG.info("Received notification from ring that new SpO2 data is available to sync");
+ break;
+ case ColmiR0xConstants.NOTIFICATION_NEW_STEPS_DATA:
+ LOG.info("Received notification from ring that new steps data is available to sync");
+ break;
+ case ColmiR0xConstants.NOTIFICATION_BATTERY_LEVEL:
+ int levelNotif = value[2];
+ LOG.info("Received battery level notification: {}%", levelNotif);
+ GBDeviceEventBatteryInfo batteryNotifEvent = new GBDeviceEventBatteryInfo();
+ batteryNotifEvent.state = BatteryState.BATTERY_NORMAL;
+ batteryNotifEvent.level = levelNotif;
+ evaluateGBDeviceEvent(batteryNotifEvent);
+ break;
+ case ColmiR0xConstants.NOTIFICATION_LIVE_ACTIVITY:
+ ColmiR0xPacketHandler.liveActivity(value);
+ break;
+ default:
+ LOG.info("Received unrecognized notification: {}", StringUtils.bytesToHex(value));
+ break;
+ }
+ break;
+ default:
+ LOG.info("Received unrecognized packet: {}", StringUtils.bytesToHex(value));
+ break;
+ }
+ }
+ if (characteristicUUID.equals(ColmiR0xConstants.CHARACTERISTIC_NOTIFY_V2)) {
+ // Big data responses can arrive in multiple packets that need to be concatenated
+ if (bigDataPacket != null) {
+ LOG.debug("Received {} bytes on big data characteristic while waiting for follow-up data", value.length);
+ ByteBuffer concatenated = ByteBuffer
+ .allocate(bigDataPacket.limit() + value.length)
+ .put(bigDataPacket)
+ .put(value);
+ bigDataPacket = concatenated;
+ if (bigDataPacket.limit() < bigDataPacketSize + 6) {
+ // If the received data is smaller than the expected packet size (+ 6 bytes header),
+ // wait for the next packet and append it
+ return true;
+ } else {
+ value = bigDataPacket.array();
+ bigDataPacket = null;
+ }
+ }
+ switch (value[0]) {
+ case ColmiR0xConstants.CMD_BIG_DATA_V2:
+ int packetLength = BLETypeConversions.toUint16(value[2], value[3]);
+ if (value.length < packetLength + 6) {
+ // If the received packet is smaller than the expected packet size (+ 6 bytes header),
+ // wait for the next packet and append it
+ LOG.debug("Big data packet is not complete yet, got {} bytes while expecting {}. Waiting for more...", value.length, packetLength + 6);
+ bigDataPacketSize = packetLength;
+ bigDataPacket = ByteBuffer.wrap(value);
+ return true;
+ }
+ switch (value[1]) {
+ case ColmiR0xConstants.BIG_DATA_TYPE_SLEEP:
+ ColmiR0xPacketHandler.historicalSleep(getDevice(), getContext(), value);
+ fetchRecordedDataFinished();
+ break;
+ case ColmiR0xConstants.BIG_DATA_TYPE_SPO2:
+ ColmiR0xPacketHandler.historicalSpo2(getDevice(), value);
+ fetchHistorySleep();
+ break;
+ default:
+ LOG.info("Received unrecognized big data packet: {}", StringUtils.bytesToHex(value));
+ break;
+ }
+ break;
+ default:
+ LOG.info("Received unrecognized big data packet: {}", StringUtils.bytesToHex(value));
+ break;
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ private byte[] buildPacket(byte[] contents) {
+ ByteBuffer buffer = ByteBuffer.allocate(16);
+ if (contents.length <= 15) {
+ buffer.put(contents);
+ int checksum = 0;
+ for (byte content : contents) {
+ checksum = (byte) (checksum + content) & 0xff;
+ }
+ buffer.put(15, (byte) checksum);
+ } else {
+ LOG.warn("Packet content too long!");
+ }
+ return buffer.array();
+ }
+
+ private void sendWrite(String taskName, byte[] contents) {
+ TransactionBuilder builder = new TransactionBuilder(taskName);
+ BluetoothGattCharacteristic characteristic = getCharacteristic(ColmiR0xConstants.CHARACTERISTIC_WRITE);
+ if (characteristic != null) {
+ builder.write(characteristic, contents);
+ builder.queue(getQueue());
+ }
+ }
+
+ private void sendCommand(String taskName, byte[] contents) {
+ TransactionBuilder builder = new TransactionBuilder(taskName);
+ BluetoothGattCharacteristic characteristic = getCharacteristic(ColmiR0xConstants.CHARACTERISTIC_COMMAND);
+ if (characteristic != null) {
+ builder.write(characteristic, contents);
+ builder.queue(getQueue());
+ }
+ }
+
+ private void requestBatteryInfo() {
+ byte[] batteryRequestPacket = buildPacket(new byte[]{ColmiR0xConstants.CMD_BATTERY});
+ LOG.info("Battery request sent: {}", StringUtils.bytesToHex(batteryRequestPacket));
+ sendWrite("batteryRequest", batteryRequestPacket);
+ }
+
+ private void setPhoneName() {
+ byte[] setPhoneNamePacket = buildPacket(new byte[]{
+ ColmiR0xConstants.CMD_PHONE_NAME,
+ 0x02, // Client major version
+ 0x0a, // Client minor version
+ 'G',
+ 'B'
+ });
+ LOG.info("Phone name sent: {}", StringUtils.bytesToHex(setPhoneNamePacket));
+ sendWrite("phoneNameRequest", setPhoneNamePacket);
+ }
+
+ private void setDateTime() {
+ Calendar now = GregorianCalendar.getInstance();
+ byte[] setDateTimePacket = buildPacket(new byte[]{
+ ColmiR0xConstants.CMD_SET_DATE_TIME,
+ Byte.parseByte(String.valueOf(now.get(Calendar.YEAR) % 2000), 16),
+ Byte.parseByte(String.valueOf(now.get(Calendar.MONTH) + 1), 16),
+ Byte.parseByte(String.valueOf(now.get(Calendar.DAY_OF_MONTH)), 16),
+ Byte.parseByte(String.valueOf(now.get(Calendar.HOUR_OF_DAY)), 16),
+ Byte.parseByte(String.valueOf(now.get(Calendar.MINUTE)), 16),
+ Byte.parseByte(String.valueOf(now.get(Calendar.SECOND)), 16)
+ });
+ LOG.info("Set date/time request sent: {}", StringUtils.bytesToHex(setDateTimePacket));
+ sendWrite("dateTimeRequest", setDateTimePacket);
+ }
+
+ @Override
+ public void onSetTime() {
+ setDateTime();
+ }
+
+ @Override
+ public void onSendConfiguration(String config) {
+ final Prefs prefs = getDevicePrefs();
+ switch (config) {
+ case SettingsActivity.PREF_MEASUREMENT_SYSTEM:
+ setUserPreferences();
+ break;
+ case DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING:
+ final boolean spo2Enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, false);
+ byte[] spo2PrefsPacket = buildPacket(new byte[]{ColmiR0xConstants.CMD_AUTO_SPO2_PREF, ColmiR0xConstants.PREF_WRITE, (byte) (spo2Enabled ? 0x01 : 0x00)});
+ LOG.info("SpO2 preference request sent: {}", StringUtils.bytesToHex(spo2PrefsPacket));
+ sendWrite("spo2PreferenceRequest", spo2PrefsPacket);
+ break;
+ case DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING:
+ final boolean stressEnabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING, false);
+ byte[] stressPrefsPacket = buildPacket(new byte[]{ColmiR0xConstants.CMD_AUTO_STRESS_PREF, ColmiR0xConstants.PREF_WRITE, (byte) (stressEnabled ? 0x01 : 0x00)});
+ LOG.info("Stress preference request sent: {}", StringUtils.bytesToHex(stressPrefsPacket));
+ sendWrite("stressPreferenceRequest", stressPrefsPacket);
+ break;
+ }
+ }
+
+ @Override
+ public void onSetHeartRateMeasurementInterval(int seconds) {
+ // Round to nearest 5 minutes and limit to 60 minutes due to device constraints
+ long hrIntervalMins = Math.min(Math.round(seconds / 60.0 / 5.0) * 5, 60);
+ byte[] hrIntervalPacket = buildPacket(new byte[]{
+ ColmiR0xConstants.CMD_AUTO_HR_PREF,
+ ColmiR0xConstants.PREF_WRITE,
+ hrIntervalMins > 0 ? (byte) 0x01 : (byte) 0x02,
+ (byte) hrIntervalMins
+ });
+ LOG.info("HR interval preference request sent: {}", StringUtils.bytesToHex(hrIntervalPacket));
+ sendWrite("hrIntervalPreferenceRequest", hrIntervalPacket);
+ }
+
+ private void setUserPreferences() {
+ final Prefs prefs = getDevicePrefs();
+ final ActivityUser user = new ActivityUser();
+ final String measurementSystem = prefs.getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, "metric");
+ byte userGender;
+ switch (user.getGender()) {
+ case ActivityUser.GENDER_FEMALE:
+ userGender = 0x01;
+ break;
+ case ActivityUser.GENDER_MALE:
+ userGender = 0x00;
+ break;
+ default:
+ userGender = 0x02;
+ break;
+ }
+ byte[] userPrefsPacket = buildPacket(new byte[]{
+ ColmiR0xConstants.CMD_PREFERENCES,
+ ColmiR0xConstants.PREF_WRITE,
+ 0x00, // 24h format, 0x01 is 12h format
+ (byte) ("metric".equals(measurementSystem) ? 0x00 : 0x01),
+ userGender,
+ (byte) user.getAge(),
+ (byte) user.getHeightCm(),
+ (byte) user.getWeightKg(),
+ 0x00, // systolic blood pressure (e.g. 120)
+ 0x00, // diastolic blood pressure (e.g. 90)
+ 0x00 // heart rate value warning threshold: (e.g. 160)
+ });
+ LOG.info("User preferences request sent: {}", StringUtils.bytesToHex(userPrefsPacket));
+ sendWrite("userPreferenceRequest", userPrefsPacket);
+ }
+
+ private void requestSettingsFromRing() {
+ byte[] request = buildPacket(new byte[]{ColmiR0xConstants.CMD_AUTO_HR_PREF, ColmiR0xConstants.PREF_READ});
+ LOG.info("Request HR measurement interval from ring: {}", StringUtils.bytesToHex(request));
+ sendWrite("hrIntervalRequest", request);
+ request = buildPacket(new byte[]{ColmiR0xConstants.CMD_AUTO_STRESS_PREF, ColmiR0xConstants.PREF_READ});
+ LOG.info("Request stress measurement setting from ring: {}", StringUtils.bytesToHex(request));
+ sendWrite("stressSettingRequest", request);
+ request = buildPacket(new byte[]{ColmiR0xConstants.CMD_AUTO_SPO2_PREF, ColmiR0xConstants.PREF_READ});
+ LOG.info("Request SpO2 measurement setting from ring: {}", StringUtils.bytesToHex(request));
+ sendWrite("spo2SettingRequest", request);
+ request = buildPacket(new byte[]{ColmiR0xConstants.CMD_GOALS, ColmiR0xConstants.PREF_READ});
+ LOG.info("Request goals from ring: {}", StringUtils.bytesToHex(request));
+ sendWrite("goalsSettingRequest", request);
+ }
+
+ @Override
+ public void onPowerOff() {
+ byte[] poweroffPacket = buildPacket(new byte[]{ColmiR0xConstants.CMD_POWER_OFF, 0x01});
+ LOG.info("Poweroff request sent: {}", StringUtils.bytesToHex(poweroffPacket));
+ sendWrite("poweroffRequest", poweroffPacket);
+ }
+
+ @Override
+ public void onFindDevice(boolean start) {
+ if (!start) return;
+
+ byte[] findDevicePacket = buildPacket(new byte[]{ColmiR0xConstants.CMD_FIND_DEVICE, 0x55, (byte) 0xAA});
+ LOG.info("Find device request sent: {}", StringUtils.bytesToHex(findDevicePacket));
+ sendWrite("findDeviceRequest", findDevicePacket);
+ }
+
+ @Override
+ public void onHeartRateTest() {
+ byte[] measureHeartRatePacket = buildPacket(new byte[]{ColmiR0xConstants.CMD_MANUAL_HEART_RATE, 0x01});
+ LOG.info("Measure HR request sent: {}", StringUtils.bytesToHex(measureHeartRatePacket));
+ sendWrite("measureHRRequest", measureHeartRatePacket);
+ }
+
+ @Override
+ public void onFetchRecordedData(int dataTypes) {
+ GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext());
+ daysAgo = 0;
+ fetchHistoryActivity();
+ }
+
+ private void fetchRecordedDataFinished() {
+ GB.updateTransferNotification(null, "", false, 100, getContext());
+ GB.signalActivityDataFinish();
+ LOG.info("Sync finished!");
+ getDevice().unsetBusyTask();
+ getDevice().sendDeviceUpdateIntent(getContext());
+ }
+
+ private void fetchHistoryActivity() {
+ getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_activity_data));
+ getDevice().sendDeviceUpdateIntent(getContext());
+ syncingDay = Calendar.getInstance();
+ syncingDay.add(Calendar.DAY_OF_MONTH, 0 - daysAgo);
+ syncingDay.set(Calendar.HOUR_OF_DAY, 0);
+ syncingDay.set(Calendar.MINUTE, 0);
+ syncingDay.set(Calendar.SECOND, 0);
+ byte[] activityHistoryRequest = buildPacket(new byte[]{ColmiR0xConstants.CMD_SYNC_ACTIVITY, (byte) daysAgo, 0x0f, 0x00, 0x5f, 0x01});
+ LOG.info("Fetch historical activity data request sent: {}", StringUtils.bytesToHex(activityHistoryRequest));
+ sendWrite("activityHistoryRequest", activityHistoryRequest);
+ }
+
+ private void fetchHistoryHR() {
+ getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_hr_data));
+ getDevice().sendDeviceUpdateIntent(getContext());
+ syncingDay = Calendar.getInstance();
+ if (daysAgo != 0) {
+ syncingDay.add(Calendar.DAY_OF_MONTH, 0 - daysAgo);
+ syncingDay.set(Calendar.HOUR_OF_DAY, 0);
+ syncingDay.set(Calendar.MINUTE, 0);
+ syncingDay.set(Calendar.SECOND, 0);
+ }
+ ByteBuffer hrHistoryRequestBB = ByteBuffer.allocate(5);
+ hrHistoryRequestBB.order(ByteOrder.LITTLE_ENDIAN);
+ hrHistoryRequestBB.put(0, ColmiR0xConstants.CMD_SYNC_HEART_RATE);
+ hrHistoryRequestBB.putInt(1, (int) (syncingDay.getTimeInMillis() / 1000));
+ byte[] hrHistoryRequest = buildPacket(hrHistoryRequestBB.array());
+ LOG.info("Fetch historical HR data request sent ({}): {}", syncingDay.getTime(), StringUtils.bytesToHex(hrHistoryRequest));
+ sendWrite("hrHistoryRequest", hrHistoryRequest);
+ }
+
+ private void fetchHistoryStress() {
+ getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_stress_data));
+ getDevice().sendDeviceUpdateIntent(getContext());
+ syncingDay = Calendar.getInstance();
+ byte[] stressHistoryRequest = buildPacket(new byte[]{ColmiR0xConstants.CMD_SYNC_STRESS});
+ LOG.info("Fetch historical stress data request sent: {}", StringUtils.bytesToHex(stressHistoryRequest));
+ sendWrite("stressHistoryRequest", stressHistoryRequest);
+ }
+
+ private void fetchHistorySpo2() {
+ getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_spo2_data));
+ getDevice().sendDeviceUpdateIntent(getContext());
+ byte[] spo2HistoryRequest = new byte[]{
+ ColmiR0xConstants.CMD_BIG_DATA_V2,
+ ColmiR0xConstants.BIG_DATA_TYPE_SPO2,
+ 0x01,
+ 0x00,
+ (byte) 0xff,
+ 0x00,
+ (byte) 0xff
+ };
+ LOG.info("Fetch historical SpO2 data request sent: {}", StringUtils.bytesToHex(spo2HistoryRequest));
+ sendCommand("spo2HistoryRequest", spo2HistoryRequest);
+ }
+
+ private void fetchHistorySleep() {
+ getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_sleep_data));
+ getDevice().sendDeviceUpdateIntent(getContext());
+ byte[] sleepHistoryRequest = new byte[]{
+ ColmiR0xConstants.CMD_BIG_DATA_V2,
+ ColmiR0xConstants.BIG_DATA_TYPE_SLEEP,
+ 0x01,
+ 0x00,
+ (byte) 0xff,
+ 0x00,
+ (byte) 0xff
+ };
+ LOG.info("Fetch historical sleep data request sent: {}", StringUtils.bytesToHex(sleepHistoryRequest));
+ sendCommand("sleepHistoryRequest", sleepHistoryRequest);
+ }
+}
diff --git a/app/src/main/res/drawable/ic_device_smartring.xml b/app/src/main/res/drawable/ic_device_smartring.xml
new file mode 100644
index 000000000..881015898
--- /dev/null
+++ b/app/src/main/res/drawable/ic_device_smartring.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_device_smartring_disabled.xml b/app/src/main/res/drawable/ic_device_smartring_disabled.xml
new file mode 100644
index 000000000..be7442051
--- /dev/null
+++ b/app/src/main/res/drawable/ic_device_smartring_disabled.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 392a72edd..7b70c3a6e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -673,6 +673,7 @@
Fetching PAI data
Fetching SpO2 data
Fetching heart rate data
+ Fetching sleep data
Fetching sleep respiratory rate data
Fetching temperature data
Fetching statistics
@@ -1809,6 +1810,9 @@
Redmi Watch 2 Lite
Redmi Smart Band Pro
Redmi Watch 4
+ Colmi R02
+ Colmi R03
+ Colmi R06
Choose export location
General
High-priority
@@ -3203,4 +3207,6 @@
Fetch unknown activity files from the watch. They will not be processed, but will be saved in the phone.
"Cannot upload watchface, too many watchfaces installed"
"Insufficient space for upload"
+ Measurement error. Are the ring\'s sensors oriented correctly?
+ Unknown measurement error %d received from ring
diff --git a/app/src/main/res/xml/devicesettings_colmi_r0x.xml b/app/src/main/res/xml/devicesettings_colmi_r0x.xml
new file mode 100644
index 000000000..dd9de0758
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_colmi_r0x.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+