diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
index 10883f697..3fcec24ef 100644
--- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
+++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
@@ -45,7 +45,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception {
- final Schema schema = new Schema(68, MAIN_PACKAGE + ".entities");
+ final Schema schema = new Schema(70, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes);
@@ -75,6 +75,13 @@ public class GBDaoGenerator {
addXiaomiSleepStageSamples(schema, user, device);
addXiaomiManualSamples(schema, user, device);
addXiaomiDailySummarySamples(schema, user, device);
+ addCmfActivitySample(schema, user, device);
+ addCmfStressSample(schema, user, device);
+ addCmfSpo2Sample(schema, user, device);
+ addCmfSleepSessionSample(schema, user, device);
+ addCmfSleepStageSample(schema, user, device);
+ addCmfHeartRateSample(schema, user, device);
+ addCmfWorkoutGpsSample(schema, user, device);
addPebbleHealthActivitySample(schema, user, device);
addPebbleHealthActivityKindOverlay(schema, user, device);
addPebbleMisfitActivitySample(schema, user, device);
@@ -275,7 +282,7 @@ public class GBDaoGenerator {
private static Entity addHuamiStressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "HuamiStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
- stressSample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
return stressSample;
}
@@ -283,7 +290,7 @@ public class GBDaoGenerator {
private static Entity addHuamiSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "HuamiSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
- spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
return spo2sample;
}
@@ -407,6 +414,64 @@ public class GBDaoGenerator {
return sample;
}
+ private static Entity addCmfActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "CmfActivitySample");
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.implementsSerializable();
+ activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ activitySample.addIntProperty("distance");
+ activitySample.addIntProperty("calories");
+ return activitySample;
+ }
+
+ private static Entity addCmfStressSample(Schema schema, Entity user, Entity device) {
+ Entity stressSample = addEntity(schema, "CmfStressSample");
+ addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
+ stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ return stressSample;
+ }
+
+ private static Entity addCmfSpo2Sample(Schema schema, Entity user, Entity device) {
+ Entity spo2sample = addEntity(schema, "CmfSpo2Sample");
+ addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
+ spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ return spo2sample;
+ }
+
+ private static Entity addCmfSleepSessionSample(Schema schema, Entity user, Entity device) {
+ Entity sleepSessionSample = addEntity(schema, "CmfSleepSessionSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepSessionSample, user, device);
+ sleepSessionSample.addLongProperty("wakeupTime");
+ sleepSessionSample.addByteArrayProperty("metadata");
+ return sleepSessionSample;
+ }
+
+ private static Entity addCmfSleepStageSample(Schema schema, Entity user, Entity device) {
+ Entity sleepStageSample = addEntity(schema, "CmfSleepStageSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device);
+ sleepStageSample.addIntProperty("duration").notNull();
+ sleepStageSample.addIntProperty("stage").notNull();
+ return sleepStageSample;
+ }
+
+ private static Entity addCmfHeartRateSample(Schema schema, Entity user, Entity device) {
+ Entity heartRateSample = addEntity(schema, "CmfHeartRateSample");
+ addCommonTimeSampleProperties("AbstractHeartRateSample", heartRateSample, user, device);
+ heartRateSample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetter(OVERRIDE);
+ return heartRateSample;
+ }
+
+ private static Entity addCmfWorkoutGpsSample(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "CmfWorkoutGpsSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
+ sample.addIntProperty("latitude");
+ sample.addIntProperty("longitude");
+ return sample;
+ }
+
private static void addHeartRateProperties(Entity activitySample) {
activitySample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetterAndSetter(OVERRIDE);
}
@@ -981,7 +1046,7 @@ public class GBDaoGenerator {
private static Entity addWena3StressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "Wena3StressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
- stressSample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
return stressSample;
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java
index 2308662c3..d201c95f3 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java
@@ -18,6 +18,7 @@
package nodomain.freeyourgadget.gadgetbridge.activities;
import android.os.Bundle;
+import android.text.InputFilter;
import android.text.format.DateFormat;
import android.view.MenuItem;
import android.view.View;
@@ -132,10 +133,15 @@ public class AlarmDetails extends AbstractGBActivity {
int snoozeVisibility = supportsSnoozing() ? View.VISIBLE : View.GONE;
cbSnooze.setVisibility(snoozeVisibility);
- int descriptionVisibility = supportsDescription() ? View.VISIBLE : View.GONE;
- title.setVisibility(descriptionVisibility);
+ title.setVisibility(supportsTitle() ? View.VISIBLE : View.GONE);
title.setText(alarm.getTitle());
- description.setVisibility(descriptionVisibility);
+
+ final int titleLimit = getAlarmTitleLimit();
+ if (titleLimit > 0) {
+ title.setFilters(new InputFilter[]{new InputFilter.LengthFilter(titleLimit)});
+ }
+
+ description.setVisibility(supportsDescription() ? View.VISIBLE : View.GONE);
description.setText(alarm.getDescription());
cbMonday.setChecked(alarm.getRepetition(Alarm.ALARM_MON));
@@ -145,7 +151,6 @@ public class AlarmDetails extends AbstractGBActivity {
cbFriday.setChecked(alarm.getRepetition(Alarm.ALARM_FRI));
cbSaturday.setChecked(alarm.getRepetition(Alarm.ALARM_SAT));
cbSunday.setChecked(alarm.getRepetition(Alarm.ALARM_SUN));
-
}
private boolean supportsSmartWakeup() {
@@ -156,6 +161,22 @@ public class AlarmDetails extends AbstractGBActivity {
return false;
}
+ private boolean supportsTitle() {
+ if (device != null) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.supportsAlarmTitle(device);
+ }
+ return false;
+ }
+
+ private int getAlarmTitleLimit() {
+ if (device != null) {
+ DeviceCoordinator coordinator = device.getDeviceCoordinator();
+ return coordinator.getAlarmTitleLimit(device);
+ }
+ return -1;
+ }
+
private boolean supportsDescription() {
if (device != null) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java
index 86feecb11..751e372be 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java
@@ -177,6 +177,7 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_HEARTRATE_MEASUREMENT_INTERVAL = "heartrate_measurement_interval";
public static final String PREF_HEARTRATE_ACTIVITY_MONITORING = "heartrate_activity_monitoring";
public static final String PREF_HEARTRATE_ALERT_ENABLED = "heartrate_alert_enabled";
+ public static final String PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD = "heartrate_alert_active_high_threshold";
public static final String PREF_HEARTRATE_ALERT_HIGH_THRESHOLD = "heartrate_alert_threshold";
public static final String PREF_HEARTRATE_ALERT_LOW_THRESHOLD = "heartrate_alert_low_threshold";
public static final String PREF_HEARTRATE_STRESS_MONITORING = "heartrate_stress_monitoring";
@@ -262,6 +263,9 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_ANTILOST_ENABLED = "pref_antilost_enabled";
public static final String PREF_HYDRATION_SWITCH = "pref_hydration_switch";
public static final String PREF_HYDRATION_PERIOD = "pref_hydration_period";
+ public static final String PREF_HYDRATION_DND = "pref_hydration_dnd";
+ public static final String PREF_HYDRATION_DND_START = "pref_hydration_dnd_start";
+ public static final String PREF_HYDRATION_DND_END = "pref_hydration_dnd_end";
public static final String PREF_AMPM_ENABLED = "pref_ampm_enabled";
public static final String PREF_SONYSWR12_LOW_VIBRATION = "vibration_preference";
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java
index 5bc3f4abc..b7419c8ac 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java
@@ -426,6 +426,9 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
addPreferenceHandlerFor(PREF_ANTILOST_ENABLED);
addPreferenceHandlerFor(PREF_HYDRATION_SWITCH);
addPreferenceHandlerFor(PREF_HYDRATION_PERIOD);
+ addPreferenceHandlerFor(PREF_HYDRATION_DND);
+ addPreferenceHandlerFor(PREF_HYDRATION_DND_START);
+ addPreferenceHandlerFor(PREF_HYDRATION_DND_END);
addPreferenceHandlerFor(PREF_AMPM_ENABLED);
addPreferenceHandlerFor(PREF_SOUNDS);
addPreferenceHandlerFor(PREF_CAMERA_REMOTE);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java
index 17e3124cd..c6029fb6b 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java
@@ -17,6 +17,7 @@
package nodomain.freeyourgadget.gadgetbridge.capabilities;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING;
+import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ENABLED;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD;
@@ -82,20 +83,23 @@ public class HeartRateCapability {
});
}
+ handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD);
handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_LOW_THRESHOLD);
final ListPreference heartrateMeasurementInterval = handler.findPreference(PREF_HEARTRATE_MEASUREMENT_INTERVAL);
+ final ListPreference heartrateAlertActiveHigh = handler.findPreference(PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
final ListPreference heartrateAlertHigh = handler.findPreference(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD);
final ListPreference heartrateAlertLow = handler.findPreference(PREF_HEARTRATE_ALERT_LOW_THRESHOLD);
// Newer devices that have low alert threshold can only use it if measurement interval is smart (-1) or 1 minute
- final boolean hrAlertsNeedSmartOrOne = heartrateAlertHigh != null && heartrateAlertLow != null && heartrateMeasurementInterval != null;
+ final boolean hrAlertsNeedSmartOrOne = heartrateAlertActiveHigh != null && heartrateAlertHigh != null && heartrateAlertLow != null && heartrateMeasurementInterval != null;
if (hrAlertsNeedSmartOrOne) {
final boolean hrMonitoringIsSmartOrOne = heartrateMeasurementInterval.getValue().equals("60") ||
heartrateMeasurementInterval.getValue().equals("-1");
heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne);
heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne);
+ heartrateAlertActiveHigh.setEnabled(hrMonitoringIsSmartOrOne);
}
if (heartrateMeasurementInterval != null) {
@@ -129,6 +133,7 @@ public class HeartRateCapability {
// Same as above, check if smart or 1 minute
final boolean hrMonitoringIsSmartOrOne = newVal.equals("60") || newVal.equals("-1");
+ heartrateAlertActiveHigh.setEnabled(hrMonitoringIsSmartOrOne);
heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne);
heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne);
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java
index ba8399137..e6e8115c3 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java
@@ -436,6 +436,16 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
return false;
}
+ @Override
+ public boolean supportsAlarmTitle(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public int getAlarmTitleLimit(GBDevice device) {
+ return -1;
+ }
+
@Override
public boolean supportsAlarmDescription(GBDevice device) {
return false;
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java
index db5b71116..b0af24587 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java
@@ -343,6 +343,18 @@ public interface DeviceCoordinator {
*/
boolean supportsAlarmSnoozing();
+ /**
+ * Returns true if this device/coordinator supports alarm titles
+ * @return
+ */
+ boolean supportsAlarmTitle(GBDevice device);
+
+ /**
+ * Returns the character limit for the alarm title, negative if no limit.
+ * @return
+ */
+ int getAlarmTitleLimit(GBDevice device);
+
/**
* Returns true if this device/coordinator supports alarm descriptions
* @return
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProCoordinator.java
new file mode 100644
index 000000000..efceba4d7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProCoordinator.java
@@ -0,0 +1,368 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import android.app.Activity;
+import android.bluetooth.le.ScanFilter;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelUuid;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.ArrayUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfActivitySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSpo2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfStressSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.workout.CmfWorkoutSummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepSessionSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfStressSampleDao;
+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.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro.CmfInstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro.CmfWatchProSupport;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class CmfWatchProCoordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("^Watch Pro$");
+ }
+
+ @NonNull
+ @Override
+ public Collection extends ScanFilter> createBLEScanFilters() {
+ final ParcelUuid casioService = new ParcelUuid(CmfWatchProSupport.UUID_SERVICE_CMF_CMD);
+ final ScanFilter filter = new ScanFilter.Builder().setServiceUuid(casioService).build();
+ return Collections.singletonList(filter);
+ }
+
+ @Nullable
+ @Override
+ public InstallHandler findInstallHandler(final Uri uri, final Context context) {
+ final CmfInstallHandler handler = new CmfInstallHandler(uri, context);
+ return handler.isValid() ? handler : null;
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull final GBDevice gbDevice,
+ @NonNull final Device device,
+ @NonNull final DaoSession session) throws GBException {
+ final Long deviceId = device.getId();
+
+ session.getCmfActivitySampleDao().queryBuilder()
+ .where(CmfActivitySampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getCmfStressSampleDao().queryBuilder()
+ .where(CmfStressSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getCmfHeartRateSampleDao().queryBuilder()
+ .where(CmfHeartRateSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getCmfSleepSessionSampleDao().queryBuilder()
+ .where(CmfSleepSessionSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getCmfSleepStageSampleDao().queryBuilder()
+ .where(CmfSleepStageSampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+
+ session.getCmfSpo2SampleDao().queryBuilder()
+ .where(CmfSpo2SampleDao.Properties.DeviceId.eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Nothing";
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_nothing_cmf_watch_pro;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_amazfit_bip;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_amazfit_bip_disabled;
+ }
+
+ @NonNull
+ @Override
+ public Class extends DeviceSupport> getDeviceSupportClass() {
+ return CmfWatchProSupport.class;
+ }
+
+ @Override
+ public int getBondingStyle() {
+ return BONDING_STYLE_REQUIRE_KEY;
+ }
+
+ @Override
+ public boolean validateAuthKey(final String authKey) {
+ final byte[] authKeyBytes = authKey.trim().getBytes();
+ return authKeyBytes.length == 32 || (authKey.startsWith("0x") && authKeyBytes.length == 34);
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificAuthenticationSettings() {
+ return new int[]{R.xml.devicesettings_pairingkey};
+ }
+
+ @Override
+ public SampleProvider extends ActivitySample> getSampleProvider(final GBDevice device, DaoSession session) {
+ return new CmfActivitySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider extends StressSample> getStressSampleProvider(final GBDevice device, final DaoSession session) {
+ return new CmfStressSampleProvider(device, session);
+ }
+
+ @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 TimeSampleProvider extends Spo2Sample> getSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ return new CmfSpo2SampleProvider(device, session);
+ }
+
+ @Nullable
+ @Override
+ public ActivitySummaryParser getActivitySummaryParser(final GBDevice device) {
+ return new CmfWorkoutSummaryParser(device);
+ }
+
+ @Override
+ public boolean supportsFlashing() {
+ return true;
+ }
+
+ @Override
+ public int getAlarmSlotCount(final GBDevice device) {
+ return 5;
+ }
+
+ @Override
+ public boolean supportsAlarmTitle(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int getAlarmTitleLimit(final GBDevice device) {
+ return 8;
+ }
+
+ @Override
+ public boolean supportsAppsManagement(final GBDevice device) {
+ return false; // TODO for watchface management
+ }
+
+ @Override
+ public boolean supportsCachedAppManagement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsInstalledAppManagement(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsWatchfaceManagement(GBDevice device) {
+ return supportsAppsManagement(device);
+ }
+
+ @Override
+ public Class extends Activity> getAppsManagementActivity() {
+ return AppManagerActivity.class;
+ }
+
+ @Override
+ public boolean supportsAppListFetching() {
+ return false; // TODO it does not, but we can fake it for watchfaces
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracks() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSpo2() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsMusicInfo() {
+ return true;
+ }
+
+ @Override
+ public int getContactsSlotCount(final GBDevice device) {
+ return 20;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsRemSleep() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsWeather() {
+ return false; // TODO weather is not implemented
+ }
+
+ @Override
+ public boolean supportsFindDevice() {
+ return true;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(final GBDevice device) {
+ final List settings = new ArrayList<>();
+
+ settings.add(R.xml.devicesettings_header_time);
+ settings.add(R.xml.devicesettings_timeformat);
+
+ settings.add(R.xml.devicesettings_header_display);
+ settings.add(R.xml.devicesettings_workout_activity_types);
+ settings.add(R.xml.devicesettings_liftwrist_display_noshed);
+
+ settings.add(R.xml.devicesettings_header_health);
+ settings.add(R.xml.devicesettings_heartrate_sleep_alert_activity_stress_spo2);
+ settings.add(R.xml.devicesettings_inactivity_dnd);
+ settings.add(R.xml.devicesettings_hydration_reminder_dnd);
+
+ settings.add(R.xml.devicesettings_header_notifications);
+ settings.add(R.xml.devicesettings_send_app_notifications);
+ settings.add(R.xml.devicesettings_transliteration);
+
+ settings.add(R.xml.devicesettings_header_other);
+ if (getContactsSlotCount(device) > 0) {
+ settings.add(R.xml.devicesettings_contacts);
+ }
+
+ return ArrayUtils.toPrimitive(settings.toArray(new Integer[0]));
+ }
+
+ @Override
+ public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) {
+ return new CmfWatchProSettingsCustomizer();
+ }
+
+ @Override
+ public String[] getSupportedLanguageSettings(final GBDevice device) {
+ return null;
+ // FIXME language setting does not seem to work from phone
+ //return new String[]{
+ // "auto",
+ // "ar_SA",
+ // "de_DE",
+ // "en_US",
+ // "es_ES",
+ // "fr_FR",
+ // "hi_IN",
+ // "id_ID",
+ // "it_IT",
+ // "ja_JP",
+ // "ko_KO",
+ // "zh_CN",
+ // "zh_HK",
+ //};
+ }
+
+ @Override
+ public List getHeartRateMeasurementIntervals() {
+ return Arrays.asList(
+ HeartRateCapability.MeasurementInterval.OFF,
+ HeartRateCapability.MeasurementInterval.SMART
+ );
+ }
+
+ protected static Prefs getPrefs(final GBDevice device) {
+ return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProSettingsCustomizer.java
new file mode 100644
index 000000000..c83df700c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProSettingsCustomizer.java
@@ -0,0 +1,82 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import android.os.Parcel;
+
+import androidx.annotation.NonNull;
+import androidx.preference.Preference;
+
+import java.util.Collections;
+import java.util.Set;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class CmfWatchProSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
+ @Override
+ public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) {
+ }
+
+ @Override
+ public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs) {
+ final String[] prefsToHide = new String[]{
+ "pref_key_header_heartrate_sleep",
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION,
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING,
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING,
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_RELAXATION_REMINDER,
+ DeviceSettingsPreferenceConst.PREF_INACTIVITY_START,
+ DeviceSettingsPreferenceConst.PREF_INACTIVITY_END,
+ };
+
+ for (final String prefKey : prefsToHide) {
+ final Preference pref = handler.findPreference(prefKey);
+ if (pref != null) {
+ pref.setVisible(false);
+ }
+ }
+ }
+
+ @Override
+ public Set getPreferenceKeysWithSummary() {
+ return Collections.emptySet();
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public CmfWatchProSettingsCustomizer createFromParcel(final Parcel in) {
+ return new CmfWatchProSettingsCustomizer();
+ }
+
+ @Override
+ public CmfWatchProSettingsCustomizer[] newArray(final int size) {
+ return new CmfWatchProSettingsCustomizer[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull final Parcel dest, final int flags) {
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfActivitySampleProvider.java
new file mode 100644
index 000000000..1dec31d0b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfActivitySampleProvider.java
@@ -0,0 +1,224 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.threeten.bp.LocalDate;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+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.entities.CmfActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class CmfActivitySampleProvider extends AbstractSampleProvider {
+ private static final Logger LOG = LoggerFactory.getLogger(CmfActivitySampleProvider.class);
+
+ public CmfActivitySampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getCmfActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return CmfActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public int normalizeType(final int rawType) {
+ return rawType;
+ }
+
+ @Override
+ public int toRawActivityKind(final int activityKind) {
+ return activityKind;
+ }
+
+ @Override
+ public float normalizeIntensity(final int rawIntensity) {
+ return rawIntensity / 100f;
+ }
+
+ @Override
+ public CmfActivitySample createActivitySample() {
+ return new CmfActivitySample();
+ }
+
+ @Override
+ protected List getGBActivitySamples(final int timestamp_from, final int timestamp_to, final int activityType) {
+ LOG.trace(
+ "Getting cmf activity samples for {} between {} and {}",
+ String.format("0x%08x", activityType),
+ timestamp_from,
+ timestamp_to
+ );
+
+ final long nanoStart = System.nanoTime();
+
+ final List samples = super.getGBActivitySamples(timestamp_from, timestamp_to, activityType);
+
+ if (!samples.isEmpty()) {
+ convertCumulativeSteps(samples);
+ }
+
+ final Map sampleByTs = new HashMap<>();
+ for (final CmfActivitySample sample : samples) {
+ sampleByTs.put(sample.getTimestamp(), sample);
+ }
+
+ overlayHeartRate(sampleByTs, timestamp_from, timestamp_to);
+ overlaySleep(sampleByTs, timestamp_from, timestamp_to);
+
+ 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 cmf samples took {}ms", executionTime);
+
+ return finalSamples;
+ }
+
+ private void convertCumulativeSteps(final List samples) {
+ final Calendar cal = Calendar.getInstance();
+
+ // Steps on the Cmf Watch are reported cumulatively per day - convert them to
+ // This slightly breaks activity recognition, because we don't have per-minute granularity...
+ int prevSteps = samples.get(0).getSteps();
+ samples.get(0).setTimestamp((int) (samples.get(0).getTimestamp() / 60) * 60);
+
+ for (int i = 1; i < samples.size(); i++) {
+ final CmfActivitySample s1 = samples.get(i - 1);
+ final CmfActivitySample s2 = samples.get(i);
+ s2.setTimestamp((int) (s2.getTimestamp() / 60) * 60);
+
+ cal.setTimeInMillis(s1.getTimestamp() * 1000L - 1000L);
+ final LocalDate d1 = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH));
+ cal.setTimeInMillis(s2.getTimestamp() * 1000L - 1000L);
+ final LocalDate d2 = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH));
+
+ if (d1.equals(d2)) {
+ int bak = s2.getSteps();
+ s2.setSteps(s2.getSteps() - prevSteps);
+ prevSteps = bak;
+ }
+ }
+ }
+
+ private void overlayHeartRate(final Map sampleByTs, final int timestamp_from, final int timestamp_to) {
+ final CmfHeartRateSampleProvider heartRateSampleProvider = new CmfHeartRateSampleProvider(getDevice(), getSession());
+ final List hrSamples = heartRateSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L);
+
+ for (final CmfHeartRateSample hrSample : hrSamples) {
+ // round to the nearest minute, we don't need per-second granularity
+ final int tsSeconds = (int) ((hrSample.getTimestamp() / 1000) / 60) * 60;
+ CmfActivitySample sample = sampleByTs.get(tsSeconds);
+ if (sample == null) {
+ //LOG.debug("Adding dummy sample at {} for hr", tsSeconds);
+ sample = new CmfActivitySample();
+ 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 CmfSleepStageSampleProvider sleepStageSampleProvider = new CmfSleepStageSampleProvider(getDevice(), getSession());
+ final List sleepStageSamples = sleepStageSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L);
+
+ for (final CmfSleepStageSample sleepStageSample : sleepStageSamples) {
+ // 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(); i += 60) {
+ CmfActivitySample sample = sampleByTs.get(i);
+ if (sample == null) {
+ //LOG.debug("Adding dummy sample at {} for sleep", i);
+ sample = new CmfActivitySample();
+ sample.setTimestamp(i);
+ sample.setProvider(this);
+ sampleByTs.put(i, sample);
+ }
+
+ final int sleepRawKind = sleepStageToActivityKind(sleepStageSample.getStage());
+ sample.setRawKind(sleepRawKind);
+
+ switch (sleepRawKind) {
+ case ActivityKind.TYPE_DEEP_SLEEP:
+ sample.setRawIntensity(20);
+ break;
+ case ActivityKind.TYPE_LIGHT_SLEEP:
+ sample.setRawIntensity(30);
+ break;
+ case ActivityKind.TYPE_REM_SLEEP:
+ sample.setRawIntensity(40);
+ break;
+ }
+ }
+ }
+ }
+
+ final int sleepStageToActivityKind(final int sleepStage) {
+ switch (sleepStage) {
+ case 1:
+ return ActivityKind.TYPE_DEEP_SLEEP;
+ case 2:
+ return ActivityKind.TYPE_LIGHT_SLEEP;
+ case 3:
+ return ActivityKind.TYPE_REM_SLEEP;
+ default:
+ return ActivityKind.TYPE_UNKNOWN;
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfHeartRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfHeartRateSampleProvider.java
new file mode 100644
index 000000000..268e44ddb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfHeartRateSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro.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.CmfHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfHeartRateSampleProvider extends AbstractTimeSampleProvider {
+ public CmfHeartRateSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getCmfHeartRateSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfHeartRateSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfHeartRateSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfHeartRateSample createSample() {
+ return new CmfHeartRateSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepSessionSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepSessionSampleProvider.java
new file mode 100644
index 000000000..b907137f6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepSessionSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro.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.CmfSleepSessionSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepSessionSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfSleepSessionSampleProvider extends AbstractTimeSampleProvider {
+ public CmfSleepSessionSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getCmfSleepSessionSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfSleepSessionSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfSleepSessionSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfSleepSessionSample createSample() {
+ return new CmfSleepSessionSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepStageSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepStageSampleProvider.java
new file mode 100644
index 000000000..c78a1b519
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepStageSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro.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.CmfSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfSleepStageSampleProvider extends AbstractTimeSampleProvider {
+ public CmfSleepStageSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getCmfSleepStageSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfSleepStageSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfSleepStageSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfSleepStageSample createSample() {
+ return new CmfSleepStageSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSpo2SampleProvider.java
new file mode 100644
index 000000000..860c50eef
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSpo2SampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro.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.CmfSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfSpo2SampleProvider extends AbstractTimeSampleProvider {
+ public CmfSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getCmfSpo2SampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfSpo2SampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfSpo2SampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfSpo2Sample createSample() {
+ return new CmfSpo2Sample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfStressSampleProvider.java
new file mode 100644
index 000000000..3e1a94a20
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfStressSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro.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.CmfStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfStressSampleProvider extends AbstractTimeSampleProvider {
+ public CmfStressSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getCmfStressSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfStressSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfStressSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfStressSample createSample() {
+ return new CmfStressSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfWorkoutGpsSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfWorkoutGpsSampleProvider.java
new file mode 100644
index 000000000..680906e51
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfWorkoutGpsSampleProvider.java
@@ -0,0 +1,56 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro.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.CmfWorkoutGpsSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfWorkoutGpsSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class CmfWorkoutGpsSampleProvider extends AbstractTimeSampleProvider {
+ public CmfWorkoutGpsSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getCmfWorkoutGpsSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return CmfWorkoutGpsSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return CmfWorkoutGpsSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public CmfWorkoutGpsSample createSample() {
+ return new CmfWorkoutGpsSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/workout/CmfWorkoutSummaryParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/workout/CmfWorkoutSummaryParser.java
new file mode 100644
index 000000000..ae4ff6e0a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/workout/CmfWorkoutSummaryParser.java
@@ -0,0 +1,95 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro.workout;
+
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ACTIVE_SECONDS;
+import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Date;
+
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro.CmfActivityType;
+
+public class CmfWorkoutSummaryParser implements ActivitySummaryParser {
+ private final GBDevice gbDevice;
+
+ public CmfWorkoutSummaryParser(final GBDevice device) {
+ this.gbDevice = device;
+ }
+
+ @Override
+ public BaseActivitySummary parseBinaryData(final BaseActivitySummary summary) {
+ final JSONObject summaryData = new JSONObject();
+
+ final ByteBuffer buf = ByteBuffer.wrap(summary.getRawSummaryData()).order(ByteOrder.LITTLE_ENDIAN);
+
+ final int startTime = buf.getInt();
+ final int duration = buf.getShort();
+ final byte workoutType = buf.get();
+
+ buf.get(new byte[19]); // ?
+ final int endTime = buf.getInt();
+ final boolean gps = buf.get() == 1;
+ buf.get(); // ?
+
+ summary.setStartTime(new Date(startTime * 1000L));
+ summary.setEndTime(new Date(endTime * 1000L));
+
+ final CmfActivityType cmfActivityType = CmfActivityType.fromCode(workoutType);
+ if (cmfActivityType != null) {
+ summary.setActivityKind(cmfActivityType.getActivityKind());
+ } else {
+ summary.setActivityKind(ActivityKind.TYPE_UNKNOWN);
+ }
+
+ addSummaryData(summaryData, ACTIVE_SECONDS, duration, UNIT_SECONDS);
+
+ return summary;
+ }
+
+ protected void addSummaryData(final JSONObject summaryData, final String key, final float value, final String unit) {
+ if (value > 0) {
+ try {
+ final JSONObject innerData = new JSONObject();
+ innerData.put("value", value);
+ innerData.put("unit", unit);
+ summaryData.put(key, innerData);
+ } catch (final JSONException ignore) {
+ }
+ }
+ }
+
+ protected void addSummaryData(final JSONObject summaryData, final String key, final String value) {
+ if (key != null && !key.equals("") && value != null && !value.equals("")) {
+ try {
+ final JSONObject innerData = new JSONObject();
+ innerData.put("value", value);
+ innerData.put("unit", "string");
+ summaryData.put(key, innerData);
+ } catch (final JSONException ignore) {
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java
index d992b5a79..57015cfd4 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java
@@ -28,6 +28,7 @@ import java.util.Locale;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
@@ -51,6 +52,11 @@ public class HuamiSettingsCustomizer implements DeviceSpecificSettingsCustomizer
@Override
public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs) {
+ final Preference hrAlertActivePref = handler.findPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
+ if (hrAlertActivePref != null) {
+ hrAlertActivePref.setVisible(false);
+ }
+
// Setup the vibration patterns for all supported notification types
for (HuamiVibrationPatternNotificationType notificationType : HuamiVibrationPatternNotificationType.values()) {
final String typeKey = notificationType.name().toLowerCase(Locale.ROOT);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java
index 41c18384b..228645812 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java
@@ -126,6 +126,11 @@ public abstract class HuaweiBRCoordinator extends AbstractBLClassicDeviceCoordin
return false;
}
+ @Override
+ public boolean supportsAlarmTitle(GBDevice device) {
+ return true;
+ }
+
@Override
public boolean supportsAlarmDescription(GBDevice device) {
// TODO: only name is supported
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java
index bd404efae..45421af7a 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java
@@ -126,6 +126,11 @@ public abstract class HuaweiLECoordinator extends AbstractBLEDeviceCoordinator i
return false;
}
+ @Override
+ public boolean supportsAlarmTitle(GBDevice device) {
+ return true;
+ }
+
@Override
public boolean supportsAlarmDescription(GBDevice device) {
// TODO: only name is supported
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java
index d383e1fce..a6a5d091f 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java
@@ -180,11 +180,6 @@ public class HuaweiSpo2SampleProvider extends AbstractTimeSampleProvider. */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro;
+
+import android.content.Context;
+import android.widget.Toast;
+
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfActivitySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfHeartRateSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSleepSessionSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSleepStageSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSpo2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfStressSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfWorkoutGpsSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.workout.CmfWorkoutSummaryParser;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepSessionSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.CmfWorkoutGpsSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.export.ActivityTrackExporter;
+import nodomain.freeyourgadget.gadgetbridge.export.GPXExporter;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack;
+import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class CmfActivitySync {
+ private static final Logger LOG = LoggerFactory.getLogger(CmfActivitySync.class);
+
+ private final CmfWatchProSupport mSupport;
+
+ private final List activitiesWithGps = new ArrayList<>();
+
+ protected CmfActivitySync(final CmfWatchProSupport support) {
+ this.mSupport = support;
+ }
+
+ protected boolean onCommand(final CmfCommand cmd, final byte[] payload) {
+ switch (cmd) {
+ case ACTIVITY_FETCH_ACK_1:
+ handleActivityFetchAck1(payload);
+ return true;
+ case ACTIVITY_FETCH_ACK_2:
+ handleActivityFetchAck2(payload);
+ return true;
+ case ACTIVITY_DATA:
+ handleActivityData(payload);
+ return true;
+ case HEART_RATE_MANUAL_AUTO:
+ case HEART_RATE_WORKOUT:
+ handleHeartRate(payload);
+ return true;
+ case HEART_RATE_RESTING:
+ handleHeartRateResting(payload);
+ return true;
+ case SLEEP_DATA:
+ handleSleepData(payload);
+ return true;
+ case STRESS:
+ handleStress(payload);
+ return true;
+ case SPO2:
+ handleSpo2(payload);
+ return true;
+ case WORKOUT_SUMMARY:
+ handleWorkoutSummary(payload);
+ return true;
+ case WORKOUT_GPS:
+ handleWorkoutGps(payload);
+ return true;
+ }
+
+ return false;
+ }
+
+ private void handleActivityFetchAck1(final byte[] payload) {
+ switch (payload[0]) {
+ case 0x01:
+ LOG.debug("Got activity fetch ack 1, starting step 2");
+ GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext());
+ getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_activity_data));
+ mSupport.sendCommand("fetch recorded data step 2", CmfCommand.ACTIVITY_FETCH_2, CmfWatchProSupport.A5);
+ break;
+ case 0x02:
+ LOG.debug("Got activity fetch finish");
+ // Process activities with GPS before unsetting device as busy
+ processActivitiesWithGps();
+ break;
+ default:
+ LOG.warn("Unknown activity fetch ack code {}", payload[0]);
+ return;
+ }
+
+ getDevice().sendDeviceUpdateIntent(getContext());
+ }
+
+ private static void handleActivityFetchAck2(final byte[] payload) {
+ final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
+
+ final int activityTs = buf.getInt();
+ final byte[] activityFlags = new byte[4]; // TODO what do they mean?
+ buf.order(ByteOrder.BIG_ENDIAN).get(activityFlags);
+ LOG.debug("Getting activity since {}, flags={}", activityTs, GB.hexdump(activityFlags));
+ }
+
+ private void handleActivityData(final byte[] payload) {
+ if (payload.length % 32 != 0) {
+ LOG.error("Activity data payload size {} not divisible by 32", payload.length);
+ return;
+ }
+
+ LOG.debug("Got {} activity samples", payload.length / 32);
+
+ final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
+
+ final List samples = new ArrayList<>();
+
+ while (buf.remaining() > 0) {
+ final CmfActivitySample sample = new CmfActivitySample();
+ sample.setTimestamp(buf.getInt());
+ sample.setSteps(buf.getInt());
+ sample.setDistance(buf.getInt());
+ sample.setCalories(buf.getInt());
+
+ final byte[] unk = new byte[16];
+ buf.get(unk);
+
+ samples.add(sample);
+ }
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(getDevice(), session);
+ final User user = DBHelper.getUser(session);
+
+ final CmfActivitySampleProvider sampleProvider = new CmfActivitySampleProvider(getDevice(), session);
+
+ for (final CmfActivitySample sample : samples) {
+ sample.setDevice(device);
+ sample.setUser(user);
+ sample.setProvider(sampleProvider);
+ }
+
+ LOG.debug("Will persist {} activity samples", samples.size());
+ sampleProvider.addGBActivitySamples(samples.toArray(new CmfActivitySample[0]));
+ } catch (final Exception e) {
+ GB.toast(getContext(), "Error saving activity samples", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private void handleHeartRate(final byte[] payload) {
+ if (payload.length % 8 != 0) {
+ LOG.error("Heart rate payload size {} not divisible by 8", payload.length);
+ return;
+ }
+
+ LOG.debug("Got {} heart rate samples", payload.length / 8);
+
+ final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
+
+ final List samples = new ArrayList<>();
+
+ while (buf.remaining() > 0) {
+ final CmfHeartRateSample sample = new CmfHeartRateSample();
+ sample.setTimestamp(buf.getInt() * 1000L);
+ sample.setHeartRate(buf.getInt());
+
+ samples.add(sample);
+ }
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(getDevice(), session);
+ final User user = DBHelper.getUser(session);
+
+ final CmfHeartRateSampleProvider sampleProvider = new CmfHeartRateSampleProvider(getDevice(), session);
+
+ for (final CmfHeartRateSample sample : samples) {
+ sample.setDevice(device);
+ sample.setUser(user);
+ }
+
+ LOG.debug("Will persist {} heart rate samples", samples.size());
+ sampleProvider.addSamples(samples);
+ } catch (final Exception e) {
+ GB.toast(getContext(), "Error saving heart rate samples", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private static void handleHeartRateResting(final byte[] payload) {
+ // TODO persist resting HR samples;
+ LOG.warn("Persisting resting HR samples is not implemented");
+ }
+
+ private void handleSleepData(final byte[] payload) {
+ final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
+
+ LOG.debug("Got sleep data samples");
+
+ final int sessionTimestamp = buf.getInt();
+ final int wakeupTime = buf.getInt();
+ final byte[] metadata = new byte[10];
+ buf.get(metadata);
+
+ final CmfSleepSessionSample sessionSample = new CmfSleepSessionSample();
+ sessionSample.setTimestamp(sessionTimestamp * 1000L);
+ sessionSample.setWakeupTime(wakeupTime * 1000L);
+ sessionSample.setMetadata(metadata);
+
+ final List stageSamples = new ArrayList<>();
+
+ while (buf.remaining() > 0) {
+ final CmfSleepStageSample sample = new CmfSleepStageSample();
+ sample.setTimestamp(buf.getInt() * 1000L);
+ sample.setDuration(buf.getShort());
+ sample.setStage(buf.getShort());
+ stageSamples.add(sample);
+ }
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(getDevice(), session);
+ final User user = DBHelper.getUser(session);
+
+ final CmfSleepSessionSampleProvider sampleProvider = new CmfSleepSessionSampleProvider(getDevice(), 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(getContext(), "Error saving sleep session sample", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(getDevice(), session);
+ final User user = DBHelper.getUser(session);
+
+ final CmfSleepStageSampleProvider sampleProvider = new CmfSleepStageSampleProvider(getDevice(), session);
+
+ for (final CmfSleepStageSample 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(getContext(), "Error saving sleep samples", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private void handleStress(final byte[] payload) {
+ if (payload.length % 8 != 0) {
+ LOG.error("Stress payload size {} not divisible by 8", payload.length);
+ return;
+ }
+
+ LOG.debug("Got {} stress samples", payload.length / 8);
+
+ final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
+
+ final List samples = new ArrayList<>();
+
+ while (buf.remaining() > 0) {
+ final CmfStressSample sample = new CmfStressSample();
+ sample.setTimestamp(buf.getInt() * 1000L);
+ sample.setStress(buf.getInt());
+
+ samples.add(sample);
+ }
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(getDevice(), session);
+ final User user = DBHelper.getUser(session);
+
+ final CmfStressSampleProvider sampleProvider = new CmfStressSampleProvider(getDevice(), session);
+
+ for (final CmfStressSample sample : samples) {
+ sample.setDevice(device);
+ sample.setUser(user);
+ }
+
+ LOG.debug("Will persist {} stress samples", samples.size());
+ sampleProvider.addSamples(samples);
+ } catch (final Exception e) {
+ GB.toast(getContext(), "Error saving stress samples", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private void handleSpo2(final byte[] payload) {
+ if (payload.length % 8 != 0) {
+ LOG.error("Spo2 payload size {} not divisible by 8", payload.length);
+ return;
+ }
+
+ LOG.debug("Got {} spo2 samples", payload.length / 8);
+
+ final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
+
+ final List samples = new ArrayList<>();
+
+ while (buf.remaining() > 0) {
+ final CmfSpo2Sample sample = new CmfSpo2Sample();
+ sample.setTimestamp(buf.getInt() * 1000L);
+ sample.setSpo2(buf.getInt());
+
+ samples.add(sample);
+ }
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(getDevice(), session);
+ final User user = DBHelper.getUser(session);
+
+ final CmfSpo2SampleProvider sampleProvider = new CmfSpo2SampleProvider(getDevice(), session);
+
+ for (final CmfSpo2Sample sample : samples) {
+ sample.setDevice(device);
+ sample.setUser(user);
+ }
+
+ LOG.debug("Will persist {} spo2 samples", samples.size());
+ sampleProvider.addSamples(samples);
+ } catch (final Exception e) {
+ GB.toast(getContext(), "Error saving spo2 samples", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private void handleWorkoutSummary(final byte[] payload) {
+ if (payload.length % 32 != 0) {
+ LOG.error("Workout summary payload size {} not divisible by 32", payload.length);
+ return;
+ }
+
+ LOG.debug("Got {} workout summary samples", payload.length / 32);
+
+ final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
+
+ final CmfWorkoutSummaryParser summaryParser = new CmfWorkoutSummaryParser(getDevice());
+
+ while (buf.remaining() > 0) {
+ final byte[] summaryBytes = new byte[32];
+ buf.get(summaryBytes);
+
+ BaseActivitySummary summary = new BaseActivitySummary();
+ summary.setRawSummaryData(summaryBytes);
+ summary.setActivityKind(ActivityKind.TYPE_UNKNOWN);
+
+ try {
+ summary = summaryParser.parseBinaryData(summary);
+ } catch (final Exception e) {
+ LOG.error("Failed to parse workout summary", e);
+ GB.toast(getContext(), "Failed to parse workout summary", Toast.LENGTH_LONG, GB.ERROR, e);
+ return;
+ }
+
+ if (summary == null) {
+ LOG.error("Workout summary is null");
+ return;
+ }
+
+ summary.setSummaryData(null); // remove json before saving to database
+
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ final DaoSession session = dbHandler.getDaoSession();
+ final Device device = DBHelper.getDevice(getDevice(), session);
+ final User user = DBHelper.getUser(session);
+
+ summary.setDevice(device);
+ summary.setUser(user);
+
+ LOG.debug("Persisting workout summary for {}", summary.getStartTime());
+
+ session.getBaseActivitySummaryDao().insertOrReplace(summary);
+ } catch (final Exception e) {
+ GB.toast(getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, e);
+ return;
+ }
+
+ // Previous to last byte indicates if it has gps
+ if (summaryBytes[summaryBytes.length - 2] == 1) {
+ activitiesWithGps.add(summary);
+ }
+ }
+ }
+
+ private void handleWorkoutGps(final byte[] payload) {
+ if (payload.length % 12 != 0) {
+ LOG.error("Workout gps payload size {} not divisible by 12", payload.length);
+ return;
+ }
+
+ LOG.debug("Got {} workout gps samples", payload.length / 12);
+
+ final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN);
+
+ final List samples = new ArrayList<>();
+
+ while (buf.remaining() > 0) {
+ final CmfWorkoutGpsSample sample = new CmfWorkoutGpsSample();
+ sample.setTimestamp(buf.getInt() * 1000L);
+ sample.setLongitude(buf.getInt());
+ sample.setLatitude(buf.getInt());
+
+ samples.add(sample);
+ }
+
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final Device device = DBHelper.getDevice(getDevice(), session);
+ final User user = DBHelper.getUser(session);
+
+ final CmfWorkoutGpsSampleProvider sampleProvider = new CmfWorkoutGpsSampleProvider(getDevice(), session);
+
+ for (final CmfWorkoutGpsSample sample : samples) {
+ sample.setDevice(device);
+ sample.setUser(user);
+ }
+
+ LOG.debug("Will persist {} workout gps samples", samples.size());
+ sampleProvider.addSamples(samples);
+ } catch (final Exception e) {
+ GB.toast(getContext(), "Error saving workout gps samples", Toast.LENGTH_LONG, GB.ERROR, e);
+ }
+ }
+
+ private void processActivitiesWithGps() {
+ LOG.debug("There are {} activities with gps to process", activitiesWithGps.size());
+
+ for (final BaseActivitySummary summary : activitiesWithGps) {
+ processGps(summary);
+ }
+
+ activitiesWithGps.clear();
+
+ getDevice().unsetBusyTask();
+ GB.updateTransferNotification(null, "", false, 100, getContext());
+ }
+
+ private void processGps(final BaseActivitySummary summary) {
+ final ActivityTrack activityTrack = buildActivityTrack(summary);
+ if (activityTrack == null) {
+ return;
+ }
+
+ // Save the gpx file
+ final File gpxFile = exportGpx(summary, activityTrack);
+ if (gpxFile == null) {
+ return;
+ }
+
+ // Update the summary in the db with the gpx path
+ try (DBHandler dbHandler = GBApplication.acquireDB()) {
+ final DaoSession session = dbHandler.getDaoSession();
+ final Device device = DBHelper.getDevice(mSupport.getDevice(), session);
+ final User user = DBHelper.getUser(session);
+
+ final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao();
+ final QueryBuilder qb = summaryDao.queryBuilder();
+ qb.where(BaseActivitySummaryDao.Properties.StartTime.eq(summary.getStartTime()));
+ qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(device.getId()));
+ qb.where(BaseActivitySummaryDao.Properties.UserId.eq(user.getId()));
+ final List summaries = qb.build().list();
+
+ if (summaries.isEmpty()) {
+ LOG.warn("Failed to find existing summary in db - this should never happen");
+ return;
+ }
+ if (summaries.size() > 1) {
+ LOG.warn("Found multiple summaries in db - this should never happen");
+ }
+
+ final BaseActivitySummary summaryToUpdate = summaries.get(0);
+ summaryToUpdate.setGpxTrack(gpxFile.getAbsolutePath());
+ session.getBaseActivitySummaryDao().insertOrReplace(summaryToUpdate);
+ } catch (final Exception e) {
+ LOG.error("Failed to update summary with gpx path", e);
+ }
+ }
+
+ @Nullable
+ private File exportGpx(final BaseActivitySummary summary, final ActivityTrack activityTrack) {
+ final GPXExporter exporter = new GPXExporter();
+ exporter.setCreator(GBApplication.app().getNameAndVersion());
+
+ final String gpxFileName = FileUtils.makeValidFileName("gadgetbridge-" + DateTimeUtils.formatIso8601(summary.getStartTime()) + ".gpx");
+ final File gpxTargetFile;
+ try {
+ gpxTargetFile = new File(FileUtils.getExternalFilesDir(), gpxFileName);
+ } catch (final IOException e) {
+ LOG.error("Failed to get external files dir", e);
+ return null;
+ }
+
+ try {
+ exporter.performExport(activityTrack, gpxTargetFile);
+ } catch (final ActivityTrackExporter.GPXTrackEmptyException e) {
+ LOG.warn("Gpx is empty");
+ return null;
+ } catch (IOException e) {
+ LOG.error("Failed to write gpx", e);
+ return null;
+ }
+
+ return gpxTargetFile;
+ }
+
+ @Nullable
+ private ActivityTrack buildActivityTrack(final BaseActivitySummary summary) {
+ final ActivityTrack track = new ActivityTrack();
+ track.setUser(summary.getUser());
+ track.setDevice(summary.getDevice());
+ track.setName(createActivityName(summary));
+
+ final List gpsSamples;
+ final List hrSamples;
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ final DaoSession session = handler.getDaoSession();
+
+ final CmfWorkoutGpsSampleProvider gpsSampleProvider = new CmfWorkoutGpsSampleProvider(getDevice(), session);
+ gpsSamples = gpsSampleProvider.getAllSamples(summary.getStartTime().getTime(), summary.getEndTime().getTime());
+
+ final CmfHeartRateSampleProvider hrSampleProvider = new CmfHeartRateSampleProvider(getDevice(), session);
+ hrSamples = new ArrayList<>(hrSampleProvider.getAllSamples(summary.getStartTime().getTime(), summary.getEndTime().getTime()));
+ } catch (final Exception e) {
+ LOG.error("Error while building activity track", e);
+ return null;
+ }
+
+ Collections.sort(hrSamples, (a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp()));
+
+ for (final CmfWorkoutGpsSample gpsSample : gpsSamples) {
+ final ActivityPoint ap = new ActivityPoint(new Date(gpsSample.getTimestamp()));
+ final GPSCoordinate coordinate = new GPSCoordinate(
+ gpsSample.getLongitude() / 10000000d,
+ gpsSample.getLatitude() / 10000000d,
+ -20000
+ );
+ ap.setLocation(coordinate);
+
+ final CmfHeartRateSample hrSample = findNearestSample(hrSamples, gpsSample.getTimestamp());
+ if (hrSample != null) {
+ ap.setHeartRate(hrSample.getHeartRate());
+ }
+
+ track.addTrackPoint(ap);
+ }
+
+ return track;
+ }
+
+ @Nullable
+ private CmfHeartRateSample findNearestSample(final List samples, final long timestamp) {
+ if (samples.isEmpty()) {
+ return null;
+ }
+
+ if (timestamp < samples.get(0).getTimestamp()) {
+ return samples.get(0);
+ }
+
+ if (timestamp > samples.get(samples.size() - 1).getTimestamp()) {
+ return samples.get(samples.size() - 1);
+ }
+
+ int start = 0;
+ int end = samples.size() - 1;
+
+ while (start <= end) {
+ final int mid = (start + end) / 2;
+
+ if (timestamp < samples.get(mid).getTimestamp()) {
+ end = mid - 1;
+ } else if (timestamp > samples.get(mid).getTimestamp()) {
+ start = mid + 1;
+ } else {
+ return samples.get(mid);
+ }
+ }
+
+ // FIXME return null if too far?
+
+ if (samples.get(start).getTimestamp() - timestamp < timestamp - samples.get(end).getTimestamp()) {
+ return samples.get(start);
+ }
+
+ return samples.get(end);
+ }
+
+ protected static String createActivityName(final BaseActivitySummary summary) {
+ String name = summary.getName();
+ String nameText = "";
+ Long id = summary.getId();
+ if (name != null) {
+ nameText = name + " - ";
+ }
+ return nameText + id;
+ }
+
+ private Context getContext() {
+ return mSupport.getContext();
+ }
+
+ private GBDevice getDevice() {
+ return mSupport.getDevice();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivityType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivityType.java
new file mode 100644
index 000000000..56d5d0782
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivityType.java
@@ -0,0 +1,184 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public enum CmfActivityType {
+ // Core (non-removable in official app)
+ INDOOR_RUNNING(0x03, R.string.activity_type_indoor_running, ActivityKind.TYPE_RUNNING),
+ OUTDOOR_RUNNING(0x02, R.string.activity_type_outdoor_running, ActivityKind.TYPE_RUNNING),
+ // Fitness
+ OUTDOOR_WALKING(0x01, R.string.activity_type_outdoor_walking, ActivityKind.TYPE_WALKING),
+ INDOOR_WALKING(0x19, R.string.activity_type_indoor_walking, ActivityKind.TYPE_WALKING),
+ OUTDOOR_CYCLING(0x05, R.string.activity_type_outdoor_cycling, ActivityKind.TYPE_CYCLING),
+ INDOOR_CYCLING(0x72, R.string.activity_type_indoor_cycling, ActivityKind.TYPE_INDOOR_CYCLING),
+ MOUNTAIN_HIKE(0x04, R.string.activity_type_mountain_hike, ActivityKind.TYPE_HIKING),
+ HIKING(0x1A, R.string.activity_type_hiking, ActivityKind.TYPE_HIKING),
+ CROSS_TRAINER(0x18, R.string.activity_type_cross_trainer),
+ FREE_TRAINING(0x10, R.string.activity_type_free_training, ActivityKind.TYPE_STRENGTH_TRAINING),
+ STRENGTH_TRAINING(0x13, R.string.activity_type_strength_training, ActivityKind.TYPE_STRENGTH_TRAINING),
+ YOGA(0x0F, R.string.activity_type_yoga, ActivityKind.TYPE_YOGA),
+ BOXING(0x21, R.string.activity_type_boxing),
+ ROWER(0x0E, R.string.activity_type_rower, ActivityKind.TYPE_ROWING_MACHINE),
+ DYNAMIC_CYCLE(0x0D, R.string.activity_type_dynamic_cycle),
+ STAIR_STEPPER(0x73, R.string.activity_type_stair_stepper),
+ TREADMILL(0x26, R.string.activity_type_treadmill, ActivityKind.TYPE_TREADMILL),
+ HIIT(0x5C, R.string.activity_type_hiit),
+ FITNESS_EXERCISES(0x4E, R.string.activity_type_fitness_exercises),
+ JUMP_ROPING(0x06, R.string.activity_type_jump_roping, ActivityKind.TYPE_JUMP_ROPING),
+ PILATES(0x2C, R.string.activity_type_pilates),
+ CROSSFIT(0x74, R.string.activity_type_crossfit),
+ FUNCTIONAL_TRAINING(0x2E, R.string.activity_type_functional_training),
+ PHYSICAL_TRAINING(0x2F, R.string.activity_type_physical_training),
+ TAEKWONDO(0x25, R.string.activity_type_taekwondo),
+ CROSS_COUNTRY_RUNNING(0x1B, R.string.activity_type_cross_country_running),
+ KARATE(0x29, R.string.activity_type_karate),
+ FENCING(0x54, R.string.activity_type_fencing),
+ CORE_TRAINING(0x4B, R.string.activity_type_core_training),
+ KENDO(0x75, R.string.activity_type_kendo),
+ HORIZONTAL_BAR(0x56, R.string.activity_type_horizontal_bar),
+ PARALLEL_BAR(0x57, R.string.activity_type_parallel_bar),
+ COOLDOWN(0x92, R.string.activity_type_cooldown),
+ CROSS_TRAINING(0x2B, R.string.activity_type_cross_training),
+ SIT_UPS(0x11, R.string.activity_type_sit_ups),
+ FITNESS_GAMING(0x4D, R.string.activity_type_fitness_gaming),
+ AEROBIC_EXERCISE(0x94, R.string.activity_type_aerobic_exercise),
+ ROLLING(0x95, R.string.activity_type_rolling),
+ FLEXIBILITY(0x31, R.string.activity_type_flexibility),
+ GYMNASTICS(0x23, R.string.activity_type_gymnastics),
+ TRACK_AND_FIELD(0x27, R.string.activity_type_track_and_field),
+ PUSH_UPS(0x67, R.string.activity_type_push_ups),
+ BATTLE_ROPE(0x99, R.string.activity_type_battle_rope),
+ SMITH_MACHINE(0x9A, R.string.activity_type_smith_machine),
+ PULL_UPS(0x66, R.string.activity_type_pull_ups),
+ PLANK(0x68, R.string.activity_type_plank),
+ JAVELIN(0x9E, R.string.activity_type_javelin),
+ LONG_JUMP(0x6C, R.string.activity_type_long_jump),
+ HIGH_JUMP(0x6A, R.string.activity_type_high_jump),
+ TRAMPOLINE(0x5F, R.string.activity_type_trampoline),
+ DUMBBELL(0x9F, R.string.activity_type_dumbbell),
+ // Dance
+ BELLY_DANCE(0x76, R.string.activity_type_belly_dance),
+ JAZZ_DANCE(0x77, R.string.activity_type_jazz_dance),
+ LATIN_DANCE(0x33, R.string.activity_type_latin_dance),
+ BALLET(0x36, R.string.activity_type_ballet),
+ STREET_DANCE(0x34, R.string.activity_type_street_dance),
+ ZUMBA(0x9B, R.string.activity_type_zumba),
+ OTHER_DANCE(0x78, R.string.activity_type_other_dance),
+ // Leisure sports
+ ROLLER_SKATING(0x58, R.string.activity_type_roller_skating),
+ MARTIAL_ARTS(0x38, R.string.activity_type_martial_arts),
+ TAI_CHI(0x1F, R.string.activity_type_tai_chi),
+ HULA_HOOPING(0x59, R.string.activity_type_hula_hooping),
+ DISC_SPORTS(0x43, R.string.activity_type_disc_sports),
+ DARTS(0x5A, R.string.activity_type_darts),
+ ARCHERY(0x30, R.string.activity_type_archery),
+ HORSE_RIDING(0x1D, R.string.activity_type_horse_riding),
+ KITE_FLYING(0x70, R.string.activity_type_kite_flying),
+ SWING(0x71, R.string.activity_type_swing),
+ STAIRS(0x15, R.string.activity_type_stairs),
+ FISHING(0x42, R.string.activity_type_fishing),
+ HAND_CYCLING(0x96, R.string.activity_type_hand_cycling),
+ MIND_AND_BODY(0x97, R.string.activity_type_mind_and_body),
+ WRESTLING(0x53, R.string.activity_type_wrestling),
+ KABADDI(0x9C, R.string.activity_type_kabaddi),
+ KARTING(0xA0, R.string.activity_type_karting),
+ // Ball sports
+ BADMINTON(0x09, R.string.activity_type_badminton),
+ TABLE_TENNIS(0x0A, R.string.activity_type_table_tennis, ActivityKind.TYPE_PINGPONG),
+ TENNIS(0x0C, R.string.activity_type_tennis),
+ BILLIARDS(0x7C, R.string.activity_type_billiards),
+ BOWLING(0x3B, R.string.activity_type_bowling),
+ VOLLEYBALL(0x49, R.string.activity_type_volleyball),
+ SHUTTLECOCK(0x20, R.string.activity_type_shuttlecock),
+ HANDBALL(0x39, R.string.activity_type_handball),
+ BASEBALL(0x3A, R.string.activity_type_baseball),
+ SOFTBALL(0x55, R.string.activity_type_softball),
+ CRICKET(0x0B, R.string.activity_type_cricket),
+ RUGBY(0x44, R.string.activity_type_rugby),
+ HOCKEY(0x1E, R.string.activity_type_hockey),
+ SQUASH(0x3C, R.string.activity_type_squash),
+ DODGEBALL(0x81, R.string.activity_type_dodgeball),
+ SOCCER(0x07, R.string.activity_type_soccer, ActivityKind.TYPE_SOCCER),
+ BASKETBALL(0x08, R.string.activity_type_basketball, ActivityKind.TYPE_BASKETBALL),
+ AUSTRALIAN_FOOTBALL(0x37, R.string.activity_type_australian_football),
+ GOLF(0x45, R.string.activity_type_golf),
+ PICKLEBALL(0x5B, R.string.activity_type_pickleball),
+ LACROSS(0x98, R.string.activity_type_lacross),
+ SHOT(0x9D, R.string.activity_type_shot),
+ // Water sports
+ SAILING(0x82, R.string.activity_type_sailing),
+ SURFING(0x64, R.string.activity_type_surfing),
+ JET_SKIING(0x87, R.string.activity_type_jet_skiing),
+ // Snow sports
+ SKATING(0x4C, R.string.activity_type_skating),
+ ICE_HOCKEY(0x24, R.string.activity_type_ice_hockey),
+ CURLING(0x3D, R.string.activity_type_curling),
+ SNOWBOARDING(0x3E, R.string.activity_type_snowboarding),
+ CROSS_COUNTRY_SKIING(0x6E, R.string.activity_type_cross_country_skiing),
+ SNOW_SPORTS(0x48, R.string.activity_type_snow_sports),
+ SKIING(0x22, R.string.activity_type_skiing),
+ // Extreme sports
+ SKATEBOARDING(0x60, R.string.activity_type_skateboarding),
+ ROCK_CLIMBING(0x69, R.string.activity_type_rock_climbing),
+ HUNTING(0x93, R.string.activity_type_hunting),
+ ;
+
+ private final byte code;
+ @StringRes
+ private final int nameRes;
+ private final int activityKind;
+
+ CmfActivityType(final int code, final int nameRes) {
+ this(code, nameRes, ActivityKind.TYPE_UNKNOWN);
+ }
+
+ CmfActivityType(final int code, final int nameRes, final int activityKind) {
+ this.code = (byte) code;
+ this.nameRes = nameRes;
+ this.activityKind = activityKind;
+ }
+
+ public byte getCode() {
+ return code;
+ }
+
+ public int getActivityKind() {
+ return activityKind;
+ }
+
+ @StringRes
+ public int getNameRes() {
+ return nameRes;
+ }
+
+ @Nullable
+ public static CmfActivityType fromCode(final byte code) {
+ for (final CmfActivityType cmd : CmfActivityType.values()) {
+ if (cmd.getCode() == code) {
+ return cmd;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java
new file mode 100644
index 000000000..03762047b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java
@@ -0,0 +1,310 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import android.bluetooth.BluetoothGattCharacteristic;
+
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.GeneralSecurityException;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.zip.CRC32;
+
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import nodomain.freeyourgadget.gadgetbridge.util.CryptoUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class CmfCharacteristic {
+ private final Logger LOG = LoggerFactory.getLogger(CmfCharacteristic.class);
+
+ private static final byte[] AES_IV = new byte[]{0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x5a};
+ private static final byte PAYLOAD_HEADER = (byte) 0xf5;
+
+ private final BluetoothGattCharacteristic bluetoothGattCharacteristic;
+ private final UUID characteristicUUID;
+
+ private final Handler handler;
+
+ private byte[] sessionKey;
+
+ private int mtu = 247;
+
+ private final Map chunkBuffers = new HashMap<>();
+
+ public CmfCharacteristic(final BluetoothGattCharacteristic bluetoothGattCharacteristic,
+ final Handler handler) {
+ this.bluetoothGattCharacteristic = bluetoothGattCharacteristic;
+ this.characteristicUUID = bluetoothGattCharacteristic.getUuid();
+ this.handler = handler;
+ }
+
+ public UUID getCharacteristicUUID() {
+ return characteristicUUID;
+ }
+
+ public void setSessionKey(final byte[] sessionKey) {
+ this.sessionKey = sessionKey;
+ }
+
+ public void setMtu(final int mtu) {
+ this.mtu = mtu;
+ }
+
+ public void sendCommand(final TransactionBuilder builder, final CmfCommand cmd, final byte[] payload) {
+ final byte[][] chunks;
+
+ if (shouldEncrypt(cmd)) {
+ chunks = makeChunksEncrypted(payload);
+ } else {
+ chunks = makeChunksPlaintext(payload);
+ }
+
+ if (chunks == null) {
+ // Something went wrong chunking - error was already printed
+ return;
+ }
+
+ for (int i = 0; i < chunks.length; i++) {
+ final byte[] chunk = chunks[i];
+
+ final ByteBuffer buf = ByteBuffer.allocate(chunk.length + 11).order(ByteOrder.BIG_ENDIAN);
+ buf.put(PAYLOAD_HEADER);
+ buf.putShort((short) chunk.length);
+ buf.putShort((short) cmd.getCmd1());
+ buf.putShort((short) chunks.length);
+ buf.putShort((short) (i + 1));
+ buf.putShort((short) cmd.getCmd2());
+ buf.put(chunk);
+
+ builder.write(bluetoothGattCharacteristic, buf.array());
+ }
+ }
+
+ private byte[][] makeChunksPlaintext(final byte[] payload) {
+ final int chunkSize = mtu - 20;
+ final int numChunks = (int) Math.ceil(payload.length / (float) chunkSize);
+ final byte[][] chunks = new byte[numChunks][];
+
+ final CRC32 crc = new CRC32();
+
+ for (int i = 0; i < numChunks; i++) {
+ final int startIdx = i * chunkSize;
+ final int endIdx = Math.min(startIdx + chunkSize, payload.length);
+ final byte[] chunk = ArrayUtils.subarray(payload, startIdx, endIdx);
+
+ crc.reset();
+ crc.update(chunk, 0, chunk.length);
+
+ chunks[i] = ArrayUtils.addAll(
+ chunk,
+ BLETypeConversions.fromUint32((int) crc.getValue())
+ );
+ }
+
+ return chunks;
+ }
+
+ @Nullable
+ private byte[][] makeChunksEncrypted(final byte[] payload) {
+ if (payload.length == 0) {
+ return new byte[1][0];
+ }
+
+ // AES will output 16-byte blocks, exclude the protocol overhead (11 bytes)
+ final int maxEncryptedPayloadSize = ((mtu - 11) / 16) * 16;
+ final int maxPayloadSize = maxEncryptedPayloadSize - 4 - 1; // exclude 4 bytes for crc and 1 byte of aes padding
+ final int numChunks = (int) Math.ceil(payload.length / (float) (maxPayloadSize));
+ final byte[][] chunks = new byte[numChunks][];
+
+ if (numChunks != 1) {
+ LOG.debug("Splitting payload into {} chunks of {} bytes", numChunks, maxPayloadSize);
+ }
+
+ final CRC32 crc = new CRC32();
+
+ for (int i = 0; i < numChunks; i++) {
+ final int startIdx = i * maxPayloadSize;
+ final int endIdx = Math.min(startIdx + maxPayloadSize, payload.length);
+ final byte[] chunk = ArrayUtils.subarray(payload, startIdx, endIdx);
+
+ crc.reset();
+ crc.update(chunk, 0, chunk.length);
+
+ final byte[] payloadToEncrypt = ArrayUtils.addAll(
+ chunk,
+ BLETypeConversions.fromUint32((int) crc.getValue())
+ );
+
+ try {
+ chunks[i] = CryptoUtils.encryptAES_CBC_Pad(payloadToEncrypt, sessionKey, AES_IV);
+ } catch (final GeneralSecurityException e) {
+ LOG.error("Failed to encrypt chunk", e);
+ return null;
+ }
+ }
+
+ return chunks;
+ }
+
+ private boolean shouldEncrypt(final CmfCommand cmd) {
+ switch (cmd) {
+ case DATA_CHUNK_WRITE_AGPS:
+ case DATA_CHUNK_WRITE_WATCHFACE:
+ return false;
+ }
+
+ return true;
+ }
+
+ public void onCharacteristicChanged(final byte[] value) {
+ final ByteBuffer buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN);
+
+ final byte header = buf.get();
+ if (header != PAYLOAD_HEADER) {
+ LOG.error("Unexpected first byte {}", String.format("0x%02x", header));
+ return;
+ }
+
+ final int encryptedPayloadLength = buf.getShort();
+ final int cmd1 = buf.getShort() & 0xFFFF;
+ final int chunkCount = buf.getShort();
+ final int chunkIndex = buf.getShort();
+ final int cmd2 = buf.getShort() & 0xFFFF;
+
+ final CmfCommand cmd = CmfCommand.fromCodes(cmd1, cmd2);
+
+ final byte[] payload;
+ if (encryptedPayloadLength > 0) {
+ final byte[] encryptedPayload = new byte[encryptedPayloadLength];
+ buf.get(encryptedPayload);
+
+ try {
+ final byte[] decryptedPayload = CryptoUtils.decryptAES_CBC_Pad(encryptedPayload, sessionKey, AES_IV);
+ payload = ArrayUtils.subarray(decryptedPayload, 0, decryptedPayload.length - 4);
+ final int expectedCrc = BLETypeConversions.toUint32(decryptedPayload, decryptedPayload.length - 4);
+ final CRC32 crc = new CRC32();
+ crc.update(payload, 0, payload.length);
+ final int actualCrc = (int) crc.getValue();
+ if (actualCrc != expectedCrc) {
+ LOG.error("Payload CRC mismatch for {}: got {}, expected {}", cmd, String.format("%08X", actualCrc), String.format("%08X", expectedCrc));
+ if (chunkCount > 1) {
+ chunkBuffers.remove(cmd);
+ }
+ return;
+ }
+ } catch (final GeneralSecurityException e) {
+ LOG.error("Failed to decrypt payload for {}", cmd, e);
+ if (chunkCount > 1) {
+ chunkBuffers.remove(cmd);
+ }
+ return;
+ }
+ } else {
+ payload = new byte[0];
+ }
+
+ LOG.debug(
+ "Got {}: {}{}",
+ chunkCount > 1 ? String.format(Locale.ROOT, "chunk %d/%d", chunkIndex, chunkCount) : "command",
+ cmd != null ? String.format("cmd=%s", cmd) : String.format("cmd1=0x%04x cmd2=0x%04x", cmd1, cmd2),
+ payload.length > 0 ? " payload=" + GB.hexdump(payload) : ""
+ );
+
+ if (cmd == null) {
+ // Just ignore unknown commands
+ LOG.warn("Unknown command cmd1={} cmd2={}", String.format("0x%04x", cmd1), String.format("0x%04x", cmd2));
+ return;
+ }
+
+ final byte[] fullPayload;
+ if (chunkCount == 1) {
+ // Single-chunk payload - just pass it through
+ fullPayload = payload;
+ } else {
+ final ChunkBuffer buffer;
+ if (chunkBuffers.containsKey(cmd)) {
+ buffer = Objects.requireNonNull(chunkBuffers.get(cmd));
+ } else {
+ buffer = new ChunkBuffer();
+ }
+
+ if (chunkIndex != buffer.expectedChunk) {
+ LOG.warn("Got unexpected chunk, expected {}", buffer.expectedChunk);
+
+ if (chunkIndex != 1) {
+ // This chunk is not the first one and we got out of sync - ignore it and do not proceed
+ return;
+ }
+
+ // Just discard whatever we had and start over
+ buffer.baos.reset();
+ }
+
+ try {
+ buffer.baos.write(payload);
+ } catch (final IOException e) {
+ LOG.error("Failed to write payload to chunk buffer", e);
+ return;
+ }
+
+ buffer.expectedChunk = chunkIndex + 1;
+
+ if (chunkIndex != chunkCount) {
+ // Chunk buffer not full yet
+ return;
+ }
+
+ LOG.debug("Got all {} chunks for {}", chunkCount, cmd);
+
+ fullPayload = buffer.baos.toByteArray().clone();
+ chunkBuffers.remove(cmd);
+ }
+
+ if (handler == null) {
+ LOG.error("Handler is null for {}", characteristicUUID);
+ return;
+ }
+
+ try {
+ handler.onCommand(cmd, fullPayload);
+ } catch (final Exception e) {
+ LOG.error("Exception while handling command", e);
+ }
+ }
+
+ public interface Handler {
+ void onCommand(CmfCommand cmd, byte[] payload);
+ }
+
+ private static class ChunkBuffer {
+ private int expectedChunk = 1;
+ private final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommand.java
new file mode 100644
index 000000000..6f46e3a88
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommand.java
@@ -0,0 +1,124 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import androidx.annotation.Nullable;
+
+public enum CmfCommand {
+ ACTIVITY_DATA(0x0056, 0x0001),
+ ACTIVITY_FETCH_1(0xffff, 0x8005),
+ ACTIVITY_FETCH_2(0xffff, 0x9057),
+ ACTIVITY_FETCH_ACK_1(0xffff, 0x0005),
+ ACTIVITY_FETCH_ACK_2(0xffff, 0xa057),
+ ALARMS_GET(0x0063, 0x0002),
+ ALARMS_SET(0x0063, 0x0001),
+ APP_NOTIFICATION(0x0065, 0x0001),
+ AUTH_NONCE_REPLY(0xffff, 0x004c),
+ AUTH_NONCE_REQUEST(0xffff, 0x804b),
+ AUTH_PAIR_REPLY(0xffff, 0x0048),
+ AUTH_PAIR_REQUEST(0xffff, 0x8047),
+ AUTH_PHONE_NAME(0xffff, 0x8049),
+ AUTH_WATCH_MAC(0xffff, 0x0049),
+ AUTHENTICATED_CONFIRM_REPLY(0xffff, 0x0004),
+ AUTHENTICATED_CONFIRM_REQUEST(0xffff, 0x804d),
+ BATTERY(0x005c, 0x0001),
+ CALL_REMINDER(0xffff, 0x9066),
+ CONTACTS_GET(0x00d5, 0x0002),
+ CONTACTS_SET(0x00d5, 0x0001),
+ DATA_CHUNK_REQUEST_AGPS(0xffff, 0xa05f),
+ DATA_CHUNK_REQUEST_WATCHFACE(0xffff, 0xa064),
+ DATA_CHUNK_WRITE_AGPS(0xffff, 0x905f),
+ DATA_CHUNK_WRITE_WATCHFACE(0xffff, 0x9064),
+ DATA_TRANSFER_AGPS_FINISH_ACK_1(0xffff, 0xa060),
+ DATA_TRANSFER_AGPS_FINISH_ACK_2(0xffff, 0x9060),
+ DATA_TRANSFER_AGPS_INIT_REPLY(0xffff, 0xa05e),
+ DATA_TRANSFER_AGPS_INIT_REQUEST(0xffff, 0x905e),
+ DATA_TRANSFER_WATCHFACE_FINISH_ACK_1(0xffff, 0xa065),
+ DATA_TRANSFER_WATCHFACE_FINISH_ACK_2(0xffff, 0x9065),
+ DATA_TRANSFER_WATCHFACE_INIT_1_REQUEST(0xffff, 0x8052),
+ DATA_TRANSFER_WATCHFACE_INIT_1_REPLY(0xffff, 0x0052),
+ DATA_TRANSFER_WATCHFACE_INIT_2_REPLY(0xffff, 0xa063),
+ DATA_TRANSFER_WATCHFACE_INIT_2_REQUEST(0xffff, 0x9063),
+ DO_NOT_DISTURB(0x0099, 0x0001),
+ FACTORY_RESET(0x009a, 0x0001),
+ FIND_PHONE(0x005b, 0x0001),
+ FIND_WATCH(0x005d, 0x0001),
+ FIRMWARE_VERSION_GET(0xffff, 0x8006),
+ FIRMWARE_VERSION_RET(0xffff, 0x0006),
+ GOALS_SET(0x005e, 0x0001),
+ GPS_COORDS(0xffff, 0x906a),
+ HEART_MONITORING_ALERTS(0xffff, 0x9059),
+ HEART_MONITORING_ENABLED_GET(0x009b, 0x0002),
+ HEART_MONITORING_ENABLED_SET(0x009b, 0x0001),
+ HEART_RATE_RESTING(0x00da, 0x0001),
+ HEART_RATE_MANUAL_AUTO(0x0053, 0x0001),
+ HEART_RATE_WORKOUT(0x00e0, 0x0001),
+ LANGUAGE_RET(0xffff, 0xa06b),
+ LANGUAGE_SET(0xffff, 0x9058),
+ MUSIC_BUTTON(0xffff, 0xa05d),
+ MUSIC_INFO_ACK(0xffff, 0xa05c),
+ MUSIC_INFO_SET(0xffff, 0x905c),
+ SERIAL_NUMBER_GET(0x00de, 0x0002),
+ SERIAL_NUMBER_RET(0x00de, 0x0001),
+ SLEEP_DATA(0x0058, 0x0001),
+ SPO2(0x0055, 0x0001),
+ SPORTS_SET(0x00dc, 0x0001),
+ STANDING_REMINDER_GET(0x0060, 0x0002),
+ STANDING_REMINDER_SET(0x0060, 0x0001),
+ STRESS(0x009d, 0x0001),
+ TIME_FORMAT(0x005f, 0x0001),
+ TIME(0xffff, 0x8004),
+ TRIGGER_SYNC(0x005c, 0x0002),
+ UNIT_LENGTH(0xffff, 0x9067),
+ UNIT_TEMPERATURE(0xffff, 0x9068),
+ WAKE_ON_WRIST_RAISE(0x0062, 0x0001),
+ WATCHFACE(0x009f, 0x0001),
+ WATER_REMINDER_GET(0x0061, 0x0002),
+ WATER_REMINDER_SET(0x0061, 0x0001),
+ WEATHER_SET_1(0xffff, 0x906b),
+ WEATHER_SET_2(0x0066, 0x0001),
+ WORKOUT_GPS(0xffff, 0xa05a),
+ WORKOUT_SUMMARY(0x0057, 0x0001),
+ ;
+
+ private final int cmd1;
+ private final int cmd2;
+
+ CmfCommand(final int cmd1, final int cmd2) {
+ this.cmd1 = cmd1;
+ this.cmd2 = cmd2;
+ }
+
+ public int getCmd1() {
+ return cmd1;
+ }
+
+ public int getCmd2() {
+ return cmd2;
+ }
+
+ @Nullable
+ public static CmfCommand fromCodes(final int cmd1, final int cmd2) {
+ for (final CmfCommand cmd : CmfCommand.values()) {
+ if (cmd.getCmd1() == cmd1 && cmd.getCmd2() == cmd2) {
+ return cmd;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfDataUploader.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfDataUploader.java
new file mode 100644
index 000000000..9e4571193
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfDataUploader.java
@@ -0,0 +1,198 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import android.net.Uri;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Random;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetProgressAction;
+
+public class CmfDataUploader implements CmfCharacteristic.Handler {
+ private static final Logger LOG = LoggerFactory.getLogger(CmfWatchProSupport.class);
+
+ private final CmfWatchProSupport mSupport;
+
+ private CmfFwHelper fwHelper;
+
+ public CmfDataUploader(final CmfWatchProSupport support) {
+ this.mSupport = support;
+ }
+
+ @Override
+ public void onCommand(final CmfCommand cmd, final byte[] payload) {
+ switch (cmd) {
+ case DATA_TRANSFER_WATCHFACE_INIT_1_REPLY:
+ if (payload[0] != 0x01) {
+ LOG.warn("Got unexpected transfer init 1 reply {}", payload[0]);
+ fwHelper = null;
+ return;
+ }
+
+ final ByteBuffer buf = ByteBuffer.allocate(9).order(ByteOrder.BIG_ENDIAN);
+ buf.put((byte) (0xa5));
+ buf.putInt(fwHelper.getBytes().length);
+ buf.putInt(new Random().nextInt()); // FIXME watchface ID?
+
+ mSupport.sendData(
+ "transfer watchface init 2 request",
+ CmfCommand.DATA_TRANSFER_WATCHFACE_INIT_2_REQUEST,
+ buf.array()
+ );
+ return;
+ case DATA_TRANSFER_AGPS_INIT_REPLY:
+ case DATA_TRANSFER_WATCHFACE_INIT_2_REPLY:
+ if (payload[0] != 0x01) {
+ LOG.warn("Got unexpected transfer 2 init reply {}", payload[0]);
+ fwHelper = null;
+ return;
+ }
+
+ setDeviceBusy();
+ updateProgress(0, true);
+
+ return;
+ case DATA_TRANSFER_WATCHFACE_FINISH_ACK_1:
+ handleAck1(CmfCommand.DATA_TRANSFER_WATCHFACE_FINISH_ACK_2, payload);
+ return;
+ case DATA_TRANSFER_AGPS_FINISH_ACK_1:
+ handleAck1(CmfCommand.DATA_TRANSFER_AGPS_FINISH_ACK_2, payload);
+ return;
+ case DATA_CHUNK_REQUEST_AGPS:
+ if (fwHelper == null || !fwHelper.isAgps()) {
+ LOG.warn("We are not sending AGPS - refusing request");
+ return;
+ }
+ handleChunkRequest(CmfCommand.DATA_CHUNK_REQUEST_AGPS, payload);
+ return;
+ case DATA_CHUNK_REQUEST_WATCHFACE:
+ if (fwHelper == null || !fwHelper.isWatchface()) {
+ LOG.warn("We are not sending a watchface - refusing request");
+ return;
+ }
+ handleChunkRequest(CmfCommand.DATA_CHUNK_WRITE_WATCHFACE, payload);
+ return;
+ }
+
+ LOG.warn("Got unknown data command {}", cmd);
+ }
+
+ public void onInstallApp(final Uri uri) {
+ if (fwHelper != null) {
+ LOG.warn("Already installing {}", fwHelper.getUri());
+ return;
+ }
+
+ fwHelper = new CmfFwHelper(uri, mSupport.getContext());
+ if (!fwHelper.isValid()) {
+ LOG.warn("Uri {} is not valid", uri);
+ fwHelper = null;
+ return;
+ }
+
+ if (fwHelper.isWatchface()) {
+ mSupport.sendData(
+ "transfer watchface init request",
+ CmfCommand.DATA_TRANSFER_WATCHFACE_INIT_1_REQUEST,
+ (byte) 0xa5
+ );
+
+ return;
+ }
+
+ LOG.warn("Unsupported fwHelper for {}", fwHelper.getUri());
+ fwHelper = null;
+ }
+
+ private void handleChunkRequest(final CmfCommand commandReply, final byte[] payload) {
+ final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN);
+ final int offset = buf.getInt();
+ final int length = buf.getInt();
+ final int progress = buf.get();
+
+ LOG.debug("Got chunk request: offset={}, length={}, progress={}", offset, length, progress);
+
+ final TransactionBuilder builder = mSupport.createTransactionBuilder("send chunk offset " + offset);
+ updateProgress(builder, progress, true);
+ mSupport.sendData(
+ "transfer watchface init request",
+ commandReply,
+ ArrayUtils.subarray(fwHelper.getBytes(), offset, offset + length)
+ );
+ }
+
+ private void handleAck1(final CmfCommand commandReply, final byte[] payload) {
+ if (payload[0] != 0x01) {
+ LOG.warn("Got unexpected transfer finish reply {}", payload[0]);
+ fwHelper = null;
+ }
+
+ LOG.debug("Got transfer finish ack 1");
+
+ unsetDeviceBusy();
+ updateProgress(100, false);
+ mSupport.sendData("transfer finish", commandReply, (byte) 0xa5);
+}
+
+ private void updateProgress(final int progressPercent, boolean ongoing) {
+ final TransactionBuilder builder = mSupport.createTransactionBuilder("update data upload progress to " + progressPercent);
+ updateProgress(builder, progressPercent, ongoing);
+ builder.queue(mSupport.getQueue());
+ }
+
+ private void updateProgress(final TransactionBuilder builder, final int progressPercent, boolean ongoing) {
+ final int uploadMessage;
+ if (fwHelper != null && fwHelper.isWatchface()) {
+ uploadMessage = R.string.uploading_watchface;
+ } else {
+ uploadMessage = R.string.updating_firmware;
+ }
+
+ builder.add(new SetProgressAction(
+ mSupport.getContext().getString(uploadMessage),
+ ongoing,
+ progressPercent,
+ mSupport.getContext()
+ ));
+ }
+
+ private void setDeviceBusy() {
+ final GBDevice device = mSupport.getDevice();
+ device.setBusyTask(mSupport.getContext().getString(R.string.updating_firmware));
+ device.sendDeviceUpdateIntent(mSupport.getContext());
+ }
+
+ private void unsetDeviceBusy() {
+ final GBDevice device = mSupport.getDevice();
+ if (device != null && device.isConnected()) {
+ if (device.isBusy()) {
+ device.unsetBusyTask();
+ device.sendDeviceUpdateIntent(mSupport.getContext());
+ }
+ device.sendDeviceUpdateIntent(mSupport.getContext());
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfFwHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfFwHelper.java
new file mode 100644
index 000000000..95c5ceb9a
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfFwHelper.java
@@ -0,0 +1,187 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
+
+public class CmfFwHelper {
+ private static final Logger LOG = LoggerFactory.getLogger(CmfFwHelper.class);
+
+ private static final byte[] HEADER_WATCHFACE = new byte[]{0x01, 0x00, 0x00, 0x02};
+ private static final byte[] HEADER_FIRMWARE = new byte[]{'A', 'O', 'T', 'A'};
+ private static final byte[] HEADER_AGPS = new byte[]{0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x31, 0x30, 0x30, 0x30, 0x30};
+
+ private final Uri uri;
+ private byte[] fw;
+ private boolean typeFirmware;
+ private boolean typeWatchface;
+ private boolean typeAgps;
+
+ private String name;
+ private String version;
+
+ public CmfFwHelper(final Uri uri, final Context context) {
+ this.uri = uri;
+
+ final UriHelper uriHelper;
+ try {
+ uriHelper = UriHelper.get(uri, context);
+ } catch (final IOException e) {
+ LOG.error("Failed to get uri helper for {}", uri, e);
+ return;
+ }
+
+ final int maxExpectedFileSize = 1024 * 1024 * 32; // 32MB
+
+ if (uriHelper.getFileSize() > maxExpectedFileSize) {
+ LOG.warn("File size is larger than the maximum expected file size of {}", maxExpectedFileSize);
+ return;
+ }
+
+ try (final InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
+ this.fw = FileUtils.readAll(in, maxExpectedFileSize);
+ } catch (final IOException e) {
+ LOG.error("Failed to read bytes from {}", uri, e);
+ return;
+ }
+
+ parseBytes();
+ }
+
+ public Uri getUri() {
+ return uri;
+ }
+
+ public boolean isValid() {
+ return isWatchface() || isFirmware() || isAgps();
+ }
+
+ public boolean isWatchface() {
+ return typeWatchface;
+ }
+
+ public boolean isFirmware() {
+ return typeFirmware;
+ }
+
+ public boolean isAgps() {
+ return typeAgps;
+ }
+
+ public String getDetails() {
+ return name != null ? name : (version != null ? version : "UNKNOWN");
+ }
+
+ public byte[] getBytes() {
+ return fw;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public void unsetFwBytes() {
+ this.fw = null;
+ }
+
+ private void parseBytes() {
+ if (parseAsWatchface()) {
+ assert name != null;
+ typeWatchface = true;
+ } else if (parseAsFirmware()) {
+ assert version != null;
+ typeFirmware = true;
+ } else if (parseAsAgps()) {
+ typeAgps = true;
+ }
+ }
+
+ private boolean parseAsWatchface() {
+ if (!ArrayUtils.equals(fw, HEADER_WATCHFACE, 4)) {
+ LOG.warn("File header not a watchface");
+ return false;
+ }
+
+ final String nameHeader = StringUtils.untilNullTerminator(fw, 8);
+ if (nameHeader == null) {
+ LOG.warn("watchface name not found in {}", uri);
+ return false;
+ }
+
+ // Confirm it's a watchface by finding the same name at the end
+ final String nameTrailer = StringUtils.untilNullTerminator(fw, fw.length - 28);
+ if (nameTrailer == null) {
+ LOG.warn("watchface name not found at the end of {}", uri);
+ return false;
+ }
+
+ if (!nameHeader.equals(nameTrailer)) {
+ LOG.warn("Names in header and trailer do not match");
+ return false;
+ }
+
+ name = nameHeader;
+
+ return true;
+ }
+
+ private boolean parseAsFirmware() {
+ if (!ArrayUtils.equals(fw, HEADER_FIRMWARE, 0)) {
+ LOG.warn("File header not a firmware");
+ return false;
+ }
+
+ // FIXME: This is not really the version, but build number?
+ final String versionHeader = StringUtils.untilNullTerminator(fw, 64);
+ if (versionHeader == null) {
+ LOG.warn("firmware version not found in {}", uri);
+ return false;
+ }
+
+ version = versionHeader;
+
+ return true;
+ }
+
+ private boolean parseAsAgps() {
+ if (!ArrayUtils.equals(fw, HEADER_AGPS, 0)) {
+ LOG.warn("File header not agps");
+ return false;
+ }
+
+ // TODO parse? and set something
+
+ return true;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfInstallHandler.java
new file mode 100644
index 000000000..0c9c12475
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfInstallHandler.java
@@ -0,0 +1,90 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import android.content.Context;
+import android.net.Uri;
+
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
+import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
+
+public class CmfInstallHandler implements InstallHandler {
+ protected final Uri mUri;
+ protected final Context mContext;
+ protected final CmfFwHelper helper;
+
+ public CmfInstallHandler(final Uri uri, final Context context) {
+ this.mUri = uri;
+ this.mContext = context;
+ this.helper = new CmfFwHelper(uri, context);
+ }
+
+ @Override
+ public boolean isValid() {
+ return helper.isValid();
+ }
+
+ @Override
+ public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
+ if (device.isBusy()) {
+ installActivity.setInfoText(device.getBusyTask());
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!device.isInitialized()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ if (!helper.isValid()) {
+ installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
+ installActivity.setInstallEnabled(false);
+ return;
+ }
+
+ final GenericItem installItem = new GenericItem();
+ if (helper.isWatchface()) {
+ installItem.setIcon(R.drawable.ic_watchface);
+ installItem.setName(mContext.getString(R.string.kind_watchface));
+ } else if (helper.isFirmware()) {
+ installItem.setIcon(R.drawable.ic_firmware);
+ installItem.setName(mContext.getString(R.string.kind_firmware));
+ } else if (helper.isAgps()) {
+ installItem.setIcon(R.drawable.ic_firmware);
+ installItem.setName(mContext.getString(R.string.kind_agps_bundle));
+ } else {
+ installItem.setIcon(R.drawable.ic_device_unknown);
+ installItem.setName(mContext.getString(R.string.kind_invalid));
+ }
+
+ installItem.setDetails(helper.getDetails());
+
+ installActivity.setInfoText(mContext.getString(R.string.firmware_install_warning, "(unknown)"));
+ installActivity.setInstallItem(installItem);
+ installActivity.setInstallEnabled(true);
+ }
+
+ @Override
+ public void onStartInstall(final GBDevice device) {
+ helper.unsetFwBytes(); // free up memory
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfNotificationIcon.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfNotificationIcon.java
new file mode 100644
index 000000000..a03e97a76
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfNotificationIcon.java
@@ -0,0 +1,75 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+
+public enum CmfNotificationIcon {
+ GENERIC_SMS(0),
+ WHATSAPP(8),
+ SNAPCHAT(9),
+ WHATSAPP_BUSINESS(10),
+ TRUECALLER(11), // blue phone
+ TELEGRAM(12),
+ FACEBOOK_MESSENGER(13),
+ IMO(14),
+ CALLAPP(15),
+ FACEBOOK(17),
+ INSTAGRAM(18),
+ TIKTOK(19),
+ LINE(20),
+ DISCORD(21),
+ GOOGLE_VOICE(22),
+ GMAIL(27),
+ OUTLOOK(29),
+ UNKNOWN(255),
+ ;
+
+ private final byte code;
+
+ CmfNotificationIcon(final int code) {
+ this.code = (byte) code;
+ }
+
+ public byte getCode() {
+ return code;
+ }
+
+ public static CmfNotificationIcon forNotification(final NotificationSpec notificationSpec) {
+ if (notificationSpec.type == null) {
+ return UNKNOWN;
+ }
+
+ try {
+ // If there's a matching enum, just return it
+ return CmfNotificationIcon.valueOf(notificationSpec.type.name());
+ } catch (final IllegalArgumentException ignored) {
+ // ignored
+ }
+
+ switch (notificationSpec.type.getGenericType()) {
+ case "generic_chat":
+ return GENERIC_SMS;
+ case "generic_email":
+ return GMAIL;
+ case "generic_phone":
+ return TRUECALLER;
+ }
+
+ return UNKNOWN;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfPreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfPreferences.java
new file mode 100644
index 000000000..d241fd0a5
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfPreferences.java
@@ -0,0 +1,392 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import android.content.Context;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class CmfPreferences {
+ private static final Logger LOG = LoggerFactory.getLogger(CmfPreferences.class);
+
+ private final CmfWatchProSupport mSupport;
+
+ protected CmfPreferences(final CmfWatchProSupport support) {
+ this.mSupport = support;
+ }
+
+ protected void onSetHeartRateMeasurementInterval(final int seconds) {
+ final boolean enabled = seconds == -1;
+ LOG.debug("Set HR smart monitoring = {}", enabled);
+
+ final byte[] cmd = new byte[]{0x01, (byte) (enabled ? 0x01 : 0x00)};
+ mSupport.sendCommand("set hr monitoring", CmfCommand.HEART_MONITORING_ENABLED_SET, cmd);
+ }
+
+ protected void onSendConfiguration(final String config) {
+ switch (config) {
+ case ActivityUser.PREF_USER_STEPS_GOAL:
+ case ActivityUser.PREF_USER_DISTANCE_METERS:
+ case ActivityUser.PREF_USER_CALORIES_BURNT:
+ setGoals();
+ return;
+ case SettingsActivity.PREF_MEASUREMENT_SYSTEM:
+ setMeasurementSystem();
+ return;
+ case DeviceSettingsPreferenceConst.PREF_LANGUAGE:
+ setLanguage();
+ return;
+ case DeviceSettingsPreferenceConst.PREF_TIMEFORMAT:
+ setTimeFormat();
+ return;
+ case DeviceSettingsPreferenceConst.PREF_LIFTWRIST_NOSHED:
+ setDisplayOnLift();
+ return;
+ case DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD:
+ case DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD:
+ case DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD:
+ case DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD:
+ setHeartAlerts();
+ return;
+ case DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING:
+ setSpo2MonitoringInterval();
+ return;
+ case DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING:
+ setStressMonitoringInterval();
+ return;
+ case DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE:
+ case DeviceSettingsPreferenceConst.PREF_INACTIVITY_THRESHOLD:
+ case DeviceSettingsPreferenceConst.PREF_INACTIVITY_START:
+ case DeviceSettingsPreferenceConst.PREF_INACTIVITY_END:
+ case DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND:
+ case DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_START:
+ case DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_END:
+ setStandingReminder();
+ case DeviceSettingsPreferenceConst.PREF_HYDRATION_SWITCH:
+ case DeviceSettingsPreferenceConst.PREF_HYDRATION_PERIOD:
+ case DeviceSettingsPreferenceConst.PREF_HYDRATION_DND:
+ case DeviceSettingsPreferenceConst.PREF_HYDRATION_DND_START:
+ case DeviceSettingsPreferenceConst.PREF_HYDRATION_DND_END:
+ setHydrationReminder();
+ return;
+ case HuamiConst.PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE:
+ setActivityTypes();
+ return;
+ // TODO call reminders
+ }
+
+ LOG.warn("Unknown config changed: {}", config);
+ }
+
+ private void setGoals() {
+ final ActivityUser activityUser = new ActivityUser();
+
+ if (activityUser.getStepsGoal() <= 0) {
+ LOG.warn("Invalid steps goal {}", activityUser.getStepsGoal());
+ return;
+ }
+
+ if (activityUser.getDistanceGoalMeters() <= 0) {
+ LOG.warn("Invalid distance goal {}", activityUser.getDistanceGoalMeters());
+ return;
+ }
+
+ if (activityUser.getCaloriesBurntGoal() <= 0) {
+ LOG.warn("Invalid calories goal {}", activityUser.getCaloriesBurntGoal());
+ return;
+ }
+
+ LOG.debug(
+ "Setting goals, steps={}, distance={}, calories={}",
+ activityUser.getStepsGoal(),
+ activityUser.getDistanceGoalMeters(),
+ activityUser.getCaloriesBurntGoal()
+ );
+
+ final ByteBuffer buf = ByteBuffer.allocate(10).order(ByteOrder.BIG_ENDIAN);
+
+ buf.put((byte) 0); // ?
+ buf.put((byte) 0); // ?
+ buf.putShort((short) activityUser.getStepsGoal());
+ buf.put((byte) 0); // ?
+ buf.put((byte) 0); // ?
+ buf.putShort((short) activityUser.getDistanceGoalMeters());
+ buf.putShort((short) activityUser.getCaloriesBurntGoal());
+
+ mSupport.sendCommand("set goals", CmfCommand.GOALS_SET, buf.array());
+ }
+
+ private void setMeasurementSystem() {
+ final Prefs prefs = mSupport.getDevicePrefs();
+ final String measurementSystem = prefs.getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, "metric");
+
+ LOG.debug("Setting measurement system to {}", measurementSystem);
+
+ final byte unitByte = (byte) ("metric".equals(measurementSystem) ? 0x00 : 0x01);
+
+ final byte[] cmd = new byte[]{0x01, unitByte};
+ final TransactionBuilder builder = mSupport.createTransactionBuilder("set measurement system");
+ mSupport.sendCommand(builder, CmfCommand.UNIT_LENGTH, cmd);
+ mSupport.sendCommand(builder, CmfCommand.UNIT_TEMPERATURE, cmd);
+ builder.queue(mSupport.getQueue());
+ }
+
+ private void setLanguage() {
+ String localeString = mSupport.getDevicePrefs().getString(
+ DeviceSettingsPreferenceConst.PREF_LANGUAGE, DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO
+ );
+ if (DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO.equals(localeString)) {
+ String language = Locale.getDefault().getLanguage();
+ String country = Locale.getDefault().getCountry();
+
+ if (nodomain.freeyourgadget.gadgetbridge.util.StringUtils.isNullOrEmpty(country)) {
+ // sometimes country is null, no idea why, guess it.
+ country = language;
+ }
+ localeString = (language + "_" + country).toLowerCase(Locale.ROOT);
+ }
+
+ String languageCommand = null;
+ if (LANGUAGES.containsKey(localeString)) {
+ languageCommand = localeString;
+ } else {
+ // Break down the language code and attempt to find it
+ final String[] languageParts = localeString.split("_");
+ for (int i = 0; i < languageParts.length; i++) {
+ if (LANGUAGES.containsKey(languageParts[0])) {
+ languageCommand = languageParts[0];
+ break;
+ }
+ }
+ }
+
+ if (languageCommand == null) {
+ LOG.warn("Unknown language {}", localeString);
+ return;
+ }
+
+ LOG.info("Set language: {} -> {}", localeString, languageCommand);
+
+ // FIXME watch ignores language?
+ mSupport.sendCommand("set language", CmfCommand.LANGUAGE_SET, languageCommand.getBytes());
+ }
+
+ private void setTimeFormat() {
+ final GBPrefs gbPrefs = new GBPrefs(mSupport.getDevicePrefs());
+ final String timeFormat = gbPrefs.getTimeFormat();
+
+ LOG.info("Setting time format to {}", timeFormat);
+
+ final byte timeFormatByte = (byte) (timeFormat.equals("24h") ? 0x00 : 0x01);
+
+ mSupport.sendCommand("set time format", CmfCommand.TIME_FORMAT, timeFormatByte);
+ }
+
+ private void setDisplayOnLift() {
+ final Prefs prefs = mSupport.getDevicePrefs();
+
+ boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_LIFTWRIST_NOSHED, false);
+
+ mSupport.sendCommand("set display on lift", CmfCommand.WAKE_ON_WRIST_RAISE, (byte) (enabled ? 0x01 : 0x00));
+ }
+
+ private void setHeartAlerts() {
+ final Prefs prefs = mSupport.getDevicePrefs();
+
+ final int hrAlertActiveHigh = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD, 0);
+ final int hrAlertHigh = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD, 0);
+ final int hrAlertLow = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD, 0);
+ final int spo2alert = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD, 0);
+
+ final ByteBuffer buf;
+ if (hrAlertActiveHigh == 0 && hrAlertHigh == 0 && hrAlertLow == 0 && spo2alert == 0) {
+ buf = ByteBuffer.allocate(1).order(ByteOrder.BIG_ENDIAN);
+ buf.put((byte) 0x00);
+ } else {
+ buf = ByteBuffer.allocate(9).order(ByteOrder.BIG_ENDIAN);
+ buf.put((byte) 0x01);
+ buf.put((byte) hrAlertLow);
+ buf.put((byte) (hrAlertHigh != 0 ? hrAlertHigh : 255));
+ buf.put((byte) (hrAlertActiveHigh != 0 ? hrAlertActiveHigh : 255));
+ buf.put((byte) spo2alert);
+ buf.put((byte) 0x00); // ?
+ buf.put((byte) 0x00); // ?
+ buf.put((byte) 0x00); // ?
+ buf.put((byte) 0x00); // ?
+ }
+
+ mSupport.sendCommand("set heart monitoring alerts", CmfCommand.HEART_MONITORING_ALERTS, buf.array());
+ }
+
+ private void setSpo2MonitoringInterval() {
+ final Prefs prefs = mSupport.getDevicePrefs();
+ final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, false);
+
+ LOG.debug("Set SpO2 monitoring = {}", enabled);
+
+ final byte[] cmd = new byte[]{0x02, (byte) (enabled ? 0x01 : 0x00)};
+ mSupport.sendCommand("set spo2 monitoring", CmfCommand.HEART_MONITORING_ENABLED_SET, cmd);
+ }
+
+ private void setStressMonitoringInterval() {
+ final Prefs prefs = mSupport.getDevicePrefs();
+ final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING, false);
+
+ LOG.debug("Set stress monitoring = {}", enabled);
+
+ final byte[] cmd = new byte[]{0x04, (byte) (enabled ? 0x01 : 0x00)};
+ mSupport.sendCommand("set stress monitoring", CmfCommand.HEART_MONITORING_ENABLED_SET, cmd);
+ }
+
+ private void setStandingReminder() {
+ final Prefs prefs = mSupport.getDevicePrefs();
+ final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE, false);
+ final int threshold = prefs.getInt(DeviceSettingsPreferenceConst.PREF_INACTIVITY_THRESHOLD, 60);
+ final boolean dnd = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND, false);
+ final Date dndStart = prefs.getTimePreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_START, "12:00");
+ final Date dndEnd = prefs.getTimePreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_END, "14:00");
+
+ final Calendar calendar = GregorianCalendar.getInstance();
+
+ if (threshold < 0 || threshold > 180) {
+ LOG.error("Invalid inactivity threshold: {}", threshold);
+ return;
+ }
+
+ final ByteBuffer buf = ByteBuffer.allocate(11).order(ByteOrder.BIG_ENDIAN);
+ buf.put((byte) (enabled ? 0x01 : 0x00));
+ buf.putShort((short) threshold);
+
+ if (enabled && dnd) {
+ calendar.setTime(dndStart);
+ buf.putInt((calendar.get(Calendar.HOUR_OF_DAY) * 3600 + calendar.get(Calendar.MINUTE) * 60));
+ calendar.setTime(dndEnd);
+ buf.putInt((calendar.get(Calendar.HOUR_OF_DAY) * 3600 + calendar.get(Calendar.MINUTE) * 60));
+ } else {
+ buf.putInt(0);
+ buf.putInt(0);
+ }
+
+ mSupport.sendCommand("set standing reminders", CmfCommand.STANDING_REMINDER_SET, buf.array());
+ }
+
+ private void setHydrationReminder() {
+ final Prefs prefs = mSupport.getDevicePrefs();
+ final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HYDRATION_SWITCH, false);
+ final int threshold = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HYDRATION_PERIOD, 60);
+ final boolean dnd = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HYDRATION_DND, false);
+ final Date dndStart = prefs.getTimePreference(DeviceSettingsPreferenceConst.PREF_HYDRATION_DND_START, "12:00");
+ final Date dndEnd = prefs.getTimePreference(DeviceSettingsPreferenceConst.PREF_HYDRATION_DND_END, "14:00");
+
+ final Calendar calendar = GregorianCalendar.getInstance();
+
+ if (threshold < 0 || threshold > 180) {
+ LOG.error("Invalid hydration threshold: {}", threshold);
+ return;
+ }
+
+ final ByteBuffer buf = ByteBuffer.allocate(11).order(ByteOrder.BIG_ENDIAN);
+ buf.put((byte) (enabled ? 0x01 : 0x00));
+ buf.putShort((short) threshold);
+
+ if (enabled && dnd) {
+ calendar.setTime(dndStart);
+ buf.putInt((calendar.get(Calendar.HOUR_OF_DAY) * 3600 + calendar.get(Calendar.MINUTE) * 60));
+ calendar.setTime(dndEnd);
+ buf.putInt((calendar.get(Calendar.HOUR_OF_DAY) * 3600 + calendar.get(Calendar.MINUTE) * 60));
+ } else {
+ buf.putInt(0);
+ buf.putInt(0);
+ }
+
+ mSupport.sendCommand("set hydration reminders", CmfCommand.WATER_REMINDER_SET, buf.array());
+ }
+
+ private void setActivityTypes() {
+ final Prefs prefs = mSupport.getDevicePrefs();
+ List activityTypes = new ArrayList<>(prefs.getList(HuamiConst.PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE, Collections.emptyList()));
+
+ if (activityTypes.isEmpty()) {
+ activityTypes.add(CmfActivityType.OUTDOOR_RUNNING.name().toLowerCase(Locale.ROOT));
+ activityTypes.add(CmfActivityType.INDOOR_RUNNING.name().toLowerCase(Locale.ROOT));
+ }
+
+ if (activityTypes.size() > 36) {
+ LOG.warn("Truncating activity types list to 36");
+ activityTypes = activityTypes.subList(0, 36);
+ }
+
+ final ByteBuffer buf = ByteBuffer.allocate(activityTypes.size() + 1);
+ buf.put((byte) activityTypes.size());
+
+ for (final String activityType : activityTypes) {
+ buf.put(CmfActivityType.valueOf(activityType.toUpperCase(Locale.ROOT)).getCode());
+ }
+
+ mSupport.sendCommand("set activity types", CmfCommand.SPORTS_SET, buf.array());
+ }
+
+ protected boolean onCommand(final CmfCommand cmd, final byte[] payload) {
+ // TODO handle preference replies from watch
+ return false;
+ }
+
+ private Context getContext() {
+ return mSupport.getContext();
+ }
+
+ private GBDevice getDevice() {
+ return mSupport.getDevice();
+ }
+
+ private static final Map LANGUAGES = new HashMap() {{
+ put("ar", "ar_SA");
+ put("de", "de_DE");
+ put("en", "en_US");
+ put("es", "es_ES");
+ put("fr", "fr_FR");
+ put("hi", "hi_IN");
+ put("in", "id_ID");
+ put("it", "it_IT");
+ put("ja", "ja_JP");
+ put("ko", "ko_KO");
+ put("zh_cn", "zh_CN");
+ put("zh_hk", "zh_HK");
+ }};
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java
new file mode 100644
index 000000000..2325a48a7
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java
@@ -0,0 +1,594 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Build;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.TimeZone;
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdateDeviceInfo;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.Contact;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.MediaManager;
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
+
+public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements CmfCharacteristic.Handler {
+ private static final Logger LOG = LoggerFactory.getLogger(CmfWatchProSupport.class);
+
+ public static final UUID UUID_SERVICE_CMF_CMD = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_CMF_COMMAND_READ = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb");
+ public static final UUID UUID_CHARACTERISTIC_CMF_COMMAND_WRITE = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb");
+
+ public static final UUID UUID_SERVICE_CMF_DATA = UUID.fromString("02f00000-0000-0000-0000-00000000ffe0");
+ public static final UUID UUID_CHARACTERISTIC_CMF_DATA_WRITE = UUID.fromString("02f00000-0000-0000-0000-00000000ffe1");
+ public static final UUID UUID_CHARACTERISTIC_CMF_DATA_READ = UUID.fromString("02f00000-0000-0000-0000-00000000ffe2");
+
+ // An a5 byte is used a lot in single payloads, probably as a "proof of encryption"?
+ public static final byte A5 = (byte) 0xa5;
+
+ private CmfCharacteristic characteristicCommandRead;
+ private CmfCharacteristic characteristicCommandWrite;
+ private CmfCharacteristic characteristicDataRead;
+ private CmfCharacteristic characteristicDataWrite;
+
+ private final CmfActivitySync activitySync = new CmfActivitySync(this);
+ private final CmfPreferences preferences = new CmfPreferences(this);
+ private CmfDataUploader dataUploader;
+
+ protected MediaManager mediaManager = null;
+
+ public CmfWatchProSupport() {
+ super(LOG);
+ addSupportedService(UUID_SERVICE_CMF_CMD);
+ addSupportedService(UUID_SERVICE_CMF_DATA);
+ }
+
+ @Override
+ public boolean useAutoConnect() {
+ return true;
+ }
+
+ @Override
+ public boolean getImplicitCallbackModify() {
+ return false;
+ }
+
+ @Override
+ public boolean getSendWriteRequestResponse() {
+ return true;
+ }
+
+ @Override
+ protected TransactionBuilder initializeDevice(final TransactionBuilder builder) {
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
+
+ final BluetoothGattCharacteristic btCharacteristicCommandRead = getCharacteristic(UUID_CHARACTERISTIC_CMF_COMMAND_READ);
+ if (btCharacteristicCommandRead == null) {
+ LOG.warn("Characteristic command read is null, will attempt to reconnect");
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext()));
+ return builder;
+ }
+
+ final BluetoothGattCharacteristic btCharacteristicCommandWrite = getCharacteristic(UUID_CHARACTERISTIC_CMF_COMMAND_WRITE);
+ if (btCharacteristicCommandWrite == null) {
+ LOG.warn("Characteristic command write is null, will attempt to reconnect");
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext()));
+ return builder;
+ }
+
+ final BluetoothGattCharacteristic btCharacteristicDataWrite = getCharacteristic(UUID_CHARACTERISTIC_CMF_DATA_WRITE);
+ if (btCharacteristicDataWrite == null) {
+ LOG.warn("Characteristic data write is null, will attempt to reconnect");
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext()));
+ return builder;
+ }
+
+ final BluetoothGattCharacteristic btCharacteristicDataRead = getCharacteristic(UUID_CHARACTERISTIC_CMF_DATA_READ);
+ if (btCharacteristicDataRead == null) {
+ LOG.warn("Characteristic data read is null, will attempt to reconnect");
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext()));
+ return builder;
+ }
+
+ dataUploader = new CmfDataUploader(this);
+
+ characteristicCommandRead = new CmfCharacteristic(btCharacteristicCommandRead, this);
+ characteristicCommandWrite = new CmfCharacteristic(btCharacteristicCommandWrite, null);
+ characteristicDataRead = new CmfCharacteristic(btCharacteristicDataRead, dataUploader);
+ characteristicDataWrite = new CmfCharacteristic(btCharacteristicDataWrite, null);
+
+ final byte[] secretKey = getSecretKey(getDevice());
+ characteristicCommandRead.setSessionKey(secretKey);
+ characteristicCommandWrite.setSessionKey(secretKey);
+ characteristicDataRead.setSessionKey(secretKey);
+ characteristicDataWrite.setSessionKey(secretKey);
+
+ builder.notify(btCharacteristicCommandWrite, true);
+ builder.notify(btCharacteristicCommandRead, true);
+ builder.notify(btCharacteristicDataWrite, true);
+ builder.notify(btCharacteristicDataRead, true);
+
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.AUTHENTICATING, getContext()));
+
+ sendCommand(builder, CmfCommand.AUTH_PHONE_NAME, ArrayUtils.addAll(new byte[]{A5}, Build.MODEL.getBytes(StandardCharsets.UTF_8)));
+
+ return builder;
+ }
+
+ @Override
+ public void setContext(final GBDevice device, final BluetoothAdapter adapter, final Context context) {
+ super.setContext(device, adapter, context);
+
+ mediaManager = new MediaManager(context);
+ }
+
+ @Override
+ protected Prefs getDevicePrefs() {
+ return super.getDevicePrefs();
+ }
+
+ @Override
+ public boolean onCharacteristicChanged(final BluetoothGatt gatt,
+ final BluetoothGattCharacteristic characteristic) {
+ if (super.onCharacteristicChanged(gatt, characteristic)) {
+ return true;
+ }
+
+ final UUID characteristicUUID = characteristic.getUuid();
+ final byte[] value = characteristic.getValue();
+
+ if (characteristicUUID.equals(characteristicCommandRead.getCharacteristicUUID())) {
+ characteristicCommandRead.onCharacteristicChanged(value);
+ return true;
+ } else if (characteristicUUID.equals(characteristicDataRead.getCharacteristicUUID())) {
+ characteristicDataRead.onCharacteristicChanged(value);
+ return true;
+ }
+
+ LOG.warn("Unhandled characteristic changed: {} {}", characteristicUUID, GB.hexdump(value));
+ return false;
+ }
+
+ @Override
+ public void onMtuChanged(final BluetoothGatt gatt, final int mtu, final int status) {
+ super.onMtuChanged(gatt, mtu, status);
+
+ characteristicCommandRead.setMtu(mtu);
+ characteristicCommandWrite.setMtu(mtu);
+ characteristicDataRead.setMtu(mtu);
+ characteristicDataWrite.setMtu(mtu);
+ }
+
+ @Override
+ public void onCommand(final CmfCommand cmd, final byte[] payload) {
+ if (activitySync.onCommand(cmd, payload)) {
+ return;
+ }
+
+ if (preferences.onCommand(cmd, payload)) {
+ return;
+ }
+
+ switch (cmd) {
+ case AUTH_WATCH_MAC:
+ LOG.debug("Got auth watch mac, requesting nonce");
+ sendCommand("auth request nonce", CmfCommand.AUTH_NONCE_REQUEST, A5);
+ return;
+ case AUTH_NONCE_REPLY:
+ LOG.debug("Got auth nonce");
+
+ try {
+ final MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
+ sha256.update(payload);
+ sha256.update(getSecretKey(getDevice()));
+ final byte[] digest = sha256.digest();
+ final byte[] sessionKey = ArrayUtils.subarray(digest, 0, 16);
+ LOG.debug("New session key: {}", GB.hexdump(sessionKey));
+ characteristicCommandRead.setSessionKey(sessionKey);
+ characteristicCommandWrite.setSessionKey(sessionKey);
+ characteristicDataRead.setSessionKey(sessionKey);
+ characteristicDataWrite.setSessionKey(sessionKey);
+ } catch (final GeneralSecurityException e) {
+ LOG.error("Failed to compute session key from auth nonce", e);
+ return;
+ }
+
+ sendCommand("auth confirm", CmfCommand.AUTHENTICATED_CONFIRM_REQUEST, A5);
+ return;
+ case AUTHENTICATED_CONFIRM_REPLY:
+ LOG.debug("Authentication confirmed, starting phase 2 initialization");
+
+ final TransactionBuilder phase2builder = createTransactionBuilder("phase 2 initialize");
+ setTime(phase2builder);
+ sendCommand(phase2builder, CmfCommand.FIRMWARE_VERSION_GET);
+ sendCommand(phase2builder, CmfCommand.SERIAL_NUMBER_GET);
+ //sendCommand(phase2builder, CmfCommand.STANDING_REMINDER_GET);
+ //sendCommand(phase2builder, CmfCommand.WATER_REMINDER_GET);
+ //sendCommand(phase2builder, CmfCommand.CONTACTS_GET);
+ //sendCommand(phase2builder, CmfCommand.ALARMS_GET);
+ // TODO premature to mark as initialized?
+ phase2builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext()));
+ phase2builder.queue(getQueue());
+ return;
+ case BATTERY:
+ final int battery = payload[0] & 0xff;
+ final boolean charging = payload[1] == 0x01;
+ LOG.debug("Got battery: level={} charging={}", battery, charging);
+ final GBDeviceEventBatteryInfo eventBatteryInfo = new GBDeviceEventBatteryInfo();
+ eventBatteryInfo.level = battery;
+ eventBatteryInfo.state = charging ? BatteryState.BATTERY_CHARGING : BatteryState.BATTERY_NORMAL;
+ evaluateGBDeviceEvent(eventBatteryInfo);
+ return;
+ case FIRMWARE_VERSION_RET:
+ final String[] fwParts = new String[payload.length];
+ for (int i = 0; i < payload.length; i++) {
+ fwParts[i] = String.valueOf(payload[i]);
+ }
+ final String fw = String.join(".", fwParts);
+ LOG.debug("Got firmware version: {}", fw);
+ final GBDeviceEventVersionInfo gbDeviceEventVersionInfo = new GBDeviceEventVersionInfo();
+ gbDeviceEventVersionInfo.fwVersion = fw;
+ gbDeviceEventVersionInfo.fwVersion2 = "N/A";
+ //gbDeviceEventVersionInfo.hwVersion = "?"; // TODO how?
+ evaluateGBDeviceEvent(gbDeviceEventVersionInfo);
+ return;
+ case SERIAL_NUMBER_RET:
+ if (payload.length != (payload[0] & 0xff) + 1) {
+ LOG.warn("Unexpected serial number payload length: {}, expected {}", payload.length, (payload[0] & 0xff));
+ return;
+ }
+ final String serialNumber = new String(ArrayUtils.subarray(payload, 1, payload.length - 2));
+ LOG.debug("Got serial number: {}", serialNumber);
+ final GBDeviceEventUpdateDeviceInfo gbDeviceEventUpdateDeviceInfo = new GBDeviceEventUpdateDeviceInfo("SERIAL: ", serialNumber);
+ evaluateGBDeviceEvent(gbDeviceEventUpdateDeviceInfo);
+ return;
+ case FIND_PHONE:
+ final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone();
+ if (payload[0] == 1) {
+ findPhoneEvent.event = GBDeviceEventFindPhone.Event.START;
+ } else {
+ findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP;
+ }
+ evaluateGBDeviceEvent(findPhoneEvent);
+ return;
+ case MUSIC_INFO_ACK:
+ LOG.debug("Got music info ack");
+ break;
+ case MUSIC_BUTTON:
+ final GBDeviceEventMusicControl deviceEventMusicControl = new GBDeviceEventMusicControl();
+ switch (BLETypeConversions.toUint16(payload)) {
+ case 0x0003:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEDOWN;
+ break;
+ case 0x0103:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEUP;
+ break;
+ case 0x0001:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PAUSE;
+ break;
+ case 0x0101:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PLAY;
+ break;
+ case 0x0102:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.NEXT;
+ break;
+ case 0x0002:
+ deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PREVIOUS;
+ break;
+ default:
+ LOG.warn("Unexpected media button key {}", GB.hexdump(payload));
+ return;
+ }
+ LOG.debug("Got media button {}", deviceEventMusicControl.event);
+ evaluateGBDeviceEvent(deviceEventMusicControl);
+ break;
+ default:
+ LOG.warn("Unhandled command: {}", cmd);
+ }
+ }
+
+ public void sendCommand(final String taskName, final CmfCommand cmd, final byte... payload) {
+ final TransactionBuilder builder = createTransactionBuilder(taskName);
+ sendCommand(builder, cmd, payload);
+ builder.queue(getQueue());
+ }
+
+ public void sendCommand(final TransactionBuilder builder, final CmfCommand cmd, final byte... payload) {
+ characteristicCommandWrite.sendCommand(builder, cmd, payload);
+ }
+
+ public void sendData(final String taskName, final CmfCommand cmd, final byte... payload) {
+ final TransactionBuilder builder = createTransactionBuilder(taskName);
+ characteristicDataWrite.sendCommand(builder, cmd, payload);
+ builder.queue(getQueue());
+ }
+
+ private static byte[] getSecretKey(final GBDevice device) {
+ final byte[] authKeyBytes = new byte[16];
+
+ final SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());
+
+ final String authKey = sharedPrefs.getString("authkey", "").trim();
+ if (StringUtils.isNotBlank(authKey)) {
+ final byte[] srcBytes;
+ // Allow both with and without 0x, to avoid user mistakes
+ if (authKey.length() == 34 && authKey.startsWith("0x")) {
+ srcBytes = GB.hexStringToByteArray(authKey.trim().substring(2));
+ } else {
+ srcBytes = GB.hexStringToByteArray(authKey.trim());
+ }
+ System.arraycopy(srcBytes, 0, authKeyBytes, 0, Math.min(srcBytes.length, 16));
+ }
+
+ return authKeyBytes;
+ }
+
+ @Override
+ public void onNotification(final NotificationSpec notificationSpec) {
+ if (!getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS, true)) {
+ LOG.debug("App notifications disabled - ignoring");
+ return;
+ }
+
+ final String senderOrTitle = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.getFirstOf(
+ notificationSpec.sender,
+ notificationSpec.title
+ );
+
+ final String body = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.getFirstOf(notificationSpec.body, "");
+
+ final byte[] senderOrTitleBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(senderOrTitle, 20); // TODO confirm max
+ final byte[] bodyBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(body, 128); // TODO confirm max
+
+ final ByteBuffer buf = ByteBuffer.allocate(7 + senderOrTitleBytes.length + bodyBytes.length)
+ .order(ByteOrder.BIG_ENDIAN);
+
+ buf.put(CmfNotificationIcon.forNotification(notificationSpec).getCode());
+ buf.put((byte) 0x00); // ?
+ buf.putInt((int) (notificationSpec.when / 1000));
+ buf.put((byte) senderOrTitleBytes.length);
+ buf.put(senderOrTitleBytes);
+ buf.put(bodyBytes);
+
+ sendCommand("send notification", CmfCommand.APP_NOTIFICATION, buf.array());
+ }
+
+ @Override
+ public void onSetContacts(final ArrayList extends Contact> contacts) {
+ final ByteBuffer buf = ByteBuffer.allocate(57 * contacts.size()).order(ByteOrder.BIG_ENDIAN);
+
+ for (final Contact contact : contacts) {
+ final byte[] nameBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(contact.getName(), 32);
+ buf.put(nameBytes);
+ buf.put(new byte[32 - nameBytes.length]);
+
+ final byte[] numberBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(contact.getNumber(), 25);
+ buf.put(numberBytes);
+ buf.put(new byte[25 - numberBytes.length]);
+ }
+
+ sendCommand("set contacts", CmfCommand.CONTACTS_SET, ArrayUtils.subarray(buf.array(), 0, buf.position()));
+ }
+
+ @Override
+ public void onSetTime() {
+ final TransactionBuilder builder = createTransactionBuilder("set time");
+ setTime(builder);
+ builder.queue(getQueue());
+ }
+
+ private void setTime(final TransactionBuilder builder) {
+ final Calendar cal = Calendar.getInstance();
+ final ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN);
+ buf.putInt((int) (cal.getTimeInMillis() / 1000));
+ buf.putInt(TimeZone.getDefault().getOffset(cal.getTimeInMillis()));
+ sendCommand(builder, CmfCommand.TIME, buf.array());
+ }
+
+ @Override
+ public void onSetAlarms(final ArrayList extends Alarm> alarms) {
+ final ByteBuffer buf = ByteBuffer.allocate(40 * alarms.size()).order(ByteOrder.BIG_ENDIAN);
+
+ int i = 0;
+ for (final Alarm alarm : alarms) {
+ if (alarm.getUnused()) {
+ continue;
+ }
+
+ buf.putInt(alarm.getHour() * 3600 + alarm.getMinute() * 60);
+ buf.put((byte) i++);
+ buf.put((byte) (alarm.getEnabled() ? 0x01 : 0x00));
+ buf.put((byte) alarm.getRepetition());
+ buf.put((byte) 0xff); // ?
+ buf.put(new byte[24]); // ?
+
+ // alarm labels do not show up on watch, even in official app
+ final byte[] labelBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(alarm.getTitle(), 8);
+ buf.put(new byte[8 - labelBytes.length]);
+ buf.put(labelBytes);
+ }
+
+ sendCommand("set alarms", CmfCommand.ALARMS_SET, ArrayUtils.subarray(buf.array(), 0, buf.position()));
+ }
+
+ @Override
+ public void onSetCallState(final CallSpec callSpec) {
+ super.onSetCallState(callSpec); // TODO onSetCallState
+ }
+
+ @Override
+ public void onSetCannedMessages(final CannedMessagesSpec cannedMessagesSpec) {
+ super.onSetCannedMessages(cannedMessagesSpec); // TODO onSetCannedMessages
+ }
+
+ @Override
+ public void onSetMusicState(final MusicStateSpec stateSpec) {
+ if (mediaManager.onSetMusicState(stateSpec)) {
+ sendMusicStateToDevice();
+ }
+ }
+
+ @Override
+ public void onSetPhoneVolume(final float ignoredVolume) {
+ sendMusicStateToDevice();
+ }
+
+ @Override
+ public void onSetMusicInfo(final MusicSpec musicSpec) {
+ if (mediaManager.onSetMusicInfo(musicSpec)) {
+ sendMusicStateToDevice();
+ }
+ }
+
+ private void sendMusicStateToDevice() {
+ final MusicSpec musicSpec = mediaManager.getBufferMusicSpec();
+ final MusicStateSpec musicStateSpec = mediaManager.getBufferMusicStateSpec();
+
+ final byte stateByte;
+ if (musicSpec == null || musicStateSpec == null) {
+ stateByte = 0x00;
+ } else if (musicStateSpec.state == MusicStateSpec.STATE_PLAYING) {
+ stateByte = 0x01;
+ } else {
+ stateByte = 0x02;
+ }
+
+ final byte[] track;
+ final byte[] artist;
+
+ if (musicSpec != null) {
+ track = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(musicSpec.track, 63);
+ artist = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(musicSpec.artist, 63);
+ } else {
+ track = new byte[0];
+ artist = new byte[0];
+ }
+
+ final AudioManager audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE);
+ final int volumeLevel = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
+ final int volumeMax = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
+
+ final ByteBuffer buf = ByteBuffer.allocate(131);
+ buf.put(stateByte);
+ buf.put((byte) volumeLevel);
+ buf.put((byte) volumeMax);
+ buf.put(track);
+ buf.put(new byte[64 - track.length]);
+ buf.put(artist);
+ buf.put(new byte[64 - artist.length]);
+
+ sendCommand("set music info", CmfCommand.MUSIC_INFO_SET, buf.array());
+ }
+
+ @Override
+ public void onInstallApp(final Uri uri) {
+ dataUploader.onInstallApp(uri);
+ }
+
+ @Override
+ public void onAppInfoReq() {
+ super.onAppInfoReq(); // TODO onAppInfoReq
+ }
+
+ @Override
+ public void onAppStart(final UUID uuid, final boolean start) {
+ super.onAppStart(uuid, start); // TODO onAppStart for watchfaces
+ }
+
+ @Override
+ public void onFetchRecordedData(final int dataTypes) {
+ sendCommand("fetch recorded data step 1", CmfCommand.ACTIVITY_FETCH_1, A5);
+ }
+
+ @Override
+ public void onReset(final int flags) {
+ if ((flags & GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) != 0) {
+ sendCommand("factory reset", CmfCommand.FACTORY_RESET, A5);
+ } else {
+ LOG.warn("Unknown reset flags: {}", String.format("0x%x", flags));
+ }
+ }
+
+ @Override
+ public void onSetHeartRateMeasurementInterval(final int seconds) {
+ preferences.onSetHeartRateMeasurementInterval(seconds);
+ }
+
+ @Override
+ public void onSendConfiguration(final String config) {
+ preferences.onSendConfiguration(config);
+ }
+
+ @Override
+ public void onFindDevice(final boolean start) {
+ if (!start) {
+ return;
+ }
+
+ sendCommand("find device", CmfCommand.FIND_WATCH);
+ }
+
+ @Override
+ public void onSendWeather(final WeatherSpec weatherSpec) {
+ // TODO onSendWeather
+ }
+
+ @Override
+ public void onTestNewFunction() {
+
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java
index f553034eb..43a201fea 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java
@@ -90,7 +90,7 @@ public class FetchSpo2NormalOperation extends AbstractRepeatingFetchOperation {
final HuamiSpo2Sample sample = new HuamiSpo2Sample();
sample.setTimestamp(timestamp.getTimeInMillis());
- sample.setType(autoMeasurement ? Spo2Sample.Type.AUTOMATIC : Spo2Sample.Type.MANUAL);
+ sample.setTypeNum((autoMeasurement ? Spo2Sample.Type.AUTOMATIC : Spo2Sample.Type.MANUAL).getNum());
sample.setSpo2(spo2);
samples.add(sample);
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java
index ceabf1390..b6b43c6f8 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java
@@ -77,7 +77,7 @@ public class FetchStressAutoOperation extends AbstractRepeatingFetchOperation {
final HuamiStressSample sample = new HuamiStressSample();
sample.setTimestamp(timestamp.getTimeInMillis());
- sample.setType(StressSample.Type.AUTOMATIC);
+ sample.setTypeNum(StressSample.Type.AUTOMATIC.getNum());
sample.setStress(stress);
samples.add(sample);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java
index 2bb0027f3..f13fe7027 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java
@@ -85,7 +85,7 @@ public class FetchStressManualOperation extends AbstractRepeatingFetchOperation
final HuamiStressSample sample = new HuamiStressSample();
sample.setTimestamp(timestamp.getTimeInMillis());
- sample.setType(StressSample.Type.MANUAL);
+ sample.setTypeNum(StressSample.Type.MANUAL.getNum());
sample.setStress(stress);
samples.add(sample);
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/StressPacketParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/StressPacketParser.java
index c7884f6ce..7d6c1a7f8 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/StressPacketParser.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/StressPacketParser.java
@@ -60,7 +60,7 @@ public class StressPacketParser extends OneBytePerSamplePacketParser {
gbSample.setUserId(userId);
gbSample.setTimestamp(currentSampleDate.getTime());
gbSample.setStress(rawSample);
- gbSample.setType(StressSample.Type.AUTOMATIC);
+ gbSample.setTypeNum(StressSample.Type.AUTOMATIC.getNum());
samples.add(gbSample);
} else {
LOG.debug("Discard stress value as out of range: " + rawSample);
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index e1042e8dd..f416de506 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -1201,6 +1201,237 @@
- indoor_ice_skating
+
+ - @string/activity_type_indoor_running
+ - @string/activity_type_outdoor_running
+ - @string/activity_type_outdoor_walking
+ - @string/activity_type_indoor_walking
+ - @string/activity_type_outdoor_cycling
+ - @string/activity_type_indoor_cycling
+ - @string/activity_type_mountain_hike
+ - @string/activity_type_hiking
+ - @string/activity_type_cross_trainer
+ - @string/activity_type_free_training
+ - @string/activity_type_strength_training
+ - @string/activity_type_yoga
+ - @string/activity_type_boxing
+ - @string/activity_type_rower
+ - @string/activity_type_dynamic_cycle
+ - @string/activity_type_stair_stepper
+ - @string/activity_type_treadmill
+ - @string/activity_type_hiit
+ - @string/activity_type_fitness_exercises
+ - @string/activity_type_jump_roping
+ - @string/activity_type_pilates
+ - @string/activity_type_crossfit
+ - @string/activity_type_functional_training
+ - @string/activity_type_physical_training
+ - @string/activity_type_taekwondo
+ - @string/activity_type_cross_country_running
+ - @string/activity_type_karate
+ - @string/activity_type_fencing
+ - @string/activity_type_core_training
+ - @string/activity_type_kendo
+ - @string/activity_type_horizontal_bar
+ - @string/activity_type_parallel_bar
+ - @string/activity_type_cooldown
+ - @string/activity_type_cross_training
+ - @string/activity_type_sit_ups
+ - @string/activity_type_fitness_gaming
+ - @string/activity_type_aerobic_exercise
+ - @string/activity_type_rolling
+ - @string/activity_type_flexibility
+ - @string/activity_type_gymnastics
+ - @string/activity_type_track_and_field
+ - @string/activity_type_push_ups
+ - @string/activity_type_battle_rope
+ - @string/activity_type_smith_machine
+ - @string/activity_type_pull_ups
+ - @string/activity_type_plank
+ - @string/activity_type_javelin
+ - @string/activity_type_long_jump
+ - @string/activity_type_high_jump
+ - @string/activity_type_trampoline
+ - @string/activity_type_dumbbell
+ - @string/activity_type_belly_dance
+ - @string/activity_type_jazz_dance
+ - @string/activity_type_latin_dance
+ - @string/activity_type_ballet
+ - @string/activity_type_street_dance
+ - @string/activity_type_zumba
+ - @string/activity_type_other_dance
+ - @string/activity_type_roller_skating
+ - @string/activity_type_martial_arts
+ - @string/activity_type_tai_chi
+ - @string/activity_type_hula_hooping
+ - @string/activity_type_disc_sports
+ - @string/activity_type_darts
+ - @string/activity_type_archery
+ - @string/activity_type_horse_riding
+ - @string/activity_type_kite_flying
+ - @string/activity_type_swing
+ - @string/activity_type_stairs
+ - @string/activity_type_fishing
+ - @string/activity_type_hand_cycling
+ - @string/activity_type_mind_and_body
+ - @string/activity_type_wrestling
+ - @string/activity_type_kabaddi
+ - @string/activity_type_karting
+ - @string/activity_type_badminton
+ - @string/activity_type_table_tennis
+ - @string/activity_type_tennis
+ - @string/activity_type_billiards
+ - @string/activity_type_bowling
+ - @string/activity_type_volleyball
+ - @string/activity_type_shuttlecock
+ - @string/activity_type_handball
+ - @string/activity_type_baseball
+ - @string/activity_type_softball
+ - @string/activity_type_cricket
+ - @string/activity_type_rugby
+ - @string/activity_type_hockey
+ - @string/activity_type_squash
+ - @string/activity_type_dodgeball
+ - @string/activity_type_soccer
+ - @string/activity_type_basketball
+ - @string/activity_type_australian_football
+ - @string/activity_type_golf
+ - @string/activity_type_pickleball
+ - @string/activity_type_lacross
+ - @string/activity_type_shot
+ - @string/activity_type_sailing
+ - @string/activity_type_surfing
+ - @string/activity_type_jet_skiing
+ - @string/activity_type_skating
+ - @string/activity_type_ice_hockey
+ - @string/activity_type_curling
+ - @string/activity_type_snowboarding
+ - @string/activity_type_cross_country_skiing
+ - @string/activity_type_snow_sports
+ - @string/activity_type_skiing
+ - @string/activity_type_skateboarding
+ - @string/activity_type_rock_climbing
+ - @string/activity_type_hunting
+
+
+
+ - indoor_running
+ - outdoor_running
+ - outdoor_walking
+ - indoor_walking
+ - outdoor_cycling
+ - indoor_cycling
+ - mountain_hike
+ - hiking
+ - cross_trainer
+ - free_training
+ - strength_training
+ - yoga
+ - boxing
+ - rower
+ - dynamic_cycle
+ - stair_stepper
+ - treadmill
+ - hiit
+ - fitness_exercises
+ - jump_roping
+ - pilates
+ - crossfit
+ - functional_training
+ - physical_training
+ - taekwondo
+ - cross_country_running
+ - karate
+ - fencing
+ - core_training
+ - kendo
+ - horizontal_bar
+ - parallel_bar
+ - cooldown
+ - cross_training
+ - sit_ups
+ - fitness_gaming
+ - aerobic_exercise
+ - rolling
+ - flexibility
+ - gymnastics
+ - track_and_field
+ - push_ups
+ - battle_rope
+ - smith_machine
+ - pull_ups
+ - plank
+ - javelin
+ - long_jump
+ - high_jump
+ - trampoline
+ - dumbbell
+ - belly_dance
+ - jazz_dance
+ - latin_dance
+ - ballet
+ - street_dance
+ - zumba
+ - other_dance
+ - roller_skating
+ - martial_arts
+ - tai_chi
+ - hula_hooping
+ - disc_sports
+ - darts
+ - archery
+ - horse_riding
+ - kite_flying
+ - swing
+ - stairs
+ - fishing
+ - hand_cycling
+ - mind_and_body
+ - wrestling
+ - kabaddi
+ - karting
+ - badminton
+ - table_tennis
+ - tennis
+ - billiards
+ - bowling
+ - volleyball
+ - shuttlecock
+ - handball
+ - baseball
+ - softball
+ - cricket
+ - rugby
+ - hockey
+ - squash
+ - dodgeball
+ - soccer
+ - basketball
+ - australian_football
+ - golf
+ - pickleball
+ - lacross
+ - shot
+ - sailing
+ - surfing
+ - jet_skiing
+ - skating
+ - ice_hockey
+ - curling
+ - snowboarding
+ - cross_country_skiing
+ - snow_sports
+ - skiing
+ - skateboarding
+ - rock_climbing
+ - hunting
+
+
+
+ - indoor_run
+ - outdoor_run
+
+
- @string/activity_type_outdoor_running
- @string/activity_type_hiking
@@ -2389,6 +2620,26 @@
- 150
+
+ - @string/off
+ - @string/heartrate_bpm_155
+ - @string/heartrate_bpm_165
+ - @string/heartrate_bpm_175
+ - @string/heartrate_bpm_185
+ - @string/heartrate_bpm_195
+ - @string/heartrate_bpm_205
+
+
+
+ - 0
+ - 155
+ - 165
+ - 175
+ - 185
+ - 195
+ - 205
+
+
- @string/off
- @string/heartrate_bpm_40
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 078a7ed5a..c25a1f285 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -712,6 +712,12 @@
140 bpm
145 bpm
150 bpm
+ 155 bpm
+ 165 bpm
+ 175 bpm
+ 185 bpm
+ 195 bpm
+ 205 bpm
80%
85%
90%
@@ -785,6 +791,7 @@
Vibrate the band when the heart rate is over a threshold, without any obvious physical activity in the last 10 minutes. This feature is experimental, and was not extensively tested.
Heart rate alert threshold
High heart rate alert threshold
+ High activity heart rate alert threshold
Low heart rate alert threshold
Stress monitoring
Monitor stress level while resting
@@ -970,6 +977,7 @@
The band will vibrate when you have been inactive for a while
Inactivity threshold (in minutes)
Disable inactivity warnings for a time interval
+ Disable hydration warnings for a time interval
Heart Rate Monitoring
Configure heart rate monitoring
Phone Silent Mode
@@ -1237,7 +1245,83 @@
Device not worn
Running
Outdoor Running
+ Indoor Running
+ Mountain Hike
+ Cross trainer
+ Free training
+ Rower
+ Dynamic cycle
+ Stair stepper
+ Fitness exercises
+ Crossfit
+ Functional training
+ Physical training
+ Taekwondo
+ Cross country running
+ Karate
+ Fencing
+ Kendo
+ Horizontal bar
+ Parallel bar
+ Cooldown
+ Cross training
+ Sit ups
+ Fitness gaming
+ Aerobic exercise
+ Rolling
+ Flexibility
+ Track and field
+ Push ups
+ Battle rope
+ Smith machine
+ Pull ups
+ Plank
+ Javelin
+ Long jump
+ High jump
+ Trampoline
+ Dumbbell
+ Belly dance
+ Jazz dance
+ Latin dance
+ Ballet
+ Other dance
+ Roller skating
+ Martial arts
+ Tai chi
+ Hula hooping
+ Disc sports
+ Darts
+ Archery
+ Horse riding
+ Kite Flying
+ Swing
+ Stairs
+ Fishing
+ Hand cycling
+ Mind and body
+ Kabaddi
+ Karting
+ Billiards
+ Shuttlecock
+ Softball
+ Dodgeball
+ Australian football
+ Pickleball
+ Lacross
+ Shot
+ Sailing
+ Jet skiing
+ Skating
+ Ice hockey
+ Curling
+ Cross country skiing
+ Snow sports
+ Skateboarding
+ Rock climbing
+ Hunting
Walking
+ Outdoor Walking
Indoor Walking
Surfing
Windsurfing
@@ -1991,6 +2075,7 @@
Nothing Ear (1)
Nothing Ear (2)
Nothing Ear (Stick)
+ CMF Watch Pro
Galaxy Buds
Galaxy Buds Live
Galaxy Buds Pro
diff --git a/app/src/main/res/xml/devicesettings_heartrate_sleep_alert_activity_stress_spo2.xml b/app/src/main/res/xml/devicesettings_heartrate_sleep_alert_activity_stress_spo2.xml
index b2a84fc37..2cbe5135c 100644
--- a/app/src/main/res/xml/devicesettings_heartrate_sleep_alert_activity_stress_spo2.xml
+++ b/app/src/main/res/xml/devicesettings_heartrate_sleep_alert_activity_stress_spo2.xml
@@ -70,6 +70,15 @@
android:key="heartrate_alert_low_threshold"
android:summary="%s"
android:title="@string/prefs_heartrate_alert_low_threshold" />
+
+
diff --git a/app/src/main/res/xml/devicesettings_hydration_reminder_dnd.xml b/app/src/main/res/xml/devicesettings_hydration_reminder_dnd.xml
new file mode 100644
index 000000000..a0295022e
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_hydration_reminder_dnd.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/devicesettings_workout_activity_types.xml b/app/src/main/res/xml/devicesettings_workout_activity_types.xml
new file mode 100644
index 000000000..5213d34e2
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_workout_activity_types.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommandTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommandTest.java
new file mode 100644
index 000000000..678be855e
--- /dev/null
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommandTest.java
@@ -0,0 +1,39 @@
+/* Copyright (C) 2024 José Rebelo
+
+ 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.cmfwatchpro;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class CmfCommandTest {
+ @Test
+ public void commandEnumCheckNoOverlap() {
+ // Ensure that no 2 commands overlap in codes
+ final Map knownCodes = new HashMap<>();
+ for (final CmfCommand cmd : CmfCommand.values()) {
+ final Boolean existingCode = knownCodes.put(
+ String.format("cmd1=0x%04x cmd2=0x%04x", cmd.getCmd1(), cmd.getCmd2()),
+ true
+ );
+ assertNull("Commands with overlapping codes", existingCode);
+ }
+ }
+}