Nothing CMF Watch Pro: Initial support

This commit is contained in:
José Rebelo 2024-01-28 18:46:13 +00:00
parent 207a2a9b01
commit 5999eb01d0
48 changed files with 4538 additions and 31 deletions

View File

@ -45,7 +45,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception {
final Schema schema = new Schema(68, MAIN_PACKAGE + ".entities");
final Schema schema = new Schema(70, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes);
@ -75,6 +75,13 @@ public class GBDaoGenerator {
addXiaomiSleepStageSamples(schema, user, device);
addXiaomiManualSamples(schema, user, device);
addXiaomiDailySummarySamples(schema, user, device);
addCmfActivitySample(schema, user, device);
addCmfStressSample(schema, user, device);
addCmfSpo2Sample(schema, user, device);
addCmfSleepSessionSample(schema, user, device);
addCmfSleepStageSample(schema, user, device);
addCmfHeartRateSample(schema, user, device);
addCmfWorkoutGpsSample(schema, user, device);
addPebbleHealthActivitySample(schema, user, device);
addPebbleHealthActivityKindOverlay(schema, user, device);
addPebbleMisfitActivitySample(schema, user, device);
@ -275,7 +282,7 @@ public class GBDaoGenerator {
private static Entity addHuamiStressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "HuamiStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE);
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
return stressSample;
}
@ -283,7 +290,7 @@ public class GBDaoGenerator {
private static Entity addHuamiSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "HuamiSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE);
spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
return spo2sample;
}
@ -407,6 +414,64 @@ public class GBDaoGenerator {
return sample;
}
private static Entity addCmfActivitySample(Schema schema, Entity user, Entity device) {
Entity activitySample = addEntity(schema, "CmfActivitySample");
addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
activitySample.implementsSerializable();
activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE);
activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
addHeartRateProperties(activitySample);
activitySample.addIntProperty("distance");
activitySample.addIntProperty("calories");
return activitySample;
}
private static Entity addCmfStressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "CmfStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
return stressSample;
}
private static Entity addCmfSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "CmfSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
return spo2sample;
}
private static Entity addCmfSleepSessionSample(Schema schema, Entity user, Entity device) {
Entity sleepSessionSample = addEntity(schema, "CmfSleepSessionSample");
addCommonTimeSampleProperties("AbstractTimeSample", sleepSessionSample, user, device);
sleepSessionSample.addLongProperty("wakeupTime");
sleepSessionSample.addByteArrayProperty("metadata");
return sleepSessionSample;
}
private static Entity addCmfSleepStageSample(Schema schema, Entity user, Entity device) {
Entity sleepStageSample = addEntity(schema, "CmfSleepStageSample");
addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device);
sleepStageSample.addIntProperty("duration").notNull();
sleepStageSample.addIntProperty("stage").notNull();
return sleepStageSample;
}
private static Entity addCmfHeartRateSample(Schema schema, Entity user, Entity device) {
Entity heartRateSample = addEntity(schema, "CmfHeartRateSample");
addCommonTimeSampleProperties("AbstractHeartRateSample", heartRateSample, user, device);
heartRateSample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetter(OVERRIDE);
return heartRateSample;
}
private static Entity addCmfWorkoutGpsSample(Schema schema, Entity user, Entity device) {
Entity sample = addEntity(schema, "CmfWorkoutGpsSample");
addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
sample.addIntProperty("latitude");
sample.addIntProperty("longitude");
return sample;
}
private static void addHeartRateProperties(Entity activitySample) {
activitySample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetterAndSetter(OVERRIDE);
}
@ -981,7 +1046,7 @@ public class GBDaoGenerator {
private static Entity addWena3StressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "Wena3StressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE);
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
return stressSample;
}

View File

