/* Copyright (C) 2023-2024 José Rebelo, Yoran Vulker This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.services; import android.content.Intent; import android.location.Location; import android.os.Handler; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import com.google.protobuf.ByteString; 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.Date; import java.util.GregorianCalendar; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiSampleProvider; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiActivitySample; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationProviderType; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService; import nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks.OpenTracksController; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityFileFetcher; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityFileId; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public class XiaomiHealthService extends AbstractXiaomiService { private static final Logger LOG = LoggerFactory.getLogger(XiaomiHealthService.class); public static final int COMMAND_TYPE = 8; private static final int CMD_SET_USER_INFO = 0; private static final int CMD_ACTIVITY_FETCH_TODAY = 1; private static final int CMD_ACTIVITY_FETCH_PAST = 2; private static final int CMD_ACTIVITY_FETCH_REQUEST = 3; private static final int CMD_ACTIVITY_FETCH_ACK = 5; private static final int CMD_CONFIG_SPO2_GET = 8; private static final int CMD_CONFIG_SPO2_SET = 9; private static final int CMD_CONFIG_HEART_RATE_GET = 10; private static final int CMD_CONFIG_HEART_RATE_SET = 11; private static final int CMD_CONFIG_STANDING_REMINDER_GET = 12; private static final int CMD_CONFIG_STANDING_REMINDER_SET = 13; private static final int CMD_CONFIG_STRESS_GET = 14; private static final int CMD_CONFIG_STRESS_SET = 15; private static final int CMD_CONFIG_GOAL_NOTIFICATION_GET = 21; private static final int CMD_CONFIG_GOAL_NOTIFICATION_SET = 22; private static final int CMD_WORKOUT_WATCH_STATUS = 26; private static final int CMD_WORKOUT_WATCH_OPEN = 30; private static final int CMD_CONFIG_VITALITY_SCORE_GET = 35; private static final int CMD_CONFIG_VITALITY_SCORE_SET = 36; private static final int CMD_WORKOUT_LOCATION = 48; private static final int CMD_CONFIG_GOALS_GET = 42; private static final int CMD_CONFIG_GOALS_SET = 43; private static final int CMD_REALTIME_STATS_START = 45; private static final int CMD_REALTIME_STATS_STOP = 46; private static final int CMD_REALTIME_STATS_EVENT = 47; private static final int GENDER_MALE = 1; private static final int GENDER_FEMALE = 2; private static final int WORKOUT_STARTED = 0; private static final int WORKOUT_RESUMED = 1; private static final int WORKOUT_PAUSED = 2; private static final int WORKOUT_FINISHED = 3; private boolean realtimeStarted = false; private boolean realtimeOneShot = false; private int previousSteps = -1; private boolean gpsStarted = false; private boolean gpsFixAcquired = false; private boolean workoutStarted = false; private final Handler gpsTimeoutHandler = new Handler(); private final Set currentGoals = new LinkedHashSet<>(); private final Set supportedGoals = new LinkedHashSet<>(); private static final int GOAL_STEPS = 1; // TODO confirm private static final int GOAL_CALORIES = 2; // TODO confirm private static final int GOAL_MOVING_TIME = 3; private static final int GOAL_STANDING_TIME = 4; private final XiaomiActivityFileFetcher activityFetcher = new XiaomiActivityFileFetcher(this); public XiaomiHealthService(final XiaomiSupport support) { super(support); } @Override public void handleCommand(final XiaomiProto.Command cmd) { switch (cmd.getSubtype()) { case CMD_SET_USER_INFO: LOG.debug("Got user info set ack, status={}", cmd.getStatus()); return; case CMD_ACTIVITY_FETCH_TODAY: case CMD_ACTIVITY_FETCH_PAST: handleActivityFetchResponse(cmd.getSubtype(), cmd.getHealth().getActivityRequestFileIds().toByteArray()); return; case CMD_CONFIG_SPO2_GET: handleSpo2Config(cmd.getHealth().getSpo2()); return; case CMD_CONFIG_SPO2_SET: LOG.debug("Got spo2 set ack, status={}", cmd.getStatus()); return; case CMD_CONFIG_HEART_RATE_SET: LOG.debug("Got heart rate set ack, status={}", cmd.getStatus()); return; case CMD_CONFIG_HEART_RATE_GET: handleHeartRateConfig(cmd.getHealth().getHeartRate()); return; case CMD_CONFIG_STANDING_REMINDER_GET: handleStandingReminderConfig(cmd.getHealth().getStandingReminder()); return; case CMD_CONFIG_STANDING_REMINDER_SET: LOG.debug("Got standing reminder set ack, status={}", cmd.getStatus()); return; case CMD_CONFIG_STRESS_GET: handleStressConfig(cmd.getHealth().getStress()); return; case CMD_CONFIG_STRESS_SET: LOG.debug("Got stress set ack, status={}", cmd.getStatus()); return; case CMD_CONFIG_GOAL_NOTIFICATION_GET: handleGoalNotificationConfig(cmd.getHealth().getGoalNotification()); return; case CMD_CONFIG_GOAL_NOTIFICATION_SET: LOG.debug("Got goal notification set ack, status={}", cmd.getStatus()); return; case CMD_CONFIG_GOALS_GET: handleGoalsConfig(cmd.getHealth().getGoalsConfig()); return; case CMD_CONFIG_GOALS_SET: LOG.debug("Got goals config set ack, status={}", cmd.getStatus()); return; case CMD_CONFIG_VITALITY_SCORE_GET: handleVitalityScore(cmd.getHealth().getVitalityScore()); return; case CMD_CONFIG_VITALITY_SCORE_SET: LOG.debug("Got vitality score set ack, status={}", cmd.getStatus()); return; case CMD_WORKOUT_WATCH_STATUS: handleWorkoutStatus(cmd.getHealth().getWorkoutStatusWatch()); return; case CMD_WORKOUT_WATCH_OPEN: handleWorkoutOpen(cmd.getHealth().getWorkoutOpenWatch()); return; case CMD_REALTIME_STATS_EVENT: handleRealtimeStats(cmd.getHealth().getRealTimeStats()); return; } LOG.warn("Unknown health command {}", cmd.getSubtype()); } @Override public void initialize() { setUserInfo(); getSupport().sendCommand("get spo2 config", COMMAND_TYPE, CMD_CONFIG_SPO2_GET); getSupport().sendCommand("get heart rate config", COMMAND_TYPE, CMD_CONFIG_HEART_RATE_GET); getSupport().sendCommand("get standing reminders config", COMMAND_TYPE, CMD_CONFIG_STANDING_REMINDER_GET); getSupport().sendCommand("get stress config", COMMAND_TYPE, CMD_CONFIG_STRESS_GET); getSupport().sendCommand("get goal notification config", COMMAND_TYPE, CMD_CONFIG_GOAL_NOTIFICATION_GET); getSupport().sendCommand("get goals config", COMMAND_TYPE, CMD_CONFIG_GOALS_GET); getSupport().sendCommand("get vitality score config", COMMAND_TYPE, CMD_CONFIG_VITALITY_SCORE_GET); } @Override public boolean onSendConfiguration(final String config, final Prefs prefs) { switch (config) { case ActivityUser.PREF_USER_HEIGHT_CM: case ActivityUser.PREF_USER_WEIGHT_KG: case ActivityUser.PREF_USER_YEAR_OF_BIRTH: case ActivityUser.PREF_USER_GENDER: case ActivityUser.PREF_USER_CALORIES_BURNT: case ActivityUser.PREF_USER_STEPS_GOAL: case ActivityUser.PREF_USER_GOAL_STANDING_TIME_HOURS: case ActivityUser.PREF_USER_ACTIVETIME_MINUTES: setUserInfo(); return true; case DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL_NOTIFICATION: sendGoalNotificationConfig(); return true; case DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL_SECONDARY: sendGoalsConfig(); return true; case DeviceSettingsPreferenceConst.PREF_VITALITY_SCORE_7_DAY: case DeviceSettingsPreferenceConst.PREF_VITALITY_SCORE_DAILY: sendVitalityScoreConfig(); return true; case DeviceSettingsPreferenceConst.PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION: case DeviceSettingsPreferenceConst.PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING: case DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL: case DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ENABLED: case DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD: case DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD: setHeartRateConfig(); return true; case DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING: case DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD: setSpo2Config(); return true; case DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE: 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: setStandingReminderConfig(); return true; case DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING: case DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_RELAXATION_REMINDER: setStressConfig(); return true; } return false; } public void setUserInfo() { LOG.debug("Setting user info"); final ActivityUser activityUser = new ActivityUser(); final int birthYear = activityUser.getYearOfBirth(); final byte birthMonth = 7; // not in user attributes final byte birthDay = 1; // not in user attributes final int genderInt = activityUser.getGender() != ActivityUser.GENDER_FEMALE ? GENDER_MALE : GENDER_FEMALE; // TODO other gender? final Calendar now = GregorianCalendar.getInstance(); final int age = now.get(Calendar.YEAR) - birthYear; // Compute the approximate max heart rate from the user age // TODO max heart rate should be input by the user int maxHeartRate = (int) Math.round(age <= 40 ? 220 - age : 207 - 0.7 * age); if (maxHeartRate < 100 || maxHeartRate > 220) { maxHeartRate = 175; } final XiaomiProto.UserInfo userInfo = XiaomiProto.UserInfo.newBuilder() .setHeight(activityUser.getHeightCm()) .setWeight(activityUser.getWeightKg()) .setBirthday(Integer.parseInt(String.format(Locale.ROOT, "%04d%02d%02d", birthYear, birthMonth, birthDay))) .setGender(genderInt) .setMaxHeartRate(maxHeartRate) .setGoalCalories(activityUser.getCaloriesBurntGoal()) .setGoalSteps(activityUser.getStepsGoal()) .setGoalStanding(activityUser.getStandingTimeGoalHours()) .setGoalMoving(activityUser.getActiveTimeGoalMinutes()) .build(); final XiaomiProto.Health health = XiaomiProto.Health.newBuilder() .setUserInfo(userInfo) .build(); getSupport().sendCommand( "set user info", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_SET_USER_INFO) .setHealth(health) .build() ); } private void handleGoalNotificationConfig(final XiaomiProto.GoalNotification goalNotification) { LOG.debug("Got goal notification config"); final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() .withPreference(XiaomiPreferences.FEAT_GOAL_NOTIFICATION, true) .withPreference(DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL_NOTIFICATION, goalNotification.getEnabled()); getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); } public void sendGoalNotificationConfig() { final boolean enabled = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL_NOTIFICATION, false); LOG.debug("Setting goal notification enabled = {}", enabled); final XiaomiProto.GoalNotification.Builder goalNotification = XiaomiProto.GoalNotification.newBuilder() .setEnabled(enabled) .setUnknown2(1); final XiaomiProto.Health health = XiaomiProto.Health.newBuilder() .setGoalNotification(goalNotification) .build(); getSupport().sendCommand( "set goal notification config", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_CONFIG_GOAL_NOTIFICATION_SET) .setHealth(health) .build() ); } private void handleGoalsConfig(final XiaomiProto.GoalsConfig goalsConfig) { LOG.debug("Got goals config"); currentGoals.clear(); supportedGoals.clear(); for (final XiaomiProto.Goal goal : goalsConfig.getCurrentGoalsList()) { currentGoals.add(goal.getId()); } for (final XiaomiProto.Goal goal : goalsConfig.getSupportedGoalsList()) { supportedGoals.add(goal.getId()); } final boolean secondaryGoalSupported = supportedGoals.contains(GOAL_STANDING_TIME) || supportedGoals.contains(GOAL_MOVING_TIME); final String secondaryValue = currentGoals.contains(GOAL_MOVING_TIME) ? "active_time" : "standing_time"; final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() .withPreference(XiaomiPreferences.FEAT_GOAL_SECONDARY, secondaryGoalSupported) .withPreference(DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL_SECONDARY, secondaryValue); getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); } public void sendGoalsConfig() { final String goalSecondary = getDevicePrefs().getString(DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL_SECONDARY, "standing_time"); LOG.debug("Setting goals config = {}", goalSecondary); final XiaomiProto.GoalsConfig.Builder goalsConfig = XiaomiProto.GoalsConfig.newBuilder(); for (final Integer currentGoal : currentGoals) { if (!currentGoal.equals(GOAL_STANDING_TIME) && !currentGoal.equals(GOAL_MOVING_TIME)) { goalsConfig.addCurrentGoals(XiaomiProto.Goal.newBuilder().setId(currentGoal)); } } if (goalSecondary.equals("active_time")) { goalsConfig.addCurrentGoals(XiaomiProto.Goal.newBuilder().setId(GOAL_MOVING_TIME)); } else { goalsConfig.addCurrentGoals(XiaomiProto.Goal.newBuilder().setId(GOAL_STANDING_TIME)); } for (final Integer supportedGoal : supportedGoals) { goalsConfig.addSupportedGoals(XiaomiProto.Goal.newBuilder().setId(supportedGoal)); } final XiaomiProto.Health health = XiaomiProto.Health.newBuilder() .setGoalsConfig(goalsConfig) .build(); getSupport().sendCommand( "set goals config", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_CONFIG_GOALS_SET) .setHealth(health) .build() ); } private void handleVitalityScore(final XiaomiProto.VitalityScore vitalityScore) { LOG.debug("Got vitality score config"); final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() .withPreference(XiaomiPreferences.FEAT_VITALITY_SCORE, true) .withPreference(DeviceSettingsPreferenceConst.PREF_VITALITY_SCORE_7_DAY, vitalityScore.getSevenDay()) .withPreference(DeviceSettingsPreferenceConst.PREF_VITALITY_SCORE_DAILY, vitalityScore.getDailyProgress()); getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); } public void sendVitalityScoreConfig() { final boolean prefSevenDay = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_VITALITY_SCORE_7_DAY, false); final boolean prefDaily = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_VITALITY_SCORE_DAILY, false); LOG.debug("Setting vitality score config, 7day={}, daily={}", prefSevenDay, prefDaily); final XiaomiProto.VitalityScore vitalityScore = XiaomiProto.VitalityScore.newBuilder() .setSevenDay(prefSevenDay) .setDailyProgress(prefDaily) .build(); final XiaomiProto.Health health = XiaomiProto.Health.newBuilder() .setVitalityScore(vitalityScore) .build(); getSupport().sendCommand( "set vitality score config", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_CONFIG_VITALITY_SCORE_SET) .setHealth(health) .build() ); } private void handleSpo2Config(final XiaomiProto.SpO2 spo2) { LOG.debug("Got SpO2 config"); final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() .withPreference(XiaomiPreferences.FEAT_SPO2, true) .withPreference(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, spo2.getAllDayTracking()) .withPreference( DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD, String.valueOf(spo2.getAlarmLow().getAlarmLowEnabled() ? spo2.getAlarmLow().getAlarmLowThreshold() : 0) ); getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); } private void setSpo2Config() { LOG.debug("Set SpO2 config"); final Prefs prefs = getDevicePrefs(); final boolean allDayMonitoring = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, false); final int lowAlertThreshold = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD, 0); final XiaomiProto.Spo2AlarmLow.Builder spo2alarmLowBuilder = XiaomiProto.Spo2AlarmLow.newBuilder() .setAlarmLowEnabled(lowAlertThreshold != 0); if (lowAlertThreshold != 0) { spo2alarmLowBuilder.setAlarmLowThreshold(lowAlertThreshold); } final XiaomiProto.SpO2.Builder spo2 = XiaomiProto.SpO2.newBuilder() .setUnknown1(1) .setAllDayTracking(allDayMonitoring) .setAlarmLow(spo2alarmLowBuilder); getSupport().sendCommand( "set spo2 config", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_CONFIG_SPO2_SET) .setHealth(XiaomiProto.Health.newBuilder().setSpo2(spo2)) .build() ); } private void handleHeartRateConfig(final XiaomiProto.HeartRate heartRate) { LOG.debug("Got heart rate config"); final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences(); if (heartRate.getDisabled()) { eventUpdatePreferences.withPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL, "0"); } else if (heartRate.getInterval() == 0) { // smart eventUpdatePreferences.withPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL, "-1"); } else { eventUpdatePreferences.withPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL, String.valueOf(heartRate.getInterval())); } eventUpdatePreferences.withPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION, heartRate.getAdvancedMonitoring().getEnabled()); eventUpdatePreferences.withPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING, heartRate.getBreathingScore() == 1); eventUpdatePreferences.withPreference( DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD, String.valueOf(heartRate.getAlarmHighEnabled() ? heartRate.getAlarmHighThreshold() : 0) ); eventUpdatePreferences.withPreference( DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD, String.valueOf(heartRate.getHeartRateAlarmLow().getAlarmLowEnabled() ? heartRate.getHeartRateAlarmLow().getAlarmLowThreshold() : 0) ); getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); } public void setHeartRateConfig() { final Prefs prefs = getDevicePrefs(); final boolean sleepDetection = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION, false); final boolean sleepBreathingQuality = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING, false); final int intervalSeconds = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL, 0); final int alertHigh = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD, 0); final int alertLow = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD, 0); int intervalMin; if (intervalSeconds == -1) { // Smart intervalMin = 0; } else { intervalMin = intervalSeconds / 60; } LOG.debug( "Set heart rate config: sleepDetection={}, sleepBreathingQuality={}, intervalSeconds={}, alertHigh={}, alertLow={}", sleepDetection, sleepBreathingQuality, intervalSeconds, alertHigh, alertLow ); final XiaomiProto.HeartRate.Builder heartRate = XiaomiProto.HeartRate.newBuilder() .setDisabled(intervalSeconds == 0) .setInterval(intervalMin) .setAdvancedMonitoring(XiaomiProto.AdvancedMonitoring.newBuilder() .setEnabled(sleepDetection)) .setBreathingScore(sleepBreathingQuality ? 1 : 2) .setAlarmHighEnabled(alertHigh > 0) .setAlarmHighThreshold(alertHigh) .setHeartRateAlarmLow(XiaomiProto.HeartRateAlarmLow.newBuilder() .setAlarmLowEnabled(alertLow > 0) .setAlarmLowThreshold(alertLow)) .setUnknown7(1); getSupport().sendCommand( "set heart rate config", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_CONFIG_HEART_RATE_SET) .setHealth(XiaomiProto.Health.newBuilder().setHeartRate(heartRate)) .build() ); } private void handleStandingReminderConfig(final XiaomiProto.StandingReminder standingReminder) { LOG.debug("Got standing reminder config"); final String start = XiaomiPreferences.prefFromHourMin(standingReminder.getStart()); final String end = XiaomiPreferences.prefFromHourMin(standingReminder.getEnd()); final String dndStart = XiaomiPreferences.prefFromHourMin(standingReminder.getDndStart()); final String dndEnd = XiaomiPreferences.prefFromHourMin(standingReminder.getDndEnd()); final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() .withPreference(XiaomiPreferences.FEAT_INACTIVITY, true) .withPreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE, standingReminder.getEnabled()) .withPreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_START, start) .withPreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_END, end) .withPreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND, standingReminder.getDnd()) .withPreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_START, dndStart) .withPreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_END, dndEnd); getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); } private void setStandingReminderConfig() { LOG.debug("Set standing reminder config"); final Prefs prefs = getDevicePrefs(); final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE, false); final Date start = prefs.getTimePreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_START, "06:00"); final Date end = prefs.getTimePreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_END, "22:00"); 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 XiaomiProto.StandingReminder standingReminder = XiaomiProto.StandingReminder.newBuilder() .setEnabled(enabled) .setStart(XiaomiPreferences.prefToHourMin(start)) .setEnd(XiaomiPreferences.prefToHourMin(end)) .setDnd(dnd) .setDndStart(XiaomiPreferences.prefToHourMin(dndStart)) .setDndEnd(XiaomiPreferences.prefToHourMin(dndEnd)) .build(); getSupport().sendCommand( "set standing reminder config", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_CONFIG_STANDING_REMINDER_SET) .setHealth(XiaomiProto.Health.newBuilder().setStandingReminder(standingReminder)) .build() ); } private void handleStressConfig(final XiaomiProto.Stress stress) { LOG.debug("Got stress config"); final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() .withPreference(XiaomiPreferences.FEAT_STRESS, true) .withPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING, stress.getAllDayTracking()) .withPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_RELAXATION_REMINDER, stress.getRelaxReminder().getEnabled()); getSupport().evaluateGBDeviceEvent(eventUpdatePreferences); } private void setStressConfig() { LOG.debug("Set stress config"); final Prefs prefs = getDevicePrefs(); final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING, false); final boolean relaxReminder = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_RELAXATION_REMINDER, false); final XiaomiProto.Stress.Builder stress = XiaomiProto.Stress.newBuilder() .setAllDayTracking(enabled) .setRelaxReminder(XiaomiProto.RelaxReminder.newBuilder().setEnabled(relaxReminder).setUnknown2(0)); getSupport().sendCommand( "set stress config", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_CONFIG_STRESS_SET) .setHealth(XiaomiProto.Health.newBuilder().setStress(stress)) .build() ); } private void handleWorkoutOpen(final XiaomiProto.WorkoutOpenWatch workoutOpenWatch) { LOG.debug( "Workout open on watch: {}, workoutStarted={}, gpsStarted={}, gpsFixAcquired={}", workoutOpenWatch.getSport(), workoutStarted, gpsStarted, gpsFixAcquired ); workoutStarted = false; final boolean sendGpsToBand = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_WORKOUT_SEND_GPS_TO_BAND, false); if (!sendGpsToBand) { getSupport().sendCommand( "send location disabled", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_WORKOUT_WATCH_OPEN) .setHealth(XiaomiProto.Health.newBuilder().setWorkoutOpenReply( XiaomiProto.WorkoutOpenReply.newBuilder() .setUnknown1(3) .setUnknown2(2) .setUnknown3(10) )) .build() ); return; } if (!gpsStarted) { gpsStarted = true; gpsFixAcquired = false; GBLocationService.start(getSupport().getContext(), getSupport().getDevice(), GBLocationProviderType.GPS, 1000); } gpsTimeoutHandler.removeCallbacksAndMessages(null); // Timeout if the watch stops sending workout open gpsTimeoutHandler.postDelayed(() -> { LOG.debug("Timed out waiting for workout"); gpsStarted = false; gpsFixAcquired = false; GBLocationService.stop(getSupport().getContext(), getSupport().getDevice()); }, 5000); } private void handleWorkoutStatus(final XiaomiProto.WorkoutStatusWatch workoutStatus) { LOG.debug("Got workout status: {}", workoutStatus.getStatus()); final boolean startOnPhone = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_WORKOUT_START_ON_PHONE, false); switch (workoutStatus.getStatus()) { case WORKOUT_STARTED: workoutStarted = true; gpsTimeoutHandler.removeCallbacksAndMessages(null); if (startOnPhone) { OpenTracksController.startRecording(getSupport().getContext(), sportToActivityKind(workoutStatus.getSport())); } break; case WORKOUT_RESUMED: case WORKOUT_PAUSED: break; case WORKOUT_FINISHED: gpsStarted = false; gpsFixAcquired = false; GBLocationService.stop(getSupport().getContext(), getSupport().getDevice()); if (startOnPhone) { OpenTracksController.stopRecording(getSupport().getContext()); } break; } } public void onSetGpsLocation(final Location location) { if (!gpsFixAcquired) { gpsFixAcquired = true; getSupport().sendCommand( "send gps fix", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_WORKOUT_WATCH_OPEN) .setHealth(XiaomiProto.Health.newBuilder().setWorkoutOpenReply( XiaomiProto.WorkoutOpenReply.newBuilder() .setUnknown1(0) .setUnknown2(2) .setUnknown3(2) )) .build() ); } if (workoutStarted) { final XiaomiProto.WorkoutLocation.Builder workoutLocation = XiaomiProto.WorkoutLocation.newBuilder() .setUnknown1(2) .setTimestamp((int) (location.getTime() / 1000L)) .setLongitude(location.getLongitude()) .setLatitude(location.getLatitude()) .setAltitude(location.getAltitude()) .setSpeed(location.getSpeed()) .setBearing(location.getBearing()); // FIXME: Check the value for these during actual workouts, but it seems to work without them //if (location.hasAccuracy() && location.getAccuracy() != 100) { // workoutLocation.setHorizontalAccuracy(location.getAccuracy()); //} //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && location.hasVerticalAccuracy() && location.getVerticalAccuracyMeters() != 100) { // workoutLocation.setVerticalAccuracy(location.getVerticalAccuracyMeters()); //} getSupport().sendCommand( "send gps location", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_WORKOUT_LOCATION) .setHealth(XiaomiProto.Health.newBuilder().setWorkoutLocation(workoutLocation)) .build() ); } } private int sportToActivityKind(final int sport) { switch (sport) { case 1: // outdoor run case 5: // trail run return ActivityKind.TYPE_RUNNING; case 2: return ActivityKind.TYPE_WALKING; case 3: // hiking case 4: // trekking return ActivityKind.TYPE_HIKING; case 6: return ActivityKind.TYPE_CYCLING; } LOG.warn("Unknown sport {}", sport); return ActivityKind.TYPE_UNKNOWN; } public XiaomiActivityFileFetcher getActivityFetcher() { return activityFetcher; } public void onFetchRecordedData(final int dataTypes) { LOG.debug("Fetch recorded data: {}", String.format("0x%08X", dataTypes)); fetchRecordedDataToday(); } private void fetchRecordedDataToday() { getSupport().sendCommand( "fetch recorded data today", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_ACTIVITY_FETCH_TODAY) .setHealth(XiaomiProto.Health.newBuilder().setActivitySyncRequestToday( // TODO official app sends 0, but sometimes 1? XiaomiProto.ActivitySyncRequestToday.newBuilder().setUnknown1(0) )) .build() ); } private void fetchRecordedDataPast() { getSupport().sendCommand( "fetch recorded data past", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_ACTIVITY_FETCH_PAST) .build() ); } public void requestRecordedData(final XiaomiActivityFileId fileId) { getSupport().sendCommand( "request recorded data", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_ACTIVITY_FETCH_REQUEST) .setHealth(XiaomiProto.Health.newBuilder().setActivityRequestFileIds( ByteString.copyFrom(fileId.toBytes()) )) .build() ); } public void ackRecordedData(final XiaomiActivityFileId fileId) { getSupport().sendCommand( "ack recorded data", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_ACTIVITY_FETCH_ACK) .setHealth(XiaomiProto.Health.newBuilder().setActivitySyncAckFileIds( ByteString.copyFrom(fileId.toBytes()) )) .build() ); } public void handleActivityFetchResponse(final int subtype, final byte[] recordIds) { if ((recordIds.length % 7) != 0) { LOG.warn("recordIds {} length = {}, not a multiple of 7, can't parse", GB.hexdump(recordIds), recordIds.length); return; } LOG.debug("Got {} activity file IDs", recordIds.length / 7); final ByteBuffer buf = ByteBuffer.wrap(recordIds).order(ByteOrder.LITTLE_ENDIAN); final List fileIds = new ArrayList<>(); while (buf.position() < buf.limit()) { final XiaomiActivityFileId fileId = XiaomiActivityFileId.from(buf); LOG.debug("Got activity to fetch: {}", fileId); fileIds.add(fileId); } activityFetcher.fetch(fileIds); if (subtype == CMD_ACTIVITY_FETCH_TODAY) { LOG.debug("Fetch recorded data from the past"); fetchRecordedDataPast(); } } public void onHeartRateTest() { LOG.debug("Trigger heart rate one-shot test"); realtimeStarted = true; realtimeOneShot = true; getSupport().sendCommand( "heart rate test", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(CMD_REALTIME_STATS_START) .build() ); } public void enableRealtimeStats(final boolean enable) { LOG.debug("Enable realtime stats: {}", enable); if (realtimeStarted == enable) { // same state, ignore return; } realtimeStarted = enable; realtimeOneShot = false; previousSteps = -1; getSupport().sendCommand( "realtime data", XiaomiProto.Command.newBuilder() .setType(COMMAND_TYPE) .setSubtype(enable ? CMD_REALTIME_STATS_START : CMD_REALTIME_STATS_STOP) .build() ); } private void handleRealtimeStats(final XiaomiProto.RealTimeStats realTimeStats) { LOG.debug("Got realtime stats"); if (!realtimeOneShot && !realtimeStarted) { // Failsafe in case it gets out of sync, stop it enableRealtimeStats(false); return; } if (realtimeOneShot) { if (realTimeStats.getHeartRate() <= 10) { return; } enableRealtimeStats(false); } if (previousSteps == -1) { previousSteps = realTimeStats.getSteps(); } final XiaomiActivitySample sample; try (final DBHandler dbHandler = GBApplication.acquireDB()) { final DaoSession session = dbHandler.getDaoSession(); final GBDevice gbDevice = getSupport().getDevice(); final Device device = DBHelper.getDevice(gbDevice, session); final User user = DBHelper.getUser(session); final int ts = (int) (System.currentTimeMillis() / 1000); final XiaomiSampleProvider provider = new XiaomiSampleProvider(gbDevice, session); sample = provider.createActivitySample(); sample.setDeviceId(device.getId()); sample.setUserId(user.getId()); sample.setTimestamp(ts); sample.setHeartRate(realTimeStats.getHeartRate()); sample.setSteps(realTimeStats.getSteps() - previousSteps); sample.setRawKind(ActivityKind.TYPE_UNKNOWN); sample.setHeartRate(realTimeStats.getHeartRate()); sample.setRawIntensity(ActivitySample.NOT_MEASURED); sample.setRawKind(ActivityKind.TYPE_UNKNOWN); } catch (final Exception e) { LOG.error("Error creating activity sample", e); return; } previousSteps = realTimeStats.getSteps(); final Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES) .putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, sample); LocalBroadcastManager.getInstance(getSupport().getContext()).sendBroadcast(intent); } }