Gadgetbridge/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/xiaomi/services/XiaomiHealthService.java

946 lines
43 KiB
Java

/* 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 <https://www.gnu.org/licenses/>. */
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<Integer> currentGoals = new LinkedHashSet<>();
private final Set<Integer> 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<XiaomiActivityFileId> 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);
}
}