@ -18,6 +18,7 @@
package nodomain.freeyourgadget.gadgetbridge.activities;
import android.os.Bundle;
import android.text.InputFilter;
import android.text.format.DateFormat;
import android.view.MenuItem;
import android.view.View;
@ -132,10 +133,15 @@ public class AlarmDetails extends AbstractGBActivity {
int snoozeVisibility = supportsSnoozing() ? View.VISIBLE : View.GONE;
cbSnooze.setVisibility(snoozeVisibility);
int descriptionVisibility = supportsDescription() ? View.VISIBLE : View.GONE;
title.setVisibility(descriptionVisibility);
title.setVisibility(supportsTitle() ? View.VISIBLE : View.GONE);
title.setText(alarm.getTitle());
description.setVisibility(descriptionVisibility);
final int titleLimit = getAlarmTitleLimit();
if (titleLimit > 0) {
title.setFilters(new InputFilter[]{new InputFilter.LengthFilter(titleLimit)});
}
description.setVisibility(supportsDescription() ? View.VISIBLE : View.GONE);
description.setText(alarm.getDescription());
cbMonday.setChecked(alarm.getRepetition(Alarm.ALARM_MON));
@ -145,7 +151,6 @@ public class AlarmDetails extends AbstractGBActivity {
cbFriday.setChecked(alarm.getRepetition(Alarm.ALARM_FRI));
cbSaturday.setChecked(alarm.getRepetition(Alarm.ALARM_SAT));
cbSunday.setChecked(alarm.getRepetition(Alarm.ALARM_SUN));
}
private boolean supportsSmartWakeup() {
@ -156,6 +161,22 @@ public class AlarmDetails extends AbstractGBActivity {
return false;
}
private boolean supportsTitle() {
if (device != null) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
return coordinator.supportsAlarmTitle(device);
}
return false;
}
private int getAlarmTitleLimit() {
if (device != null) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();
return coordinator.getAlarmTitleLimit(device);
}
return -1;
}
private boolean supportsDescription() {
if (device != null) {
DeviceCoordinator coordinator = device.getDeviceCoordinator();

View File

@ -177,6 +177,7 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_HEARTRATE_MEASUREMENT_INTERVAL = "heartrate_measurement_interval";
public static final String PREF_HEARTRATE_ACTIVITY_MONITORING = "heartrate_activity_monitoring";
public static final String PREF_HEARTRATE_ALERT_ENABLED = "heartrate_alert_enabled";
public static final String PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD = "heartrate_alert_active_high_threshold";
public static final String PREF_HEARTRATE_ALERT_HIGH_THRESHOLD = "heartrate_alert_threshold";
public static final String PREF_HEARTRATE_ALERT_LOW_THRESHOLD = "heartrate_alert_low_threshold";
public static final String PREF_HEARTRATE_STRESS_MONITORING = "heartrate_stress_monitoring";
@ -262,6 +263,9 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_ANTILOST_ENABLED = "pref_antilost_enabled";
public static final String PREF_HYDRATION_SWITCH = "pref_hydration_switch";
public static final String PREF_HYDRATION_PERIOD = "pref_hydration_period";
public static final String PREF_HYDRATION_DND = "pref_hydration_dnd";
public static final String PREF_HYDRATION_DND_START = "pref_hydration_dnd_start";
public static final String PREF_HYDRATION_DND_END = "pref_hydration_dnd_end";
public static final String PREF_AMPM_ENABLED = "pref_ampm_enabled";
public static final String PREF_SONYSWR12_LOW_VIBRATION = "vibration_preference";

View File

@ -426,6 +426,9 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
addPreferenceHandlerFor(PREF_ANTILOST_ENABLED);
addPreferenceHandlerFor(PREF_HYDRATION_SWITCH);
addPreferenceHandlerFor(PREF_HYDRATION_PERIOD);
addPreferenceHandlerFor(PREF_HYDRATION_DND);
addPreferenceHandlerFor(PREF_HYDRATION_DND_START);
addPreferenceHandlerFor(PREF_HYDRATION_DND_END);
addPreferenceHandlerFor(PREF_AMPM_ENABLED);
addPreferenceHandlerFor(PREF_SOUNDS);
addPreferenceHandlerFor(PREF_CAMERA_REMOTE);

View File

@ -17,6 +17,7 @@
package nodomain.freeyourgadget.gadgetbridge.capabilities;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ENABLED;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD;
@ -82,20 +83,23 @@ public class HeartRateCapability {
});
}
handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD);
handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_LOW_THRESHOLD);
final ListPreference heartrateMeasurementInterval = handler.findPreference(PREF_HEARTRATE_MEASUREMENT_INTERVAL);
final ListPreference heartrateAlertActiveHigh = handler.findPreference(PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
final ListPreference heartrateAlertHigh = handler.findPreference(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD);
final ListPreference heartrateAlertLow = handler.findPreference(PREF_HEARTRATE_ALERT_LOW_THRESHOLD);
// Newer devices that have low alert threshold can only use it if measurement interval is smart (-1) or 1 minute
final boolean hrAlertsNeedSmartOrOne = heartrateAlertHigh != null && heartrateAlertLow != null && heartrateMeasurementInterval != null;
final boolean hrAlertsNeedSmartOrOne = heartrateAlertActiveHigh != null && heartrateAlertHigh != null && heartrateAlertLow != null && heartrateMeasurementInterval != null;
if (hrAlertsNeedSmartOrOne) {
final boolean hrMonitoringIsSmartOrOne = heartrateMeasurementInterval.getValue().equals("60") ||
heartrateMeasurementInterval.getValue().equals("-1");
heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne);
heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne);
heartrateAlertActiveHigh.setEnabled(hrMonitoringIsSmartOrOne);
}
if (heartrateMeasurementInterval != null) {
@ -129,6 +133,7 @@ public class HeartRateCapability {
// Same as above, check if smart or 1 minute
final boolean hrMonitoringIsSmartOrOne = newVal.equals("60") || newVal.equals("-1");
heartrateAlertActiveHigh.setEnabled(hrMonitoringIsSmartOrOne);
heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne);
heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne);
}

