mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2025-01-10 17:11:56 +01:00
Compare commits
10 Commits
207a2a9b01
...
cf0e8be816
Author | SHA1 | Date | |
---|---|---|---|
|
cf0e8be816 | ||
|
b0ff9eae88 | ||
|
a53d59f907 | ||
|
b860ab116f | ||
|
2a41b77ffc | ||
|
a64317fe7a | ||
|
e974d00104 | ||
|
0ed9e5b1a8 | ||
|
0e5545191d | ||
|
5999eb01d0 |
@ -45,7 +45,7 @@ public class GBDaoGenerator {
|
|||||||
|
|
||||||
|
|
||||||
public static void main(String[] args) throws Exception {
|
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 userAttributes = addUserAttributes(schema);
|
||||||
Entity user = addUserInfo(schema, userAttributes);
|
Entity user = addUserInfo(schema, userAttributes);
|
||||||
@ -75,6 +75,13 @@ public class GBDaoGenerator {
|
|||||||
addXiaomiSleepStageSamples(schema, user, device);
|
addXiaomiSleepStageSamples(schema, user, device);
|
||||||
addXiaomiManualSamples(schema, user, device);
|
addXiaomiManualSamples(schema, user, device);
|
||||||
addXiaomiDailySummarySamples(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);
|
addPebbleHealthActivitySample(schema, user, device);
|
||||||
addPebbleHealthActivityKindOverlay(schema, user, device);
|
addPebbleHealthActivityKindOverlay(schema, user, device);
|
||||||
addPebbleMisfitActivitySample(schema, user, device);
|
addPebbleMisfitActivitySample(schema, user, device);
|
||||||
@ -275,7 +282,7 @@ public class GBDaoGenerator {
|
|||||||
private static Entity addHuamiStressSample(Schema schema, Entity user, Entity device) {
|
private static Entity addHuamiStressSample(Schema schema, Entity user, Entity device) {
|
||||||
Entity stressSample = addEntity(schema, "HuamiStressSample");
|
Entity stressSample = addEntity(schema, "HuamiStressSample");
|
||||||
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
|
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
|
||||||
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE);
|
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
|
||||||
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
|
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
|
||||||
return stressSample;
|
return stressSample;
|
||||||
}
|
}
|
||||||
@ -283,7 +290,7 @@ public class GBDaoGenerator {
|
|||||||
private static Entity addHuamiSpo2Sample(Schema schema, Entity user, Entity device) {
|
private static Entity addHuamiSpo2Sample(Schema schema, Entity user, Entity device) {
|
||||||
Entity spo2sample = addEntity(schema, "HuamiSpo2Sample");
|
Entity spo2sample = addEntity(schema, "HuamiSpo2Sample");
|
||||||
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
|
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
|
||||||
spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE);
|
spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
|
||||||
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
|
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
|
||||||
return spo2sample;
|
return spo2sample;
|
||||||
}
|
}
|
||||||
@ -407,6 +414,64 @@ public class GBDaoGenerator {
|
|||||||
return sample;
|
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) {
|
private static void addHeartRateProperties(Entity activitySample) {
|
||||||
activitySample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetterAndSetter(OVERRIDE);
|
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) {
|
private static Entity addWena3StressSample(Schema schema, Entity user, Entity device) {
|
||||||
Entity stressSample = addEntity(schema, "Wena3StressSample");
|
Entity stressSample = addEntity(schema, "Wena3StressSample");
|
||||||
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
|
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
|
||||||
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE);
|
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
|
||||||
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
|
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
|
||||||
return stressSample;
|
return stressSample;
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
package nodomain.freeyourgadget.gadgetbridge.activities;
|
package nodomain.freeyourgadget.gadgetbridge.activities;
|
||||||
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.text.InputFilter;
|
||||||
import android.text.format.DateFormat;
|
import android.text.format.DateFormat;
|
||||||
import android.view.MenuItem;
|
import android.view.MenuItem;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
@ -132,10 +133,15 @@ public class AlarmDetails extends AbstractGBActivity {
|
|||||||
int snoozeVisibility = supportsSnoozing() ? View.VISIBLE : View.GONE;
|
int snoozeVisibility = supportsSnoozing() ? View.VISIBLE : View.GONE;
|
||||||
cbSnooze.setVisibility(snoozeVisibility);
|
cbSnooze.setVisibility(snoozeVisibility);
|
||||||
|
|
||||||
int descriptionVisibility = supportsDescription() ? View.VISIBLE : View.GONE;
|
title.setVisibility(supportsTitle() ? View.VISIBLE : View.GONE);
|
||||||
title.setVisibility(descriptionVisibility);
|
|
||||||
title.setText(alarm.getTitle());
|
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());
|
description.setText(alarm.getDescription());
|
||||||
|
|
||||||
cbMonday.setChecked(alarm.getRepetition(Alarm.ALARM_MON));
|
cbMonday.setChecked(alarm.getRepetition(Alarm.ALARM_MON));
|
||||||
@ -145,7 +151,6 @@ public class AlarmDetails extends AbstractGBActivity {
|
|||||||
cbFriday.setChecked(alarm.getRepetition(Alarm.ALARM_FRI));
|
cbFriday.setChecked(alarm.getRepetition(Alarm.ALARM_FRI));
|
||||||
cbSaturday.setChecked(alarm.getRepetition(Alarm.ALARM_SAT));
|
cbSaturday.setChecked(alarm.getRepetition(Alarm.ALARM_SAT));
|
||||||
cbSunday.setChecked(alarm.getRepetition(Alarm.ALARM_SUN));
|
cbSunday.setChecked(alarm.getRepetition(Alarm.ALARM_SUN));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean supportsSmartWakeup() {
|
private boolean supportsSmartWakeup() {
|
||||||
@ -156,6 +161,22 @@ public class AlarmDetails extends AbstractGBActivity {
|
|||||||
return false;
|
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() {
|
private boolean supportsDescription() {
|
||||||
if (device != null) {
|
if (device != null) {
|
||||||
DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||||
|
@ -177,6 +177,7 @@ public class DeviceSettingsPreferenceConst {
|
|||||||
public static final String PREF_HEARTRATE_MEASUREMENT_INTERVAL = "heartrate_measurement_interval";
|
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_ACTIVITY_MONITORING = "heartrate_activity_monitoring";
|
||||||
public static final String PREF_HEARTRATE_ALERT_ENABLED = "heartrate_alert_enabled";
|
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_HIGH_THRESHOLD = "heartrate_alert_threshold";
|
||||||
public static final String PREF_HEARTRATE_ALERT_LOW_THRESHOLD = "heartrate_alert_low_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";
|
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_ANTILOST_ENABLED = "pref_antilost_enabled";
|
||||||
public static final String PREF_HYDRATION_SWITCH = "pref_hydration_switch";
|
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_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_AMPM_ENABLED = "pref_ampm_enabled";
|
||||||
|
|
||||||
public static final String PREF_SONYSWR12_LOW_VIBRATION = "vibration_preference";
|
public static final String PREF_SONYSWR12_LOW_VIBRATION = "vibration_preference";
|
||||||
|
@ -426,6 +426,9 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
|
|||||||
addPreferenceHandlerFor(PREF_ANTILOST_ENABLED);
|
addPreferenceHandlerFor(PREF_ANTILOST_ENABLED);
|
||||||
addPreferenceHandlerFor(PREF_HYDRATION_SWITCH);
|
addPreferenceHandlerFor(PREF_HYDRATION_SWITCH);
|
||||||
addPreferenceHandlerFor(PREF_HYDRATION_PERIOD);
|
addPreferenceHandlerFor(PREF_HYDRATION_PERIOD);
|
||||||
|
addPreferenceHandlerFor(PREF_HYDRATION_DND);
|
||||||
|
addPreferenceHandlerFor(PREF_HYDRATION_DND_START);
|
||||||
|
addPreferenceHandlerFor(PREF_HYDRATION_DND_END);
|
||||||
addPreferenceHandlerFor(PREF_AMPM_ENABLED);
|
addPreferenceHandlerFor(PREF_AMPM_ENABLED);
|
||||||
addPreferenceHandlerFor(PREF_SOUNDS);
|
addPreferenceHandlerFor(PREF_SOUNDS);
|
||||||
addPreferenceHandlerFor(PREF_CAMERA_REMOTE);
|
addPreferenceHandlerFor(PREF_CAMERA_REMOTE);
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
package nodomain.freeyourgadget.gadgetbridge.capabilities;
|
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_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_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_HIGH_THRESHOLD;
|
||||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_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_HIGH_THRESHOLD);
|
||||||
handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_LOW_THRESHOLD);
|
handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_LOW_THRESHOLD);
|
||||||
|
|
||||||
final ListPreference heartrateMeasurementInterval = handler.findPreference(PREF_HEARTRATE_MEASUREMENT_INTERVAL);
|
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 heartrateAlertHigh = handler.findPreference(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD);
|
||||||
final ListPreference heartrateAlertLow = handler.findPreference(PREF_HEARTRATE_ALERT_LOW_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
|
// 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) {
|
if (hrAlertsNeedSmartOrOne) {
|
||||||
final boolean hrMonitoringIsSmartOrOne = heartrateMeasurementInterval.getValue().equals("60") ||
|
final boolean hrMonitoringIsSmartOrOne = heartrateMeasurementInterval.getValue().equals("60") ||
|
||||||
heartrateMeasurementInterval.getValue().equals("-1");
|
heartrateMeasurementInterval.getValue().equals("-1");
|
||||||
|
|
||||||
heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne);
|
heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne);
|
||||||
heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne);
|
heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne);
|
||||||
|
heartrateAlertActiveHigh.setEnabled(hrMonitoringIsSmartOrOne);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (heartrateMeasurementInterval != null) {
|
if (heartrateMeasurementInterval != null) {
|
||||||
@ -129,6 +133,7 @@ public class HeartRateCapability {
|
|||||||
// Same as above, check if smart or 1 minute
|
// Same as above, check if smart or 1 minute
|
||||||
final boolean hrMonitoringIsSmartOrOne = newVal.equals("60") || newVal.equals("-1");
|
final boolean hrMonitoringIsSmartOrOne = newVal.equals("60") || newVal.equals("-1");
|
||||||
|
|
||||||
|
heartrateAlertActiveHigh.setEnabled(hrMonitoringIsSmartOrOne);
|
||||||
heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne);
|
heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne);
|
||||||
heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne);
|
heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne);
|
||||||
}
|
}
|
||||||
|
@ -436,6 +436,16 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsAlarmTitle(GBDevice device) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getAlarmTitleLimit(GBDevice device) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsAlarmDescription(GBDevice device) {
|
public boolean supportsAlarmDescription(GBDevice device) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -343,6 +343,18 @@ public interface DeviceCoordinator {
|
|||||||
*/
|
*/
|
||||||
boolean supportsAlarmSnoozing();
|
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
|
* Returns true if this device/coordinator supports alarm descriptions
|
||||||
* @return
|
* @return
|
||||||
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<Integer> 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<HeartRateCapability.MeasurementInterval> getHeartRateMeasurementIntervals() {
|
||||||
|
return Arrays.asList(
|
||||||
|
HeartRateCapability.MeasurementInterval.OFF,
|
||||||
|
HeartRateCapability.MeasurementInterval.SMART
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static Prefs getPrefs(final GBDevice device) {
|
||||||
|
return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<String> getPreferenceKeysWithSummary() {
|
||||||
|
return Collections.emptySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final Creator<CmfWatchProSettingsCustomizer> CREATOR = new Creator<CmfWatchProSettingsCustomizer>() {
|
||||||
|
@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) {
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<CmfActivitySample> {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(CmfActivitySampleProvider.class);
|
||||||
|
|
||||||
|
public CmfActivitySampleProvider(final GBDevice device, final DaoSession session) {
|
||||||
|
super(device, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AbstractDao<CmfActivitySample, ?> 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<CmfActivitySample> 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<CmfActivitySample> samples = super.getGBActivitySamples(timestamp_from, timestamp_to, activityType);
|
||||||
|
|
||||||
|
if (!samples.isEmpty()) {
|
||||||
|
convertCumulativeSteps(samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<Integer, CmfActivitySample> 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<CmfActivitySample> 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<CmfActivitySample> 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<Integer, CmfActivitySample> sampleByTs, final int timestamp_from, final int timestamp_to) {
|
||||||
|
final CmfHeartRateSampleProvider heartRateSampleProvider = new CmfHeartRateSampleProvider(getDevice(), getSession());
|
||||||
|
final List<CmfHeartRateSample> 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<Integer, CmfActivitySample> sampleByTs, final int timestamp_from, final int timestamp_to) {
|
||||||
|
final CmfSleepStageSampleProvider sleepStageSampleProvider = new CmfSleepStageSampleProvider(getDevice(), getSession());
|
||||||
|
final List<CmfSleepStageSample> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<CmfHeartRateSample> {
|
||||||
|
public CmfHeartRateSampleProvider(final GBDevice device, final DaoSession session) {
|
||||||
|
super(device, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public AbstractDao<CmfHeartRateSample, ?> 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<CmfSleepSessionSample> {
|
||||||
|
public CmfSleepSessionSampleProvider(final GBDevice device, final DaoSession session) {
|
||||||
|
super(device, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public AbstractDao<CmfSleepSessionSample, ?> 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<CmfSleepStageSample> {
|
||||||
|
public CmfSleepStageSampleProvider(final GBDevice device, final DaoSession session) {
|
||||||
|
super(device, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public AbstractDao<CmfSleepStageSample, ?> 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<CmfSpo2Sample> {
|
||||||
|
public CmfSpo2SampleProvider(final GBDevice device, final DaoSession session) {
|
||||||
|
super(device, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public AbstractDao<CmfSpo2Sample, ?> 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<CmfStressSample> {
|
||||||
|
public CmfStressSampleProvider(final GBDevice device, final DaoSession session) {
|
||||||
|
super(device, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public AbstractDao<CmfStressSample, ?> 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<CmfWorkoutGpsSample> {
|
||||||
|
public CmfWorkoutGpsSampleProvider(final GBDevice device, final DaoSession session) {
|
||||||
|
super(device, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public AbstractDao<CmfWorkoutGpsSample, ?> 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -28,6 +28,7 @@ import java.util.Locale;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
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.DeviceSpecificSettingsCustomizer;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
|
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||||
@ -51,6 +52,11 @@ public class HuamiSettingsCustomizer implements DeviceSpecificSettingsCustomizer
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs) {
|
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
|
// Setup the vibration patterns for all supported notification types
|
||||||
for (HuamiVibrationPatternNotificationType notificationType : HuamiVibrationPatternNotificationType.values()) {
|
for (HuamiVibrationPatternNotificationType notificationType : HuamiVibrationPatternNotificationType.values()) {
|
||||||
final String typeKey = notificationType.name().toLowerCase(Locale.ROOT);
|
final String typeKey = notificationType.name().toLowerCase(Locale.ROOT);
|
||||||
|
@ -126,6 +126,11 @@ public abstract class HuaweiBRCoordinator extends AbstractBLClassicDeviceCoordin
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsAlarmTitle(GBDevice device) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsAlarmDescription(GBDevice device) {
|
public boolean supportsAlarmDescription(GBDevice device) {
|
||||||
// TODO: only name is supported
|
// TODO: only name is supported
|
||||||
|
@ -48,6 +48,7 @@ public final class HuaweiConstants {
|
|||||||
public static final String HO_BAND5_NAME = "honor band 5-";
|
public static final String HO_BAND5_NAME = "honor band 5-";
|
||||||
public static final String HO_BAND6_NAME = "honor band 6-";
|
public static final String HO_BAND6_NAME = "honor band 6-";
|
||||||
public static final String HO_BAND7_NAME = "honor band 7-";
|
public static final String HO_BAND7_NAME = "honor band 7-";
|
||||||
|
public static final String HO_MAGICWATCH2_NAME = "honor magicwatch 2-";
|
||||||
public static final String HU_BAND3E_NAME = "huawei band 3e-";
|
public static final String HU_BAND3E_NAME = "huawei band 3e-";
|
||||||
public static final String HU_BAND4E_NAME = "huawei band 4e-";
|
public static final String HU_BAND4E_NAME = "huawei band 4e-";
|
||||||
public static final String HU_BAND6_NAME = "huawei band 6-";
|
public static final String HU_BAND6_NAME = "huawei band 6-";
|
||||||
|
@ -126,6 +126,11 @@ public abstract class HuaweiLECoordinator extends AbstractBLEDeviceCoordinator i
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsAlarmTitle(GBDevice device) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsAlarmDescription(GBDevice device) {
|
public boolean supportsAlarmDescription(GBDevice device) {
|
||||||
// TODO: only name is supported
|
// TODO: only name is supported
|
||||||
|
@ -180,11 +180,6 @@ public class HuaweiSpo2SampleProvider extends AbstractTimeSampleProvider<HuaweiS
|
|||||||
return typeNum;
|
return typeNum;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setTypeNum(int num) {
|
|
||||||
this.typeNum = num;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setTimestamp(long timestamp) {
|
public void setTimestamp(long timestamp) {
|
||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
|
@ -0,0 +1,83 @@
|
|||||||
|
/* Copyright (C) 2024 Vitaly Tomin, Gaignon Damien
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>. */
|
||||||
|
package nodomain.freeyourgadget.gadgetbridge.devices.huawei.honormagicwatch2;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiSpo2SampleProvider;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
|
||||||
|
|
||||||
|
public class HonorMagicWatch2Coordinator extends HuaweiBRCoordinator {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(HonorMagicWatch2Coordinator.class);
|
||||||
|
|
||||||
|
public HonorMagicWatch2Coordinator() {
|
||||||
|
super();
|
||||||
|
getHuaweiCoordinator().setTransactionCrypted(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getManufacturer() {
|
||||||
|
return "Honor";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DeviceType getDeviceType() {
|
||||||
|
return DeviceType.HONORMAGICWATCH2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Pattern getSupportedDeviceName() {
|
||||||
|
return Pattern.compile(HuaweiConstants.HO_MAGICWATCH2_NAME + ".*", Pattern.CASE_INSENSITIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getBondingStyle(){
|
||||||
|
return BONDING_STYLE_ASK;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsSpo2() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TimeSampleProvider<? extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session) {
|
||||||
|
return new HuaweiSpo2SampleProvider(device, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
|
||||||
|
return getHuaweiCoordinator().genericHuaweiSupportedDeviceSpecificSettings(new int[]{
|
||||||
|
R.xml.devicesettings_spo_automatic_enable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getDeviceNameResource() {
|
||||||
|
return R.string.devicetype_honor_magicwatch2;
|
||||||
|
}
|
||||||
|
}
|
@ -31,6 +31,11 @@ import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
|
|||||||
public class HuaweiTalkBandB6Coordinator extends HuaweiBRCoordinator {
|
public class HuaweiTalkBandB6Coordinator extends HuaweiBRCoordinator {
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(HuaweiTalkBandB6Coordinator.class);
|
private static final Logger LOG = LoggerFactory.getLogger(HuaweiTalkBandB6Coordinator.class);
|
||||||
|
|
||||||
|
public HuaweiTalkBandB6Coordinator() {
|
||||||
|
super();
|
||||||
|
getHuaweiCoordinator().setTransactionCrypted(false);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public DeviceType getDeviceType() {
|
public DeviceType getDeviceType() {
|
||||||
return DeviceType.HUAWEITALKBANDB6;
|
return DeviceType.HUAWEITALKBANDB6;
|
||||||
|
@ -37,6 +37,7 @@ public class HuaweiWatchGT2Coordinator extends HuaweiBRCoordinator {
|
|||||||
|
|
||||||
public HuaweiWatchGT2Coordinator() {
|
public HuaweiWatchGT2Coordinator() {
|
||||||
super();
|
super();
|
||||||
|
getHuaweiCoordinator().setTransactionCrypted(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -33,6 +33,7 @@ public class HuaweiWatchGT3Coordinator extends HuaweiBRCoordinator {
|
|||||||
|
|
||||||
public HuaweiWatchGT3Coordinator() {
|
public HuaweiWatchGT3Coordinator() {
|
||||||
super();
|
super();
|
||||||
|
getHuaweiCoordinator().setTransactionCrypted(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -156,6 +156,11 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsAlarmTitle(GBDevice device) {
|
||||||
|
return isHybridHR();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsAlarmDescription(GBDevice device) {
|
public boolean supportsAlarmDescription(GBDevice device) {
|
||||||
return isHybridHR();
|
return isHybridHR();
|
||||||
|
@ -120,6 +120,11 @@ public class WithingsSteelHRDeviceCoordinator extends AbstractDeviceCoordinator
|
|||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsAlarmTitle(GBDevice device) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsAlarmDescription(GBDevice device) {
|
public boolean supportsAlarmDescription(GBDevice device) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -45,6 +45,11 @@ public class XiaomiSettingsCustomizer implements DeviceSpecificSettingsCustomize
|
|||||||
activityMonitoringPref.setVisible(false);
|
activityMonitoringPref.setVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Preference hrAlertActivePref = handler.findPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
|
||||||
|
if (hrAlertActivePref != null) {
|
||||||
|
hrAlertActivePref.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
populateOrHideListPreference(HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE, handler, prefs);
|
populateOrHideListPreference(HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE, handler, prefs);
|
||||||
|
|
||||||
hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_DISPLAY, Arrays.asList(
|
hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_DISPLAY, Arrays.asList(
|
||||||
|
@ -22,18 +22,15 @@ import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
|
|||||||
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
||||||
|
|
||||||
public abstract class AbstractSpo2Sample extends AbstractTimeSample implements Spo2Sample {
|
public abstract class AbstractSpo2Sample extends AbstractTimeSample implements Spo2Sample {
|
||||||
public abstract int getTypeNum();
|
public int getTypeNum() {
|
||||||
public abstract void setTypeNum(int num);
|
return Spo2Sample.Type.UNKNOWN.getNum();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Type getType() {
|
public Type getType() {
|
||||||
return Type.fromNum(getTypeNum());
|
return Type.fromNum(getTypeNum());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setType(final Type type) {
|
|
||||||
setTypeNum(type.getNum());
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
@ -22,18 +22,15 @@ import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
|
|||||||
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
|
||||||
|
|
||||||
public abstract class AbstractStressSample extends AbstractTimeSample implements StressSample {
|
public abstract class AbstractStressSample extends AbstractTimeSample implements StressSample {
|
||||||
public abstract int getTypeNum();
|
public int getTypeNum() {
|
||||||
public abstract void setTypeNum(int num);
|
return Type.UNKNOWN.getNum();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Type getType() {
|
public Type getType() {
|
||||||
return Type.fromNum(getTypeNum());
|
return Type.fromNum(getTypeNum());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setType(final Type type) {
|
|
||||||
setTypeNum(type.getNum());
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
@ -36,6 +36,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.casio.gb6900.CasioGB6900Devi
|
|||||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100.CasioGBX100DeviceCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100.CasioGBX100DeviceCoordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGMWB5000DeviceCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGMWB5000DeviceCoordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGWB5600DeviceCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGWB5600DeviceCoordinator;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.CmfWatchProCoordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.divoom.PixooCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.divoom.PixooCoordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.domyos.DomyosT540Coordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.domyos.DomyosT540Coordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.femometer.FemometerVinca2DeviceCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.femometer.FemometerVinca2DeviceCoordinator;
|
||||||
@ -109,6 +110,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband4.HonorBand4
|
|||||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband5.HonorBand5Coordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband5.HonorBand5Coordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband6.HonorBand6Coordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband6.HonorBand6Coordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband7.HonorBand7Coordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honorband7.HonorBand7Coordinator;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.honormagicwatch2.HonorMagicWatch2Coordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband4pro.HuaweiBand4ProCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband4pro.HuaweiBand4ProCoordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband6.HuaweiBand6Coordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband6.HuaweiBand6Coordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband7.HuaweiBand7Coordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband7.HuaweiBand7Coordinator;
|
||||||
@ -313,6 +315,7 @@ public enum DeviceType {
|
|||||||
NOTHING_EAR1(Ear1Coordinator.class),
|
NOTHING_EAR1(Ear1Coordinator.class),
|
||||||
NOTHING_EAR2(Ear2Coordinator.class),
|
NOTHING_EAR2(Ear2Coordinator.class),
|
||||||
NOTHING_EAR_STICK(EarStickCoordinator.class),
|
NOTHING_EAR_STICK(EarStickCoordinator.class),
|
||||||
|
NOTHING_CMF_WATCH_PRO(CmfWatchProCoordinator.class),
|
||||||
GALAXY_BUDS_PRO(GalaxyBudsProDeviceCoordinator.class),
|
GALAXY_BUDS_PRO(GalaxyBudsProDeviceCoordinator.class),
|
||||||
GALAXY_BUDS_LIVE(GalaxyBudsLiveDeviceCoordinator.class),
|
GALAXY_BUDS_LIVE(GalaxyBudsLiveDeviceCoordinator.class),
|
||||||
GALAXY_BUDS(GalaxyBudsDeviceCoordinator.class),
|
GALAXY_BUDS(GalaxyBudsDeviceCoordinator.class),
|
||||||
@ -342,6 +345,7 @@ public enum DeviceType {
|
|||||||
HUAWEIBAND7(HuaweiBand7Coordinator.class),
|
HUAWEIBAND7(HuaweiBand7Coordinator.class),
|
||||||
HONORBAND6(HonorBand6Coordinator.class),
|
HONORBAND6(HonorBand6Coordinator.class),
|
||||||
HONORBAND7(HonorBand7Coordinator.class),
|
HONORBAND7(HonorBand7Coordinator.class),
|
||||||
|
HONORMAGICWATCH2(HonorMagicWatch2Coordinator.class),
|
||||||
HUAWEIWATCHGT3(HuaweiWatchGT3Coordinator.class),
|
HUAWEIWATCHGT3(HuaweiWatchGT3Coordinator.class),
|
||||||
HUAWEIBAND8(HuaweiBand8Coordinator.class),
|
HUAWEIBAND8(HuaweiBand8Coordinator.class),
|
||||||
VESC(VescCoordinator.class),
|
VESC(VescCoordinator.class),
|
||||||
|
@ -108,7 +108,9 @@ public class BleNamesResolver {
|
|||||||
mServices.put("16187f00-0000-1000-8000-00807f9b34fb", "(Propr: Xiaomi Wear Service - Mi Smart Watch 4C/Redmi Band)");
|
mServices.put("16187f00-0000-1000-8000-00807f9b34fb", "(Propr: Xiaomi Wear Service - Mi Smart Watch 4C/Redmi Band)");
|
||||||
mServices.put("1314f000-1000-9000-7000-301291e21220", "(Propr: Xiaomi Wear Service - Mi Watch/Mi Watch Color/Mi Watch Color Sport)");
|
mServices.put("1314f000-1000-9000-7000-301291e21220", "(Propr: Xiaomi Wear Service - Mi Watch/Mi Watch Color/Mi Watch Color Sport)");
|
||||||
mServices.put("7495fe00-a7f3-424b-92dd-4a006a3aef56", "(Propr: Xiaomi Wear Service - Mi Watch CN)");
|
mServices.put("7495fe00-a7f3-424b-92dd-4a006a3aef56", "(Propr: Xiaomi Wear Service - Mi Watch CN)");
|
||||||
|
//mServices.put("0000fff0-0000-1000-8000-00805f9b34fb", "(Propr: Nothing CMF Command");
|
||||||
|
//mServices.put("02f00000-0000-0000-0000-00000000ffe0", "(Propr: Nothing CMF Data");
|
||||||
|
|
||||||
mCharacteristics.put("00002a43-0000-1000-8000-00805f9b34fb", "Alert AlertCategory ID");
|
mCharacteristics.put("00002a43-0000-1000-8000-00805f9b34fb", "Alert AlertCategory ID");
|
||||||
mCharacteristics.put("00002a42-0000-1000-8000-00805f9b34fb", "Alert AlertCategory ID Bit Mask");
|
mCharacteristics.put("00002a42-0000-1000-8000-00805f9b34fb", "Alert AlertCategory ID Bit Mask");
|
||||||
mCharacteristics.put("00002a06-0000-1000-8000-00805f9b34fb", "Alert Level");
|
mCharacteristics.put("00002a06-0000-1000-8000-00805f9b34fb", "Alert Level");
|
||||||
@ -193,6 +195,11 @@ public class BleNamesResolver {
|
|||||||
|
|
||||||
mCharacteristics.put("14702856-620a-3973-7c78-9cfff0876abd", "(Propr: HPLUS Control)");
|
mCharacteristics.put("14702856-620a-3973-7c78-9cfff0876abd", "(Propr: HPLUS Control)");
|
||||||
mCharacteristics.put("14702853-620a-3973-7c78-9cfff0876abd", "(Propr: HPLUS Measurements)");
|
mCharacteristics.put("14702853-620a-3973-7c78-9cfff0876abd", "(Propr: HPLUS Measurements)");
|
||||||
|
//mCharacteristics.put("0000fff1-0000-1000-8000-00805f9b34fb", "(Propr: Nothing CMF Command Read");
|
||||||
|
//mCharacteristics.put("0000fff2-0000-1000-8000-00805f9b34fb", "(Propr: Nothing CMF Command Write");
|
||||||
|
//mCharacteristics.put("02f00000-0000-0000-0000-00000000ffe1", "(Propr: Nothing CMF Data Write");
|
||||||
|
//mCharacteristics.put("02f00000-0000-0000-0000-00000000ffe2", "(Propr: Nothing CMF Data Read");
|
||||||
|
|
||||||
mValueFormats.put(Integer.valueOf(52), "32bit float");
|
mValueFormats.put(Integer.valueOf(52), "32bit float");
|
||||||
mValueFormats.put(Integer.valueOf(50), "16bit float");
|
mValueFormats.put(Integer.valueOf(50), "16bit float");
|
||||||
mValueFormats.put(Integer.valueOf(34), "16bit signed int");
|
mValueFormats.put(Integer.valueOf(34), "16bit signed int");
|
||||||
|
@ -0,0 +1,663 @@
|
|||||||
|
/* 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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<BaseActivitySummary> 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<CmfActivitySample> 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<CmfHeartRateSample> 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<CmfSleepStageSample> 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<CmfStressSample> 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<CmfSpo2Sample> 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<CmfWorkoutGpsSample> 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<BaseActivitySummary> 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<BaseActivitySummary> 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<CmfWorkoutGpsSample> gpsSamples;
|
||||||
|
final List<CmfHeartRateSample> 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<CmfHeartRateSample> 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<CmfCommand, ChunkBuffer> 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<String> 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<String, String> LANGUAGES = new HashMap<String, String>() {{
|
||||||
|
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");
|
||||||
|
}};
|
||||||
|
}
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -90,7 +90,7 @@ public class FetchSpo2NormalOperation extends AbstractRepeatingFetchOperation {
|
|||||||
|
|
||||||
final HuamiSpo2Sample sample = new HuamiSpo2Sample();
|
final HuamiSpo2Sample sample = new HuamiSpo2Sample();
|
||||||
sample.setTimestamp(timestamp.getTimeInMillis());
|
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);
|
sample.setSpo2(spo2);
|
||||||
samples.add(sample);
|
samples.add(sample);
|
||||||
}
|
}
|
||||||
|
@ -77,7 +77,7 @@ public class FetchStressAutoOperation extends AbstractRepeatingFetchOperation {
|
|||||||
|
|
||||||
final HuamiStressSample sample = new HuamiStressSample();
|
final HuamiStressSample sample = new HuamiStressSample();
|
||||||
sample.setTimestamp(timestamp.getTimeInMillis());
|
sample.setTimestamp(timestamp.getTimeInMillis());
|
||||||
sample.setType(StressSample.Type.AUTOMATIC);
|
sample.setTypeNum(StressSample.Type.AUTOMATIC.getNum());
|
||||||
sample.setStress(stress);
|
sample.setStress(stress);
|
||||||
samples.add(sample);
|
samples.add(sample);
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@ public class FetchStressManualOperation extends AbstractRepeatingFetchOperation
|
|||||||
|
|
||||||
final HuamiStressSample sample = new HuamiStressSample();
|
final HuamiStressSample sample = new HuamiStressSample();
|
||||||
sample.setTimestamp(timestamp.getTimeInMillis());
|
sample.setTimestamp(timestamp.getTimeInMillis());
|
||||||
sample.setType(StressSample.Type.MANUAL);
|
sample.setTypeNum(StressSample.Type.MANUAL.getNum());
|
||||||
sample.setStress(stress);
|
sample.setStress(stress);
|
||||||
samples.add(sample);
|
samples.add(sample);
|
||||||
}
|
}
|
||||||
|
@ -400,12 +400,16 @@ public class HuaweiSupportProvider {
|
|||||||
try {
|
try {
|
||||||
createSecretKey();
|
createSecretKey();
|
||||||
GetAuthRequest authReq = new GetAuthRequest(this, linkParamsReq);
|
GetAuthRequest authReq = new GetAuthRequest(this, linkParamsReq);
|
||||||
GetBondParamsRequest bondParamsReq = new GetBondParamsRequest(this);
|
if (getHuaweiType() == HuaweiDeviceType.BLE || getHuaweiType() == HuaweiDeviceType.AW) {
|
||||||
GetBondRequest bondReq = new GetBondRequest(this);
|
GetBondParamsRequest bondParamsReq = new GetBondParamsRequest(this);
|
||||||
authReq.nextRequest(bondParamsReq);
|
GetBondRequest bondReq = new GetBondRequest(this);
|
||||||
bondParamsReq.nextRequest(bondReq);
|
authReq.nextRequest(bondParamsReq);
|
||||||
bondParamsReq.setFinalizeReq(configureReq);
|
bondParamsReq.nextRequest(bondReq);
|
||||||
bondReq.setFinalizeReq(configureReq);
|
bondParamsReq.setFinalizeReq(configureReq);
|
||||||
|
bondReq.setFinalizeReq(configureReq);
|
||||||
|
} else {
|
||||||
|
authReq.setFinalizeReq(configureReq);
|
||||||
|
}
|
||||||
authReq.doPerform();
|
authReq.doPerform();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
GB.toast(context, "init Normal Mode of Huawei device failed", Toast.LENGTH_SHORT, GB.ERROR, e);
|
GB.toast(context, "init Normal Mode of Huawei device failed", Toast.LENGTH_SHORT, GB.ERROR, e);
|
||||||
|
@ -60,7 +60,7 @@ public class StressPacketParser extends OneBytePerSamplePacketParser {
|
|||||||
gbSample.setUserId(userId);
|
gbSample.setUserId(userId);
|
||||||
gbSample.setTimestamp(currentSampleDate.getTime());
|
gbSample.setTimestamp(currentSampleDate.getTime());
|
||||||
gbSample.setStress(rawSample);
|
gbSample.setStress(rawSample);
|
||||||
gbSample.setType(StressSample.Type.AUTOMATIC);
|
gbSample.setTypeNum(StressSample.Type.AUTOMATIC.getNum());
|
||||||
samples.add(gbSample);
|
samples.add(gbSample);
|
||||||
} else {
|
} else {
|
||||||
LOG.debug("Discard stress value as out of range: " + rawSample);
|
LOG.debug("Discard stress value as out of range: " + rawSample);
|
||||||
|
@ -85,7 +85,10 @@ public class SleepDetailsParser extends XiaomiActivityParser {
|
|||||||
final int count = buf.getShort();
|
final int count = buf.getShort();
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
final int firstRecordTime = buf.getInt();
|
// If version is less than 2 firstRecordTime is bedTime
|
||||||
|
if (fileId.getVersion() >= 2) {
|
||||||
|
final int firstRecordTime = buf.getInt();
|
||||||
|
}
|
||||||
|
|
||||||
// Skip count samples - each sample is a u8
|
// Skip count samples - each sample is a u8
|
||||||
// timestamp of each sample is firstRecordTime + (unit * index)
|
// timestamp of each sample is firstRecordTime + (unit * index)
|
||||||
@ -99,7 +102,10 @@ public class SleepDetailsParser extends XiaomiActivityParser {
|
|||||||
final int count = buf.getShort();
|
final int count = buf.getShort();
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
final int firstRecordTime = buf.getInt();
|
// If version is less than 2 firstRecordTime is bedTime
|
||||||
|
if (fileId.getVersion() >= 2) {
|
||||||
|
final int firstRecordTime = buf.getInt();
|
||||||
|
}
|
||||||
|
|
||||||
// Skip count samples - each sample is a u8
|
// Skip count samples - each sample is a u8
|
||||||
// timestamp of each sample is firstRecordTime + (unit * index)
|
// timestamp of each sample is firstRecordTime + (unit * index)
|
||||||
@ -113,7 +119,10 @@ public class SleepDetailsParser extends XiaomiActivityParser {
|
|||||||
final int count = buf.getShort();
|
final int count = buf.getShort();
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
final int firstRecordTime = buf.getInt();
|
// If version is less than 2 firstRecordTime is bedTime
|
||||||
|
if (fileId.getVersion() >= 2) {
|
||||||
|
final int firstRecordTime = buf.getInt();
|
||||||
|
}
|
||||||
|
|
||||||
// Skip count samples - each sample is a float
|
// Skip count samples - each sample is a float
|
||||||
// timestamp of each sample is firstRecordTime + (unit * index)
|
// timestamp of each sample is firstRecordTime + (unit * index)
|
||||||
|
@ -24,6 +24,8 @@ import android.media.session.MediaController;
|
|||||||
import android.media.session.MediaSessionManager;
|
import android.media.session.MediaSessionManager;
|
||||||
import android.media.session.PlaybackState;
|
import android.media.session.PlaybackState;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@ -105,7 +107,12 @@ public class MediaManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
public static MusicSpec extractMusicSpec(final MediaMetadata d) {
|
public static MusicSpec extractMusicSpec(final MediaMetadata d) {
|
||||||
|
if (d == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final MusicSpec musicSpec = new MusicSpec();
|
final MusicSpec musicSpec = new MusicSpec();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -128,7 +135,12 @@ public class MediaManager {
|
|||||||
return musicSpec;
|
return musicSpec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
public static MusicStateSpec extractMusicStateSpec(final PlaybackState s) {
|
public static MusicStateSpec extractMusicStateSpec(final PlaybackState s) {
|
||||||
|
if (s == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final MusicStateSpec stateSpec = new MusicStateSpec();
|
final MusicStateSpec stateSpec = new MusicStateSpec();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -1201,6 +1201,237 @@
|
|||||||
<item>indoor_ice_skating</item>
|
<item>indoor_ice_skating</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
|
<string-array name="pref_workout_activity_types">
|
||||||
|
<item>@string/activity_type_indoor_running</item>
|
||||||
|
<item>@string/activity_type_outdoor_running</item>
|
||||||
|
<item>@string/activity_type_outdoor_walking</item>
|
||||||
|
<item>@string/activity_type_indoor_walking</item>
|
||||||
|
<item>@string/activity_type_outdoor_cycling</item>
|
||||||
|
<item>@string/activity_type_indoor_cycling</item>
|
||||||
|
<item>@string/activity_type_mountain_hike</item>
|
||||||
|
<item>@string/activity_type_hiking</item>
|
||||||
|
<item>@string/activity_type_cross_trainer</item>
|
||||||
|
<item>@string/activity_type_free_training</item>
|
||||||
|
<item>@string/activity_type_strength_training</item>
|
||||||
|
<item>@string/activity_type_yoga</item>
|
||||||
|
<item>@string/activity_type_boxing</item>
|
||||||
|
<item>@string/activity_type_rower</item>
|
||||||
|
<item>@string/activity_type_dynamic_cycle</item>
|
||||||
|
<item>@string/activity_type_stair_stepper</item>
|
||||||
|
<item>@string/activity_type_treadmill</item>
|
||||||
|
<item>@string/activity_type_hiit</item>
|
||||||
|
<item>@string/activity_type_fitness_exercises</item>
|
||||||
|
<item>@string/activity_type_jump_roping</item>
|
||||||
|
<item>@string/activity_type_pilates</item>
|
||||||
|
<item>@string/activity_type_crossfit</item>
|
||||||
|
<item>@string/activity_type_functional_training</item>
|
||||||
|
<item>@string/activity_type_physical_training</item>
|
||||||
|
<item>@string/activity_type_taekwondo</item>
|
||||||
|
<item>@string/activity_type_cross_country_running</item>
|
||||||
|
<item>@string/activity_type_karate</item>
|
||||||
|
<item>@string/activity_type_fencing</item>
|
||||||
|
<item>@string/activity_type_core_training</item>
|
||||||
|
<item>@string/activity_type_kendo</item>
|
||||||
|
<item>@string/activity_type_horizontal_bar</item>
|
||||||
|
<item>@string/activity_type_parallel_bar</item>
|
||||||
|
<item>@string/activity_type_cooldown</item>
|
||||||
|
<item>@string/activity_type_cross_training</item>
|
||||||
|
<item>@string/activity_type_sit_ups</item>
|
||||||
|
<item>@string/activity_type_fitness_gaming</item>
|
||||||
|
<item>@string/activity_type_aerobic_exercise</item>
|
||||||
|
<item>@string/activity_type_rolling</item>
|
||||||
|
<item>@string/activity_type_flexibility</item>
|
||||||
|
<item>@string/activity_type_gymnastics</item>
|
||||||
|
<item>@string/activity_type_track_and_field</item>
|
||||||
|
<item>@string/activity_type_push_ups</item>
|
||||||
|
<item>@string/activity_type_battle_rope</item>
|
||||||
|
<item>@string/activity_type_smith_machine</item>
|
||||||
|
<item>@string/activity_type_pull_ups</item>
|
||||||
|
<item>@string/activity_type_plank</item>
|
||||||
|
<item>@string/activity_type_javelin</item>
|
||||||
|
<item>@string/activity_type_long_jump</item>
|
||||||
|
<item>@string/activity_type_high_jump</item>
|
||||||
|
<item>@string/activity_type_trampoline</item>
|
||||||
|
<item>@string/activity_type_dumbbell</item>
|
||||||
|
<item>@string/activity_type_belly_dance</item>
|
||||||
|
<item>@string/activity_type_jazz_dance</item>
|
||||||
|
<item>@string/activity_type_latin_dance</item>
|
||||||
|
<item>@string/activity_type_ballet</item>
|
||||||
|
<item>@string/activity_type_street_dance</item>
|
||||||
|
<item>@string/activity_type_zumba</item>
|
||||||
|
<item>@string/activity_type_other_dance</item>
|
||||||
|
<item>@string/activity_type_roller_skating</item>
|
||||||
|
<item>@string/activity_type_martial_arts</item>
|
||||||
|
<item>@string/activity_type_tai_chi</item>
|
||||||
|
<item>@string/activity_type_hula_hooping</item>
|
||||||
|
<item>@string/activity_type_disc_sports</item>
|
||||||
|
<item>@string/activity_type_darts</item>
|
||||||
|
<item>@string/activity_type_archery</item>
|
||||||
|
<item>@string/activity_type_horse_riding</item>
|
||||||
|
<item>@string/activity_type_kite_flying</item>
|
||||||
|
<item>@string/activity_type_swing</item>
|
||||||
|
<item>@string/activity_type_stairs</item>
|
||||||
|
<item>@string/activity_type_fishing</item>
|
||||||
|
<item>@string/activity_type_hand_cycling</item>
|
||||||
|
<item>@string/activity_type_mind_and_body</item>
|
||||||
|
<item>@string/activity_type_wrestling</item>
|
||||||
|
<item>@string/activity_type_kabaddi</item>
|
||||||
|
<item>@string/activity_type_karting</item>
|
||||||
|
<item>@string/activity_type_badminton</item>
|
||||||
|
<item>@string/activity_type_table_tennis</item>
|
||||||
|
<item>@string/activity_type_tennis</item>
|
||||||
|
<item>@string/activity_type_billiards</item>
|
||||||
|
<item>@string/activity_type_bowling</item>
|
||||||
|
<item>@string/activity_type_volleyball</item>
|
||||||
|
<item>@string/activity_type_shuttlecock</item>
|
||||||
|
<item>@string/activity_type_handball</item>
|
||||||
|
<item>@string/activity_type_baseball</item>
|
||||||
|
<item>@string/activity_type_softball</item>
|
||||||
|
<item>@string/activity_type_cricket</item>
|
||||||
|
<item>@string/activity_type_rugby</item>
|
||||||
|
<item>@string/activity_type_hockey</item>
|
||||||
|
<item>@string/activity_type_squash</item>
|
||||||
|
<item>@string/activity_type_dodgeball</item>
|
||||||
|
<item>@string/activity_type_soccer</item>
|
||||||
|
<item>@string/activity_type_basketball</item>
|
||||||
|
<item>@string/activity_type_australian_football</item>
|
||||||
|
<item>@string/activity_type_golf</item>
|
||||||
|
<item>@string/activity_type_pickleball</item>
|
||||||
|
<item>@string/activity_type_lacross</item>
|
||||||
|
<item>@string/activity_type_shot</item>
|
||||||
|
<item>@string/activity_type_sailing</item>
|
||||||
|
<item>@string/activity_type_surfing</item>
|
||||||
|
<item>@string/activity_type_jet_skiing</item>
|
||||||
|
<item>@string/activity_type_skating</item>
|
||||||
|
<item>@string/activity_type_ice_hockey</item>
|
||||||
|
<item>@string/activity_type_curling</item>
|
||||||
|
<item>@string/activity_type_snowboarding</item>
|
||||||
|
<item>@string/activity_type_cross_country_skiing</item>
|
||||||
|
<item>@string/activity_type_snow_sports</item>
|
||||||
|
<item>@string/activity_type_skiing</item>
|
||||||
|
<item>@string/activity_type_skateboarding</item>
|
||||||
|
<item>@string/activity_type_rock_climbing</item>
|
||||||
|
<item>@string/activity_type_hunting</item>
|
||||||
|
</string-array>
|
||||||
|
|
||||||
|
<string-array name="pref_workout_activity_types_values">
|
||||||
|
<item>indoor_running</item>
|
||||||
|
<item>outdoor_running</item>
|
||||||
|
<item>outdoor_walking</item>
|
||||||
|
<item>indoor_walking</item>
|
||||||
|
<item>outdoor_cycling</item>
|
||||||
|
<item>indoor_cycling</item>
|
||||||
|
<item>mountain_hike</item>
|
||||||
|
<item>hiking</item>
|
||||||
|
<item>cross_trainer</item>
|
||||||
|
<item>free_training</item>
|
||||||
|
<item>strength_training</item>
|
||||||
|
<item>yoga</item>
|
||||||
|
<item>boxing</item>
|
||||||
|
<item>rower</item>
|
||||||
|
<item>dynamic_cycle</item>
|
||||||
|
<item>stair_stepper</item>
|
||||||
|
<item>treadmill</item>
|
||||||
|
<item>hiit</item>
|
||||||
|
<item>fitness_exercises</item>
|
||||||
|
<item>jump_roping</item>
|
||||||
|
<item>pilates</item>
|
||||||
|
<item>crossfit</item>
|
||||||
|
<item>functional_training</item>
|
||||||
|
<item>physical_training</item>
|
||||||
|
<item>taekwondo</item>
|
||||||
|
<item>cross_country_running</item>
|
||||||
|
<item>karate</item>
|
||||||
|
<item>fencing</item>
|
||||||
|
<item>core_training</item>
|
||||||
|
<item>kendo</item>
|
||||||
|
<item>horizontal_bar</item>
|
||||||
|
<item>parallel_bar</item>
|
||||||
|
<item>cooldown</item>
|
||||||
|
<item>cross_training</item>
|
||||||
|
<item>sit_ups</item>
|
||||||
|
<item>fitness_gaming</item>
|
||||||
|
<item>aerobic_exercise</item>
|
||||||
|
<item>rolling</item>
|
||||||
|
<item>flexibility</item>
|
||||||
|
<item>gymnastics</item>
|
||||||
|
<item>track_and_field</item>
|
||||||
|
<item>push_ups</item>
|
||||||
|
<item>battle_rope</item>
|
||||||
|
<item>smith_machine</item>
|
||||||
|
<item>pull_ups</item>
|
||||||
|
<item>plank</item>
|
||||||
|
<item>javelin</item>
|
||||||
|
<item>long_jump</item>
|
||||||
|
<item>high_jump</item>
|
||||||
|
<item>trampoline</item>
|
||||||
|
<item>dumbbell</item>
|
||||||
|
<item>belly_dance</item>
|
||||||
|
<item>jazz_dance</item>
|
||||||
|
<item>latin_dance</item>
|
||||||
|
<item>ballet</item>
|
||||||
|
<item>street_dance</item>
|
||||||
|
<item>zumba</item>
|
||||||
|
<item>other_dance</item>
|
||||||
|
<item>roller_skating</item>
|
||||||
|
<item>martial_arts</item>
|
||||||
|
<item>tai_chi</item>
|
||||||
|
<item>hula_hooping</item>
|
||||||
|
<item>disc_sports</item>
|
||||||
|
<item>darts</item>
|
||||||
|
<item>archery</item>
|
||||||
|
<item>horse_riding</item>
|
||||||
|
<item>kite_flying</item>
|
||||||
|
<item>swing</item>
|
||||||
|
<item>stairs</item>
|
||||||
|
<item>fishing</item>
|
||||||
|
<item>hand_cycling</item>
|
||||||
|
<item>mind_and_body</item>
|
||||||
|
<item>wrestling</item>
|
||||||
|
<item>kabaddi</item>
|
||||||
|
<item>karting</item>
|
||||||
|
<item>badminton</item>
|
||||||
|
<item>table_tennis</item>
|
||||||
|
<item>tennis</item>
|
||||||
|
<item>billiards</item>
|
||||||
|
<item>bowling</item>
|
||||||
|
<item>volleyball</item>
|
||||||
|
<item>shuttlecock</item>
|
||||||
|
<item>handball</item>
|
||||||
|
<item>baseball</item>
|
||||||
|
<item>softball</item>
|
||||||
|
<item>cricket</item>
|
||||||
|
<item>rugby</item>
|
||||||
|
<item>hockey</item>
|
||||||
|
<item>squash</item>
|
||||||
|
<item>dodgeball</item>
|
||||||
|
<item>soccer</item>
|
||||||
|
<item>basketball</item>
|
||||||
|
<item>australian_football</item>
|
||||||
|
<item>golf</item>
|
||||||
|
<item>pickleball</item>
|
||||||
|
<item>lacross</item>
|
||||||
|
<item>shot</item>
|
||||||
|
<item>sailing</item>
|
||||||
|
<item>surfing</item>
|
||||||
|
<item>jet_skiing</item>
|
||||||
|
<item>skating</item>
|
||||||
|
<item>ice_hockey</item>
|
||||||
|
<item>curling</item>
|
||||||
|
<item>snowboarding</item>
|
||||||
|
<item>cross_country_skiing</item>
|
||||||
|
<item>snow_sports</item>
|
||||||
|
<item>skiing</item>
|
||||||
|
<item>skateboarding</item>
|
||||||
|
<item>rock_climbing</item>
|
||||||
|
<item>hunting</item>
|
||||||
|
</string-array>
|
||||||
|
|
||||||
|
<string-array name="pref_workout_activity_types_default">
|
||||||
|
<item>indoor_run</item>
|
||||||
|
<item>outdoor_run</item>
|
||||||
|
</string-array>
|
||||||
|
|
||||||
<string-array name="pref_withings_steel_activity_types">
|
<string-array name="pref_withings_steel_activity_types">
|
||||||
<item>@string/activity_type_outdoor_running</item>
|
<item>@string/activity_type_outdoor_running</item>
|
||||||
<item>@string/activity_type_hiking</item>
|
<item>@string/activity_type_hiking</item>
|
||||||
@ -2389,6 +2620,26 @@
|
|||||||
<item>150</item>
|
<item>150</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
|
<string-array name="prefs_miband_heartrate_high_active_alert_threshold_with_off">
|
||||||
|
<item name="0">@string/off</item>
|
||||||
|
<item name="155">@string/heartrate_bpm_155</item>
|
||||||
|
<item name="165">@string/heartrate_bpm_165</item>
|
||||||
|
<item name="175">@string/heartrate_bpm_175</item>
|
||||||
|
<item name="185">@string/heartrate_bpm_185</item>
|
||||||
|
<item name="195">@string/heartrate_bpm_195</item>
|
||||||
|
<item name="205">@string/heartrate_bpm_205</item>
|
||||||
|
</string-array>
|
||||||
|
|
||||||
|
<string-array name="prefs_miband_heartrate_high_active_alert_threshold_with_off_values">
|
||||||
|
<item>0</item>
|
||||||
|
<item>155</item>
|
||||||
|
<item>165</item>
|
||||||
|
<item>175</item>
|
||||||
|
<item>185</item>
|
||||||
|
<item>195</item>
|
||||||
|
<item>205</item>
|
||||||
|
</string-array>
|
||||||
|
|
||||||
<string-array name="prefs_miband_heartrate_low_alert_threshold">
|
<string-array name="prefs_miband_heartrate_low_alert_threshold">
|
||||||
<item name="0">@string/off</item>
|
<item name="0">@string/off</item>
|
||||||
<item name="40">@string/heartrate_bpm_40</item>
|
<item name="40">@string/heartrate_bpm_40</item>
|
||||||
|
@ -712,6 +712,12 @@
|
|||||||
<string name="heartrate_bpm_140">140 bpm</string>
|
<string name="heartrate_bpm_140">140 bpm</string>
|
||||||
<string name="heartrate_bpm_145">145 bpm</string>
|
<string name="heartrate_bpm_145">145 bpm</string>
|
||||||
<string name="heartrate_bpm_150">150 bpm</string>
|
<string name="heartrate_bpm_150">150 bpm</string>
|
||||||
|
<string name="heartrate_bpm_155">155 bpm</string>
|
||||||
|
<string name="heartrate_bpm_165">165 bpm</string>
|
||||||
|
<string name="heartrate_bpm_175">175 bpm</string>
|
||||||
|
<string name="heartrate_bpm_185">185 bpm</string>
|
||||||
|
<string name="heartrate_bpm_195">195 bpm</string>
|
||||||
|
<string name="heartrate_bpm_205">205 bpm</string>
|
||||||
<string name="spo2_perc_80">80%</string>
|
<string name="spo2_perc_80">80%</string>
|
||||||
<string name="spo2_perc_85">85%</string>
|
<string name="spo2_perc_85">85%</string>
|
||||||
<string name="spo2_perc_90">90%</string>
|
<string name="spo2_perc_90">90%</string>
|
||||||
@ -785,6 +791,7 @@
|
|||||||
<string name="prefs_heartrate_alert_experimental_description">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.</string>
|
<string name="prefs_heartrate_alert_experimental_description">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.</string>
|
||||||
<string name="prefs_heartrate_alert_threshold">Heart rate alert threshold</string>
|
<string name="prefs_heartrate_alert_threshold">Heart rate alert threshold</string>
|
||||||
<string name="prefs_heartrate_alert_high_threshold">High heart rate alert threshold</string>
|
<string name="prefs_heartrate_alert_high_threshold">High heart rate alert threshold</string>
|
||||||
|
<string name="prefs_heartrate_alert_active_high_threshold">High activity heart rate alert threshold</string>
|
||||||
<string name="prefs_heartrate_alert_low_threshold">Low heart rate alert threshold</string>
|
<string name="prefs_heartrate_alert_low_threshold">Low heart rate alert threshold</string>
|
||||||
<string name="prefs_stress_monitoring_title">Stress monitoring</string>
|
<string name="prefs_stress_monitoring_title">Stress monitoring</string>
|
||||||
<string name="prefs_stress_monitoring_description">Monitor stress level while resting</string>
|
<string name="prefs_stress_monitoring_description">Monitor stress level while resting</string>
|
||||||
@ -970,6 +977,7 @@
|
|||||||
<string name="mi2_prefs_inactivity_warnings_summary">The band will vibrate when you have been inactive for a while</string>
|
<string name="mi2_prefs_inactivity_warnings_summary">The band will vibrate when you have been inactive for a while</string>
|
||||||
<string name="mi2_prefs_inactivity_warnings_threshold">Inactivity threshold (in minutes)</string>
|
<string name="mi2_prefs_inactivity_warnings_threshold">Inactivity threshold (in minutes)</string>
|
||||||
<string name="mi2_prefs_inactivity_warnings_dnd_summary">Disable inactivity warnings for a time interval</string>
|
<string name="mi2_prefs_inactivity_warnings_dnd_summary">Disable inactivity warnings for a time interval</string>
|
||||||
|
<string name="hydration_dnd_summary">Disable hydration warnings for a time interval</string>
|
||||||
<string name="mi2_prefs_heart_rate_monitoring">Heart Rate Monitoring</string>
|
<string name="mi2_prefs_heart_rate_monitoring">Heart Rate Monitoring</string>
|
||||||
<string name="mi2_prefs_heart_rate_monitoring_summary">Configure heart rate monitoring</string>
|
<string name="mi2_prefs_heart_rate_monitoring_summary">Configure heart rate monitoring</string>
|
||||||
<string name="prefs_phone_silent_mode">Phone Silent Mode</string>
|
<string name="prefs_phone_silent_mode">Phone Silent Mode</string>
|
||||||
@ -1237,7 +1245,83 @@
|
|||||||
<string name="activity_type_not_worn">Device not worn</string>
|
<string name="activity_type_not_worn">Device not worn</string>
|
||||||
<string name="activity_type_running">Running</string>
|
<string name="activity_type_running">Running</string>
|
||||||
<string name="activity_type_outdoor_running">Outdoor Running</string>
|
<string name="activity_type_outdoor_running">Outdoor Running</string>
|
||||||
|
<string name="activity_type_indoor_running">Indoor Running</string>
|
||||||
|
<string name="activity_type_mountain_hike">Mountain Hike</string>
|
||||||
|
<string name="activity_type_cross_trainer">Cross trainer</string>
|
||||||
|
<string name="activity_type_free_training">Free training</string>
|
||||||
|
<string name="activity_type_rower">Rower</string>
|
||||||
|
<string name="activity_type_dynamic_cycle">Dynamic cycle</string>
|
||||||
|
<string name="activity_type_stair_stepper">Stair stepper</string>
|
||||||
|
<string name="activity_type_fitness_exercises">Fitness exercises</string>
|
||||||
|
<string name="activity_type_crossfit">Crossfit</string>
|
||||||
|
<string name="activity_type_functional_training">Functional training</string>
|
||||||
|
<string name="activity_type_physical_training">Physical training</string>
|
||||||
|
<string name="activity_type_taekwondo">Taekwondo</string>
|
||||||
|
<string name="activity_type_cross_country_running">Cross country running</string>
|
||||||
|
<string name="activity_type_karate">Karate</string>
|
||||||
|
<string name="activity_type_fencing">Fencing</string>
|
||||||
|
<string name="activity_type_kendo">Kendo</string>
|
||||||
|
<string name="activity_type_horizontal_bar">Horizontal bar</string>
|
||||||
|
<string name="activity_type_parallel_bar">Parallel bar</string>
|
||||||
|
<string name="activity_type_cooldown">Cooldown</string>
|
||||||
|
<string name="activity_type_cross_training">Cross training</string>
|
||||||
|
<string name="activity_type_sit_ups">Sit ups</string>
|
||||||
|
<string name="activity_type_fitness_gaming">Fitness gaming</string>
|
||||||
|
<string name="activity_type_aerobic_exercise">Aerobic exercise</string>
|
||||||
|
<string name="activity_type_rolling">Rolling</string>
|
||||||
|
<string name="activity_type_flexibility">Flexibility</string>
|
||||||
|
<string name="activity_type_track_and_field">Track and field</string>
|
||||||
|
<string name="activity_type_push_ups">Push ups</string>
|
||||||
|
<string name="activity_type_battle_rope">Battle rope</string>
|
||||||
|
<string name="activity_type_smith_machine">Smith machine</string>
|
||||||
|
<string name="activity_type_pull_ups">Pull ups</string>
|
||||||
|
<string name="activity_type_plank">Plank</string>
|
||||||
|
<string name="activity_type_javelin">Javelin</string>
|
||||||
|
<string name="activity_type_long_jump">Long jump</string>
|
||||||
|
<string name="activity_type_high_jump">High jump</string>
|
||||||
|
<string name="activity_type_trampoline">Trampoline</string>
|
||||||
|
<string name="activity_type_dumbbell">Dumbbell</string>
|
||||||
|
<string name="activity_type_belly_dance">Belly dance</string>
|
||||||
|
<string name="activity_type_jazz_dance">Jazz dance</string>
|
||||||
|
<string name="activity_type_latin_dance">Latin dance</string>
|
||||||
|
<string name="activity_type_ballet">Ballet</string>
|
||||||
|
<string name="activity_type_other_dance">Other dance</string>
|
||||||
|
<string name="activity_type_roller_skating">Roller skating</string>
|
||||||
|
<string name="activity_type_martial_arts">Martial arts</string>
|
||||||
|
<string name="activity_type_tai_chi">Tai chi</string>
|
||||||
|
<string name="activity_type_hula_hooping">Hula hooping</string>
|
||||||
|
<string name="activity_type_disc_sports">Disc sports</string>
|
||||||
|
<string name="activity_type_darts">Darts</string>
|
||||||
|
<string name="activity_type_archery">Archery</string>
|
||||||
|
<string name="activity_type_horse_riding">Horse riding</string>
|
||||||
|
<string name="activity_type_kite_flying">Kite Flying</string>
|
||||||
|
<string name="activity_type_swing">Swing</string>
|
||||||
|
<string name="activity_type_stairs">Stairs</string>
|
||||||
|
<string name="activity_type_fishing">Fishing</string>
|
||||||
|
<string name="activity_type_hand_cycling">Hand cycling</string>
|
||||||
|
<string name="activity_type_mind_and_body">Mind and body</string>
|
||||||
|
<string name="activity_type_kabaddi">Kabaddi</string>
|
||||||
|
<string name="activity_type_karting">Karting</string>
|
||||||
|
<string name="activity_type_billiards">Billiards</string>
|
||||||
|
<string name="activity_type_shuttlecock">Shuttlecock</string>
|
||||||
|
<string name="activity_type_softball">Softball</string>
|
||||||
|
<string name="activity_type_dodgeball">Dodgeball</string>
|
||||||
|
<string name="activity_type_australian_football">Australian football</string>
|
||||||
|
<string name="activity_type_pickleball">Pickleball</string>
|
||||||
|
<string name="activity_type_lacross">Lacross</string>
|
||||||
|
<string name="activity_type_shot">Shot</string>
|
||||||
|
<string name="activity_type_sailing">Sailing</string>
|
||||||
|
<string name="activity_type_jet_skiing">Jet skiing</string>
|
||||||
|
<string name="activity_type_skating">Skating</string>
|
||||||
|
<string name="activity_type_ice_hockey">Ice hockey</string>
|
||||||
|
<string name="activity_type_curling">Curling</string>
|
||||||
|
<string name="activity_type_cross_country_skiing">Cross country skiing</string>
|
||||||
|
<string name="activity_type_snow_sports">Snow sports</string>
|
||||||
|
<string name="activity_type_skateboarding">Skateboarding</string>
|
||||||
|
<string name="activity_type_rock_climbing">Rock climbing</string>
|
||||||
|
<string name="activity_type_hunting">Hunting</string>
|
||||||
<string name="activity_type_walking">Walking</string>
|
<string name="activity_type_walking">Walking</string>
|
||||||
|
<string name="activity_type_outdoor_walking">Outdoor Walking</string>
|
||||||
<string name="activity_type_indoor_walking">Indoor Walking</string>
|
<string name="activity_type_indoor_walking">Indoor Walking</string>
|
||||||
<string name="activity_type_surfing">Surfing</string>
|
<string name="activity_type_surfing">Surfing</string>
|
||||||
<string name="activity_type_windsurfing">Windsurfing</string>
|
<string name="activity_type_windsurfing">Windsurfing</string>
|
||||||
@ -1440,6 +1524,7 @@
|
|||||||
<string name="devicetype_honor_band5">Honor Band 5</string>
|
<string name="devicetype_honor_band5">Honor Band 5</string>
|
||||||
<string name="devicetype_honor_band6">Honor Band 6</string>
|
<string name="devicetype_honor_band6">Honor Band 6</string>
|
||||||
<string name="devicetype_honor_band7">Honor Band 7</string>
|
<string name="devicetype_honor_band7">Honor Band 7</string>
|
||||||
|
<string name="devicetype_honor_magicwatch2">Honor MagicWatch 2</string>
|
||||||
<string name="devicetype_huawei_band_aw70">Huawei Band (AW70)</string>
|
<string name="devicetype_huawei_band_aw70">Huawei Band (AW70)</string>
|
||||||
<string name="devicetype_huawei_band6">Huawei Band 6</string>
|
<string name="devicetype_huawei_band6">Huawei Band 6</string>
|
||||||
<string name="devicetype_huawei_band7">Huawei Band 7</string>
|
<string name="devicetype_huawei_band7">Huawei Band 7</string>
|
||||||
@ -1991,6 +2076,7 @@
|
|||||||
<string name="devicetype_nothingear1">Nothing Ear (1)</string>
|
<string name="devicetype_nothingear1">Nothing Ear (1)</string>
|
||||||
<string name="devicetype_nothingear2">Nothing Ear (2)</string>
|
<string name="devicetype_nothingear2">Nothing Ear (2)</string>
|
||||||
<string name="devicetype_nothingearstick">Nothing Ear (Stick)</string>
|
<string name="devicetype_nothingearstick">Nothing Ear (Stick)</string>
|
||||||
|
<string name="devicetype_nothing_cmf_watch_pro">CMF Watch Pro</string>
|
||||||
<string name="devicetype_galaxybuds">Galaxy Buds</string>
|
<string name="devicetype_galaxybuds">Galaxy Buds</string>
|
||||||
<string name="devicetype_galaxybuds_live">Galaxy Buds Live</string>
|
<string name="devicetype_galaxybuds_live">Galaxy Buds Live</string>
|
||||||
<string name="devicetype_galaxybuds_pro">Galaxy Buds Pro</string>
|
<string name="devicetype_galaxybuds_pro">Galaxy Buds Pro</string>
|
||||||
|
@ -70,6 +70,15 @@
|
|||||||
android:key="heartrate_alert_low_threshold"
|
android:key="heartrate_alert_low_threshold"
|
||||||
android:summary="%s"
|
android:summary="%s"
|
||||||
android:title="@string/prefs_heartrate_alert_low_threshold" />
|
android:title="@string/prefs_heartrate_alert_low_threshold" />
|
||||||
|
|
||||||
|
<ListPreference
|
||||||
|
android:defaultValue="0"
|
||||||
|
android:entries="@array/prefs_miband_heartrate_high_active_alert_threshold_with_off"
|
||||||
|
android:entryValues="@array/prefs_miband_heartrate_high_active_alert_threshold_with_off_values"
|
||||||
|
android:icon="@drawable/ic_heartrate"
|
||||||
|
android:key="heartrate_alert_active_high_threshold"
|
||||||
|
android:summary="%s"
|
||||||
|
android:title="@string/prefs_heartrate_alert_active_high_threshold" />
|
||||||
</PreferenceCategory>
|
</PreferenceCategory>
|
||||||
|
|
||||||
<!-- Stress Monitoring -->
|
<!-- Stress Monitoring -->
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<PreferenceScreen
|
||||||
|
android:icon="@drawable/ic_drink"
|
||||||
|
android:key="screen_hydration_reminder"
|
||||||
|
android:persistent="false"
|
||||||
|
android:summary="@string/lefun_prefs_hydration_reminder_summary"
|
||||||
|
android:title="@string/lefun_prefs_hydration_reminder_title">
|
||||||
|
|
||||||
|
<SwitchPreferenceCompat
|
||||||
|
android:defaultValue="false"
|
||||||
|
android:key="pref_hydration_switch"
|
||||||
|
android:title="@string/lefun_prefs_hydration_reminder_title" />
|
||||||
|
<EditTextPreference
|
||||||
|
android:defaultValue="60"
|
||||||
|
android:key="pref_hydration_period"
|
||||||
|
android:summary="@string/lefun_prefs_hydration_reminder_summary"
|
||||||
|
android:title="@string/lefun_prefs_hydration_reminder_interval_title"/>
|
||||||
|
|
||||||
|
<SwitchPreferenceCompat
|
||||||
|
android:defaultValue="false"
|
||||||
|
android:dependency="screen_hydration_reminder"
|
||||||
|
android:key="pref_hydration_dnd"
|
||||||
|
android:summary="@string/hydration_dnd_summary"
|
||||||
|
android:title="@string/mi2_prefs_do_not_disturb" />
|
||||||
|
|
||||||
|
<nodomain.freeyourgadget.gadgetbridge.util.XTimePreference
|
||||||
|
android:defaultValue="12:00"
|
||||||
|
android:dependency="pref_hydration_dnd"
|
||||||
|
android:key="pref_hydration_dnd_start"
|
||||||
|
android:title="@string/mi2_prefs_do_not_disturb_start" />
|
||||||
|
|
||||||
|
<nodomain.freeyourgadget.gadgetbridge.util.XTimePreference
|
||||||
|
android:defaultValue="14:00"
|
||||||
|
android:dependency="pref_hydration_dnd"
|
||||||
|
android:key="pref_hydration_dnd_end"
|
||||||
|
android:title="@string/mi2_prefs_do_not_disturb_end" />
|
||||||
|
</PreferenceScreen>
|
||||||
|
</androidx.preference.PreferenceScreen>
|
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<com.mobeta.android.dslv.DragSortListPreference
|
||||||
|
android:icon="@drawable/ic_activity_unknown_small"
|
||||||
|
android:defaultValue="@array/pref_workout_activity_types_default"
|
||||||
|
android:dialogTitle="@string/mi5_prefs_workout_activity_types"
|
||||||
|
android:entries="@array/pref_workout_activity_types"
|
||||||
|
android:entryValues="@array/pref_workout_activity_types_values"
|
||||||
|
android:key="workout_activity_types_sortable"
|
||||||
|
android:persistent="true"
|
||||||
|
android:summary="@string/mi5_prefs_workout_activity_types_summary"
|
||||||
|
android:title="@string/mi5_prefs_workout_activity_types" />
|
||||||
|
</androidx.preference.PreferenceScreen>
|
@ -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 <https://www.gnu.org/licenses/>. */
|
||||||
|
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<String, Boolean> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user