View File

@ -436,6 +436,16 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
return false;
}
@Override
public boolean supportsAlarmTitle(GBDevice device) {
return false;
}
@Override
public int getAlarmTitleLimit(GBDevice device) {
return -1;
}
@Override
public boolean supportsAlarmDescription(GBDevice device) {
return false;

View File

@ -343,6 +343,18 @@ public interface DeviceCoordinator {
*/
boolean supportsAlarmSnoozing();
/**
* Returns true if this device/coordinator supports alarm titles
* @return
*/
boolean supportsAlarmTitle(GBDevice device);
/**
* Returns the character limit for the alarm title, negative if no limit.
* @return
*/
int getAlarmTitleLimit(GBDevice device);
/**
* Returns true if this device/coordinator supports alarm descriptions
* @return

View File

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

View File

@ -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) {
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {
}
}
}
}

View File

@ -28,6 +28,7 @@ import java.util.Locale;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
@ -51,6 +52,11 @@ public class HuamiSettingsCustomizer implements DeviceSpecificSettingsCustomizer
@Override
public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs) {
final Preference hrAlertActivePref = handler.findPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD);
if (hrAlertActivePref != null) {
hrAlertActivePref.setVisible(false);
}
// Setup the vibration patterns for all supported notification types
for (HuamiVibrationPatternNotificationType notificationType : HuamiVibrationPatternNotificationType.values()) {
final String typeKey = notificationType.name().toLowerCase(Locale.ROOT);

View File

@ -126,6 +126,11 @@ public abstract class HuaweiBRCoordinator extends AbstractBLClassicDeviceCoordin
return false;
}
@Override
public boolean supportsAlarmTitle(GBDevice device) {
return true;
}
@Override
public boolean supportsAlarmDescription(GBDevice device) {
// TODO: only name is supported

View File

@ -126,6 +126,11 @@ public abstract class HuaweiLECoordinator extends AbstractBLEDeviceCoordinator i
return false;
}
@Override
public boolean supportsAlarmTitle(GBDevice device) {
return true;
}
@Override
public boolean supportsAlarmDescription(GBDevice device) {
// TODO: only name is supported

View File

@ -180,11 +180,6 @@ public class HuaweiSpo2SampleProvider extends AbstractTimeSampleProvider<HuaweiS
return typeNum;
}
@Override
public void setTypeNum(int num) {
this.typeNum = num;
}
@Override
public void setTimestamp(long timestamp) {
this.timestamp = timestamp;

View File

@ -156,6 +156,11 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator {
return 0;
}
@Override
public boolean supportsAlarmTitle(GBDevice device) {
return isHybridHR();
}
@Override
public boolean supportsAlarmDescription(GBDevice device) {
return isHybridHR();

View File

@ -120,6 +120,11 @@ public class WithingsSteelHRDeviceCoordinator extends AbstractDeviceCoordinator
return 3;
}
@Override
public boolean supportsAlarmTitle(GBDevice device) {
return true;
}
@Override
public boolean supportsAlarmDescription(GBDevice device) {
return true;

View File

@ -45,6 +45,11 @@ public class XiaomiSettingsCustomizer implements DeviceSpecificSettingsCustomize
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);
hidePrefIfNoneVisible(handler, DeviceSettingsPreferenceConst.PREF_HEADER_DISPLAY, Arrays.asList(

View File

@ -22,18 +22,15 @@ import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
public abstract class AbstractSpo2Sample extends AbstractTimeSample implements Spo2Sample {
public abstract int getTypeNum();
public abstract void setTypeNum(int num);
public int getTypeNum() {
return Spo2Sample.Type.UNKNOWN.getNum();
}
@Override
public Type getType() {
return Type.fromNum(getTypeNum());
}
public void setType(final Type type) {
setTypeNum(type.getNum());
}
@NonNull
@Override
public String toString() {

View File

@ -22,18 +22,15 @@ import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
public abstract class AbstractStressSample extends AbstractTimeSample implements StressSample {
public abstract int getTypeNum();
public abstract void setTypeNum(int num);
public int getTypeNum() {
return Type.UNKNOWN.getNum();
}
@Override
public Type getType() {
return Type.fromNum(getTypeNum());
}
public void setType(final Type type) {
setTypeNum(type.getNum());
}
@NonNull
@Override
public String toString() {

View File

@ -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.gwb5600.CasioGMWB5000DeviceCoordinator;
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.domyos.DomyosT540Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.femometer.FemometerVinca2DeviceCoordinator;
@ -313,6 +314,7 @@ public enum DeviceType {
NOTHING_EAR1(Ear1Coordinator.class),
NOTHING_EAR2(Ear2Coordinator.class),
NOTHING_EAR_STICK(EarStickCoordinator.class),
NOTHING_CMF_WATCH_PRO(CmfWatchProCoordinator.class),
GALAXY_BUDS_PRO(GalaxyBudsProDeviceCoordinator.class),
GALAXY_BUDS_LIVE(GalaxyBudsLiveDeviceCoordinator.class),
GALAXY_BUDS(GalaxyBudsDeviceCoordinator.class),

View File

@ -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("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("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("00002a42-0000-1000-8000-00805f9b34fb", "Alert AlertCategory ID Bit Mask");
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("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(50), "16bit float");
mValueFormats.put(Integer.valueOf(34), "16bit signed int");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {
}
}

View File

@ -90,7 +90,7 @@ public class FetchSpo2NormalOperation extends AbstractRepeatingFetchOperation {
final HuamiSpo2Sample sample = new HuamiSpo2Sample();
sample.setTimestamp(timestamp.getTimeInMillis());
sample.setType(autoMeasurement ? Spo2Sample.Type.AUTOMATIC : Spo2Sample.Type.MANUAL);
sample.setTypeNum((autoMeasurement ? Spo2Sample.Type.AUTOMATIC : Spo2Sample.Type.MANUAL).getNum());
sample.setSpo2(spo2);
samples.add(sample);
}

View File

@ -77,7 +77,7 @@ public class FetchStressAutoOperation extends AbstractRepeatingFetchOperation {
final HuamiStressSample sample = new HuamiStressSample();
sample.setTimestamp(timestamp.getTimeInMillis());
sample.setType(StressSample.Type.AUTOMATIC);
sample.setTypeNum(StressSample.Type.AUTOMATIC.getNum());
sample.setStress(stress);
samples.add(sample);

View File

@ -85,7 +85,7 @@ public class FetchStressManualOperation extends AbstractRepeatingFetchOperation
final HuamiStressSample sample = new HuamiStressSample();
sample.setTimestamp(timestamp.getTimeInMillis());
sample.setType(StressSample.Type.MANUAL);
sample.setTypeNum(StressSample.Type.MANUAL.getNum());
sample.setStress(stress);
samples.add(sample);
}

View File

@ -60,7 +60,7 @@ public class StressPacketParser extends OneBytePerSamplePacketParser {
gbSample.setUserId(userId);
gbSample.setTimestamp(currentSampleDate.getTime());
gbSample.setStress(rawSample);
gbSample.setType(StressSample.Type.AUTOMATIC);
gbSample.setTypeNum(StressSample.Type.AUTOMATIC.getNum());
samples.add(gbSample);
} else {
LOG.debug("Discard stress value as out of range: " + rawSample);

View File

@ -1201,6 +1201,237 @@
<item>indoor_ice_skating</item>
</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">
<item>@string/activity_type_outdoor_running</item>
<item>@string/activity_type_hiking</item>
@ -2389,6 +2620,26 @@
<item>150</item>
</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">
<item name="0">@string/off</item>
<item name="40">@string/heartrate_bpm_40</item>

View File

@ -712,6 +712,12 @@
<string name="heartrate_bpm_140">140 bpm</string>
<string name="heartrate_bpm_145">145 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_85">85%</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_threshold">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_stress_monitoring_title">Stress monitoring</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_threshold">Inactivity threshold (in minutes)</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_summary">Configure heart rate monitoring</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_running">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_outdoor_walking">Outdoor Walking</string>
<string name="activity_type_indoor_walking">Indoor Walking</string>
<string name="activity_type_surfing">Surfing</string>
<string name="activity_type_windsurfing">Windsurfing</string>
@ -1991,6 +2075,7 @@
<string name="devicetype_nothingear1">Nothing Ear (1)</string>
<string name="devicetype_nothingear2">Nothing Ear (2)</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_live">Galaxy Buds Live</string>
<string name="devicetype_galaxybuds_pro">Galaxy Buds Pro</string>

View File

@ -70,6 +70,15 @@
android:key="heartrate_alert_low_threshold"
android:summary="%s"
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>
<!-- Stress Monitoring -->

View File

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

View File

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

View File

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