/* Copyright (C) 2022 José Rebelo This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami; import static org.apache.commons.lang3.ArrayUtils.subarray; import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.*; import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.SUCCESS; import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_NAME; import static nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions.fromUint16; import static nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions.fromUint8; import static nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions.mapTimeZone; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.FITNESS_GOAL_CALORIES; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.FITNESS_GOAL_FAT_BURN_TIME; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.FITNESS_GOAL_SLEEP; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.FITNESS_GOAL_STANDING_TIME; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.FITNESS_GOAL_STEPS; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.FITNESS_GOAL_WEIGHT; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.HEART_RATE_ALL_DAY_MONITORING; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.LANGUAGE; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.LANGUAGE_FOLLOW_PHONE; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.PASSWORD_ENABLED; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.PASSWORD_TEXT; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.SLEEP_HIGH_ACCURACY_MONITORING; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.TEMPERATURE_UNIT; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.TIME_FORMAT; import android.location.Location; import android.net.Uri; import android.os.Handler; import android.widget.Toast; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventAppInfo; import nodomain.freeyourgadget.gadgetbridge.capabilities.loyaltycards.LoyaltyCard; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventScreenshot; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsAgpsInstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsGpxRouteInstallHandler; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.miband.VibrationProfile; import nodomain.freeyourgadget.gadgetbridge.externalevents.CalendarReceiver; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.Alarm; import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec; 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.Reminder; import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.UpdateFirmwareOperation; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.UpdateFirmwareOperation2021; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations.ZeppOsAgpsUpdateOperation; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations.ZeppOsGpxRouteUploadOperation; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAgpsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAlarmsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAlexaService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAppsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsCalendarService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsCannedMessagesService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsDisplayItemsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsHttpService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsLogsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsLoyaltyCardService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsMusicService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsNotificationService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsRemindersService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsServicesService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsShortcutCardsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsContactsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsFileTransferService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsFtpServerService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsMorningUpdatesService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsPhoneService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWatchfaceService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWifiService; import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFileTransferService.Callback { private static final Logger LOG = LoggerFactory.getLogger(Huami2021Support.class); // Tracks whether realtime HR monitoring is already started, so we can just // send CONTINUE commands private boolean heartRateRealtimeStarted; // Keep track of whether the rawSensor is enabled private boolean rawSensor = false; // Services private final ZeppOsServicesService servicesService = new ZeppOsServicesService(this); private final ZeppOsFileTransferService fileTransferService = new ZeppOsFileTransferService(this); private final ZeppOsConfigService configService = new ZeppOsConfigService(this); private final ZeppOsAgpsService agpsService = new ZeppOsAgpsService(this); private final ZeppOsWifiService wifiService = new ZeppOsWifiService(this); private final ZeppOsFtpServerService ftpServerService = new ZeppOsFtpServerService(this); private final ZeppOsContactsService contactsService = new ZeppOsContactsService(this); private final ZeppOsMorningUpdatesService morningUpdatesService = new ZeppOsMorningUpdatesService(this); private final ZeppOsPhoneService phoneService = new ZeppOsPhoneService(this); private final ZeppOsShortcutCardsService shortcutCardsService = new ZeppOsShortcutCardsService(this); private final ZeppOsWatchfaceService watchfaceService = new ZeppOsWatchfaceService(this); private final ZeppOsAlarmsService alarmsService = new ZeppOsAlarmsService(this); private final ZeppOsCalendarService calendarService = new ZeppOsCalendarService(this); private final ZeppOsCannedMessagesService cannedMessagesService = new ZeppOsCannedMessagesService(this); private final ZeppOsNotificationService notificationService = new ZeppOsNotificationService(this, fileTransferService); private final ZeppOsAlexaService alexaService = new ZeppOsAlexaService(this); private final ZeppOsAppsService appsService = new ZeppOsAppsService(this); private final ZeppOsLogsService logsService = new ZeppOsLogsService(this); private final ZeppOsDisplayItemsService displayItemsService = new ZeppOsDisplayItemsService(this); private final ZeppOsHttpService httpService = new ZeppOsHttpService(this); private final ZeppOsRemindersService remindersService = new ZeppOsRemindersService(this); private final ZeppOsLoyaltyCardService loyaltyCardService = new ZeppOsLoyaltyCardService(this); private final ZeppOsMusicService musicService = new ZeppOsMusicService(this); private final Map mServiceMap = new LinkedHashMap() {{ put(servicesService.getEndpoint(), servicesService); put(fileTransferService.getEndpoint(), fileTransferService); put(configService.getEndpoint(), configService); put(agpsService.getEndpoint(), agpsService); put(wifiService.getEndpoint(), wifiService); put(ftpServerService.getEndpoint(), ftpServerService); put(contactsService.getEndpoint(), contactsService); put(morningUpdatesService.getEndpoint(), morningUpdatesService); put(phoneService.getEndpoint(), phoneService); put(shortcutCardsService.getEndpoint(), shortcutCardsService); put(watchfaceService.getEndpoint(), watchfaceService); put(alarmsService.getEndpoint(), alarmsService); put(calendarService.getEndpoint(), calendarService); put(cannedMessagesService.getEndpoint(), cannedMessagesService); put(notificationService.getEndpoint(), notificationService); put(alexaService.getEndpoint(), alexaService); put(appsService.getEndpoint(), appsService); put(logsService.getEndpoint(), logsService); put(displayItemsService.getEndpoint(), displayItemsService); put(httpService.getEndpoint(), httpService); put(remindersService.getEndpoint(), remindersService); put(loyaltyCardService.getEndpoint(), loyaltyCardService); put(musicService.getEndpoint(), musicService); }}; public Huami2021Support() { this(LOG); } public Huami2021Support(final Logger logger) { super(logger); } @Override protected byte getAuthFlags() { return 0x00; } @Override public byte getCryptFlags() { return (byte) 0x80; } /** * Do not reset the gatt callback implicitly, as that would interrupt operations. * See https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/2912 for more information. */ @Override public boolean getImplicitCallbackModify() { return false; } @Override public void onSendConfiguration(final String config) { final Prefs prefs = getDevicePrefs(); // FIXME: This should not be handled here switch (config) { case ActivityUser.PREF_USER_STEPS_GOAL: case ActivityUser.PREF_USER_CALORIES_BURNT: case ActivityUser.PREF_USER_SLEEP_DURATION: case ActivityUser.PREF_USER_GOAL_WEIGHT_KG: case ActivityUser.PREF_USER_GOAL_STANDING_TIME_HOURS: case ActivityUser.PREF_USER_GOAL_FAT_BURN_TIME_MINUTES: final TransactionBuilder builder = createTransactionBuilder("set fitness goal"); setFitnessGoal(builder); builder.queue(getQueue()); return; } // Check if any of the services handles this config for (AbstractZeppOsService service : mServiceMap.values()) { if (service.onSendConfiguration(config, prefs)) { return; } } LOG.warn("Unhandled config {}, will pass to HuamiSupport", config); super.onSendConfiguration(config); } @Override public void onTestNewFunction() { setRawSensor(!rawSensor); } @Override protected void acknowledgeFindPhone() { LOG.info("Acknowledging find phone"); final byte[] cmd = new byte[]{FIND_PHONE_ACK, SUCCESS}; writeToChunked2021("ack find phone", CHUNKED2021_ENDPOINT_FIND_DEVICE, cmd, true); } protected void stopFindPhone() { LOG.info("Stopping find phone"); writeToChunked2021("found phone", CHUNKED2021_ENDPOINT_FIND_DEVICE, FIND_PHONE_STOP_FROM_PHONE, true); } @Override public void onFindDevice(final boolean start) { if (getCoordinator().supportsContinuousFindDevice()) { sendFindDeviceCommand(start); } else { // Vibrate band periodically super.onFindDevice(start); } } @Override protected void sendFindDeviceCommand(boolean start) { final byte findBandCommand = start ? FIND_BAND_START : FIND_BAND_STOP_FROM_PHONE; LOG.info("Sending find band {}", start); try { final TransactionBuilder builder = performInitialized("find huami 2021"); writeToChunked2021(builder, CHUNKED2021_ENDPOINT_FIND_DEVICE, findBandCommand, true); builder.queue(getQueue()); } catch (IOException e) { LOG.error("error while sending find Huami 2021 device command", e); } } @Override public void onFindPhone(final boolean start) { LOG.info("Find phone: {}", start); findPhoneStarted = start; if (!start) { stopFindPhone(); } } @Override public void onScreenshotReq() { appsService.requestScreenshot(); } @Override public void onSetHeartRateMeasurementInterval(final int seconds) { try { int minuteInterval; if (seconds == -1) { // Smart minuteInterval = -1; } else { minuteInterval = seconds / 60; minuteInterval = Math.min(minuteInterval, 120); minuteInterval = Math.max(0, minuteInterval); } final TransactionBuilder builder = performInitialized(String.format("set heart rate interval to: %d minutes", minuteInterval)); setHeartrateMeasurementInterval(builder, minuteInterval); builder.queue(getQueue()); } catch (final IOException e) { GB.toast(getContext(), "Error toggling heart measurement interval: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); } } @Override protected Huami2021Support sendCalendarEvents(final TransactionBuilder builder) { // We have native calendar sync CalendarReceiver.forceSync(); return this; } @Override public void onAddCalendarEvent(final CalendarEventSpec calendarEventSpec) { calendarService.addEvent(calendarEventSpec); } @Override public void onDeleteCalendarEvent(final byte type, final long id) { calendarService.deleteEvent(type, id); } @Override public void onHeartRateTest() { // TODO onHeartRateTest - what modes? this only works sometimes try { final TransactionBuilder builder = performInitialized("HeartRateTest"); enableNotifyHeartRateMeasurements(true, builder); //writeToChunked2021(builder, CHUNKED2021_ENDPOINT_HEARTRATE, new byte[]{HEART_RATE_CMD_REALTIME_SET, HEART_RATE_REALTIME_MODE_START}, false); builder.queue(getQueue()); } catch (final IOException e) { LOG.error("Unable to read heart rate from Huami 2021 device", e); } } @Override public void onEnableRealtimeHeartRateMeasurement(final boolean enable) { final byte hrcmd; if (!enable) { hrcmd = HEART_RATE_REALTIME_MODE_STOP; } else if (heartRateRealtimeStarted == enable) { hrcmd = HEART_RATE_REALTIME_MODE_CONTINUE; } else { // enable == true, for the first time hrcmd = HEART_RATE_REALTIME_MODE_START; } heartRateRealtimeStarted = enable; try { final TransactionBuilder builder = performInitialized("Set realtime heart rate measurement = " + enable); enableNotifyHeartRateMeasurements(enable, builder); writeToChunked2021(builder, CHUNKED2021_ENDPOINT_HEARTRATE, new byte[]{HEART_RATE_CMD_REALTIME_SET, hrcmd}, false); builder.queue(getQueue()); enableRealtimeSamplesTimer(enable); } catch (final IOException e) { LOG.error("Unable to set realtime heart rate measurement", e); } } @Override protected Huami2021Support requestBatteryInfo(TransactionBuilder builder) { LOG.debug("Requesting Battery Info"); writeToChunked2021(builder, CHUNKED2021_ENDPOINT_BATTERY, BATTERY_REQUEST, false); return this; } @Override protected Huami2021Support setFitnessGoal(final TransactionBuilder builder) { final int goalSteps = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_STEPS_GOAL, ActivityUser.defaultUserStepsGoal); final int goalCalories = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_CALORIES_BURNT, ActivityUser.defaultUserCaloriesBurntGoal); final int goalSleep = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_SLEEP_DURATION, ActivityUser.defaultUserSleepDurationGoal); final int goalWeight = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_GOAL_WEIGHT_KG, ActivityUser.defaultUserGoalWeightKg); final int goalStandingTime = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_GOAL_STANDING_TIME_HOURS, ActivityUser.defaultUserGoalStandingTimeHours); final int goalFatBurnTime = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_GOAL_FAT_BURN_TIME_MINUTES, ActivityUser.defaultUserFatBurnTimeMinutes); LOG.info("Setting Fitness Goals to steps={}, calories={}, sleep={}, weight={}, standingTime={}, fatBurn={}", goalSteps, goalCalories, goalSleep, goalWeight, goalStandingTime, goalFatBurnTime); configService.newSetter() .setInt(FITNESS_GOAL_STEPS, goalSteps) .setShort(FITNESS_GOAL_CALORIES, (short) goalCalories) .setShort(FITNESS_GOAL_SLEEP, (short) (goalSleep * 60)) .setShort(FITNESS_GOAL_WEIGHT, (short) goalWeight) .setShort(FITNESS_GOAL_STANDING_TIME, (short) (goalStandingTime)) .setShort(FITNESS_GOAL_FAT_BURN_TIME, (short) goalFatBurnTime) .write(builder); return this; } @Override protected Huami2021Support setUserInfo(final TransactionBuilder builder) { LOG.info("Attempting to set user info..."); final Prefs prefs = GBApplication.getPrefs(); final Prefs devicePrefs = getDevicePrefs(); final String alias = prefs.getString(PREF_USER_NAME, null); final ActivityUser activityUser = new ActivityUser(); final int height = activityUser.getHeightCm(); final int weight = activityUser.getWeightKg(); final int birthYear = activityUser.getYearOfBirth(); final byte birthMonth = 7; // not in user attributes final byte birthDay = 1; // not in user attributes final String region = devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_DEVICE_REGION, "unknown"); if (alias == null || weight == 0 || height == 0 || birthYear == 0) { LOG.warn("Unable to set user info, make sure it is set up"); return this; } byte genderByte = 2; // other switch (activityUser.getGender()) { case ActivityUser.GENDER_MALE: genderByte = 0; break; case ActivityUser.GENDER_FEMALE: genderByte = 1; } final int userid = alias.hashCode(); final ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { baos.write(USER_INFO_CMD_SET); baos.write(new byte[]{0x4f, 0x07, 0x00, 0x00}); baos.write(fromUint16(birthYear)); baos.write(birthMonth); baos.write(birthDay); baos.write(genderByte); baos.write(fromUint16(height)); baos.write(fromUint16(weight * 200)); baos.write(BLETypeConversions.fromUint64(userid)); baos.write(region.getBytes(StandardCharsets.UTF_8)); baos.write(0); baos.write(0x09); // TODO ? baos.write(alias.getBytes(StandardCharsets.UTF_8)); baos.write((byte) 0); writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_USER_INFO, baos.toByteArray(), true); } catch (final Exception e) { LOG.error("Failed to send user info", e); } return this; } @Override protected Huami2021Support setPassword(final TransactionBuilder builder) { final boolean passwordEnabled = HuamiCoordinator.getPasswordEnabled(gbDevice.getAddress()); final String password = HuamiCoordinator.getPassword(gbDevice.getAddress()); LOG.info("Setting password: {}, {}", passwordEnabled, password); if (password == null || password.isEmpty()) { LOG.warn("Invalid password: {}", password); return this; } configService.newSetter() .setBoolean(PASSWORD_ENABLED, passwordEnabled) .setString(PASSWORD_TEXT, password) .write(builder); return this; } @Override protected void queueAlarm(final Alarm alarm, final TransactionBuilder builder) { alarmsService.sendAlarm(alarm, builder); } @Override public void onSetCallState(final CallSpec callSpec) { notificationService.setCallState(callSpec); } @Override public void onNotification(final NotificationSpec notificationSpec) { notificationService.sendNotification(notificationSpec); } @Override public void onSetReminders(final ArrayList reminders) { final TransactionBuilder builder; try { builder = performInitialized("onSetReminders"); remindersService.sendReminders(builder, reminders); builder.queue(getQueue()); } catch (final IOException e) { LOG.error("Unable to send reminders to device", e); } } @Override public void onSetLoyaltyCards(final ArrayList cards) { loyaltyCardService.setCards(cards); } @Override public void onSetContacts(ArrayList contacts) { contactsService.setContacts((List) contacts); } @Override protected boolean isWorldClocksEncrypted() { return true; } @Override public void onDeleteNotification(final int id) { notificationService.deleteNotification(id); } @Override protected void sendPhoneGps(final HuamiPhoneGpsStatus status, final Location location) { final byte[] locationBytes = encodePhoneGpsPayload(status, location); final ByteBuffer buf = ByteBuffer.allocate(2 + locationBytes.length); buf.order(ByteOrder.LITTLE_ENDIAN); buf.put(WORKOUT_CMD_GPS_LOCATION); buf.put((byte) 0x00); // ? buf.put(locationBytes); writeToChunked2021("send phone gps", CHUNKED2021_ENDPOINT_WORKOUT, buf.array(), true); } @Override public void onSetCannedMessages(final CannedMessagesSpec cannedMessagesSpec) { cannedMessagesService.setCannedMessages(cannedMessagesSpec); } @Override public void onSetPhoneVolume(final float volume) { musicService.sendVolume(volume); } @Override protected void sendMusicStateToDevice(final MusicSpec musicSpec, final MusicStateSpec musicStateSpec) { musicService.sendMusicState(musicSpec, musicStateSpec); } @Override public void onEnableRealtimeSteps(final boolean enable) { final byte[] cmd = {STEPS_CMD_ENABLE_REALTIME, bool(enable)}; writeToChunked2021("toggle realtime steps", CHUNKED2021_ENDPOINT_STEPS, cmd, false); } @Override public void onInstallApp(final Uri uri) { final ZeppOsAgpsInstallHandler agpsHandler = new ZeppOsAgpsInstallHandler(uri, getContext()); if (agpsHandler.isValid()) { try { if (getCoordinator().sendAgpsAsFileTransfer()) { LOG.info("Sending AGPS as file transfer"); new ZeppOsAgpsUpdateOperation( this, agpsHandler.getFile(), agpsService, fileTransferService, configService ).perform(); } else { LOG.info("Sending AGPS as firmware update"); // Write the agps epo update to a temporary file in cache, so we can reuse the firmware update operation final File cacheDir = getContext().getCacheDir(); final File agpsCacheDir = new File(cacheDir, "zepp-os-agps"); agpsCacheDir.mkdir(); final File uihhFile = new File(agpsCacheDir, "epo-agps.uihh"); try (FileOutputStream outputStream = new FileOutputStream(uihhFile)) { outputStream.write(agpsHandler.getFile().getUihhBytes()); } catch (final IOException e) { LOG.error("Failed to write agps bytes to temporary uihhFile", e); return; } new UpdateFirmwareOperation2021(Uri.parse(uihhFile.toURI().toString()), this).perform(); } } catch (final Exception e) { GB.toast(getContext(), "AGPS file cannot be installed: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e); } return; } final ZeppOsGpxRouteInstallHandler gpxRouteHandler = new ZeppOsGpxRouteInstallHandler(uri, getContext()); if (gpxRouteHandler.isValid()) { try { new ZeppOsGpxRouteUploadOperation( this, gpxRouteHandler.getFile(), fileTransferService ).perform(); } catch (final Exception e) { GB.toast(getContext(), "Gpx route file cannot be installed: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e); } return; } super.onInstallApp(uri); } @Override public void onAppInfoReq() { // Merge the data from apps and watchfaces // This is required because the apps service only knows the versions, not the app type, // and the watchface service only knows the app IDs, and not the versions final GBDeviceEventAppInfo appInfoCmd = new GBDeviceEventAppInfo(); final List appsFull = new ArrayList<>(); final Map watchfacesById = new HashMap<>(); final List watchfaces = watchfaceService.getWatchfaces(); for (final GBDeviceApp watchface : watchfaces) { watchfacesById.put(watchface.getUUID(), watchface); } final List apps = appsService.getApps(); for (final GBDeviceApp app : apps) { final GBDeviceApp watchface = watchfacesById.get(app.getUUID()); if (watchface != null) { appsFull.add(new GBDeviceApp( watchface.getUUID(), watchface.getName(), watchface.getCreator(), app.getVersion(), GBDeviceApp.Type.WATCHFACE )); } else { appsFull.add(new GBDeviceApp( app.getUUID(), app.getName(), app.getCreator(), app.getVersion(), GBDeviceApp.Type.APP_GENERIC )); } } appInfoCmd.apps = appsFull.toArray(new GBDeviceApp[0]); evaluateGBDeviceEvent(appInfoCmd); } @Override public void onAppStart(final UUID uuid, final boolean start) { if (start) { // This actually also starts apps... watchfaceService.setWatchface(uuid); } } @Override public void onAppDelete(final UUID uuid) { appsService.deleteApp(uuid); } @Override protected Huami2021Support setHeartrateSleepSupport(final TransactionBuilder builder) { final boolean enableHrSleepSupport = MiBandCoordinator.getHeartrateSleepSupport(gbDevice.getAddress()); configService.newSetter() .setBoolean(SLEEP_HIGH_ACCURACY_MONITORING, enableHrSleepSupport) .write(builder); return this; } @Override public byte[] getTimeBytes(final Calendar calendar, final TimeUnit precision) { final byte[] bytes = BLETypeConversions.shortCalendarToRawBytes(calendar); if (precision != TimeUnit.MINUTES && precision != TimeUnit.SECONDS) { throw new IllegalArgumentException("Unsupported precision, only MINUTES and SECONDS are supported"); } final byte seconds = precision == TimeUnit.SECONDS ? fromUint8(calendar.get(Calendar.SECOND)) : 0; final byte tz = BLETypeConversions.mapTimeZone(calendar, BLETypeConversions.TZ_FLAG_INCLUDE_DST_IN_TZ); return BLETypeConversions.join(bytes, new byte[]{seconds, tz}); } @Override public Huami2021Support setCurrentTimeWithService(TransactionBuilder builder) { // It seems that the format sent to the Current Time characteristic changed in newer devices // to kind-of match the GATT spec, but it doesn't quite respect it? // - 11 bytes get sent instead of 10 (extra byte at the end for the offset in quarter-hours?) // - Day of week starts at 0 // Otherwise, the command gets rejected with an "Out of Range" error and init fails. final Calendar timestamp = createCalendar(); final byte[] year = fromUint16(timestamp.get(Calendar.YEAR)); final byte[] cmd = { year[0], year[1], fromUint8(timestamp.get(Calendar.MONTH) + 1), fromUint8(timestamp.get(Calendar.DATE)), fromUint8(timestamp.get(Calendar.HOUR_OF_DAY)), fromUint8(timestamp.get(Calendar.MINUTE)), fromUint8(timestamp.get(Calendar.SECOND)), fromUint8(timestamp.get(Calendar.DAY_OF_WEEK) - 1), 0x00, // Fractions256? 0x08, // Reason for change? mapTimeZone(timestamp, BLETypeConversions.TZ_FLAG_INCLUDE_DST_IN_TZ), // TODO: Confirm this }; builder.write(getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_CURRENT_TIME), cmd); return this; } @Override public HuamiSupport enableNotifications(final TransactionBuilder builder, final boolean enable) { builder.notify(getCharacteristic(HuamiService.UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_READ), enable); return this; } @Override public Huami2021Support enableFurtherNotifications(final TransactionBuilder builder, final boolean enable) { // Nothing to do here, they are already enabled from enableNotifications return this; } @Override protected HuamiSupport setHeartrateMeasurementInterval(final TransactionBuilder builder, final int minutes) { configService.newSetter() .setByte(HEART_RATE_ALL_DAY_MONITORING, (byte) minutes) .write(builder); return this; } @Override protected boolean supportsDeviceDefaultVibrationProfiles() { return true; } @Override protected void setVibrationPattern(final TransactionBuilder builder, final HuamiVibrationPatternNotificationType notificationType, final boolean test, final VibrationProfile profile) { final int MAX_TOTAL_LENGTH_MS = 10_000; // 10 seconds, about as long as Mi Fit allows // The on-off sequence, until the max total length is reached final List onOff = truncateVibrationsOnOff(profile, MAX_TOTAL_LENGTH_MS); final ByteBuffer buf = ByteBuffer.allocate(5 + 2 * onOff.size()); buf.order(ByteOrder.LITTLE_ENDIAN); buf.put(VIBRATION_PATTERN_SET); buf.put(notificationType.getCode()); buf.put((byte) (profile != null ? 1 : 0)); // 1 for custom, 0 for device default buf.put((byte) (test ? 1 : 0)); buf.put((byte) (onOff.size() / 2)); for (Short time : onOff) { buf.putShort(time); } writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS, buf.array(), true); } @Override public void onSendWeather(final WeatherSpec weatherSpec) { // Weather is not sent directly to the bands, they send HTTP requests for each location. // When we have a weather update, set the default location to that location on the band. // TODO: Support for multiple weather locations final String locationKey = "1.234,-5.678,xiaomi_accu:" + System.currentTimeMillis(); // dummy final String locationName = weatherSpec.location; try { final ByteArrayOutputStream baos = new ByteArrayOutputStream(); baos.write(Huami2021Service.WEATHER_CMD_SET_DEFAULT_LOCATION); baos.write((byte) 0x02); // ? 2 for current, 4 for default baos.write((byte) 0x00); // ? baos.write((byte) 0x00); // ? baos.write((byte) 0x00); // ? baos.write(locationKey.getBytes(StandardCharsets.UTF_8)); baos.write((byte) 0x00); // ? baos.write(locationName.getBytes(StandardCharsets.UTF_8)); baos.write((byte) 0x00); // ? final TransactionBuilder builder = performInitialized("set weather location"); writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_WEATHER, baos.toByteArray(), false); builder.queue(getQueue()); } catch (final Exception e) { LOG.error("Failed to set weather location", e); } } @Override protected Huami2021Support setTimeFormat(final TransactionBuilder builder) { final GBPrefs gbPrefs = new GBPrefs(getDevicePrefs()); final String timeFormat = gbPrefs.getTimeFormat(); // FIXME: This "works", but the band does not update when the setting changes, so we don't do anything if (true) { LOG.warn("setDateTime is disabled"); return this; } LOG.info("Setting time format to {}", timeFormat); final byte timeFormatByte; if (timeFormat.equals("24h")) { timeFormatByte = 0x01; } else { timeFormatByte = 0x00; } configService.newSetter() .setByte(TIME_FORMAT, timeFormatByte) .write(builder); return this; } @Override protected Huami2021Support setDistanceUnit(final TransactionBuilder builder) { final MiBandConst.DistanceUnit unit = HuamiCoordinator.getDistanceUnit(); LOG.info("Setting distance unit to {}", unit); final byte unitByte; switch (unit) { case IMPERIAL: unitByte = 0x01; break; case METRIC: default: unitByte = 0x00; break; } configService.newSetter() .setByte(TEMPERATURE_UNIT, unitByte) .write(builder); return this; } @Override protected Huami2021Support setLanguage(final TransactionBuilder builder) { final String localeString = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()) .getString("language", "auto"); LOG.info("Setting device language to {}", localeString); configService.newSetter() .setByte(LANGUAGE, getLanguageId()) .setBoolean(LANGUAGE_FOLLOW_PHONE, localeString.equals("auto")) .write(builder); return this; } @Override protected void writeToChunked(final TransactionBuilder builder, final int type, final byte[] data) { LOG.warn("writeToChunked is not supported"); } @Override protected void writeToChunkedOld(final TransactionBuilder builder, final int type, final byte[] data) { LOG.warn("writeToChunkedOld is not supported"); } @Override public void writeToConfiguration(final TransactionBuilder builder, final byte[] data) { LOG.warn("writeToConfiguration is not supported"); } @Override protected Huami2021Support requestGPSVersion(final TransactionBuilder builder) { LOG.warn("Request GPS version not implemented"); return this; } public void requestDisplayItems(final TransactionBuilder builder) { displayItemsService.requestItems(builder, ZeppOsDisplayItemsService.DISPLAY_ITEMS_MENU); } public void requestApps(final TransactionBuilder builder) { appsService.requestApps(builder); } public void requestWatchfaces(final TransactionBuilder builder) { watchfaceService.requestWatchfaces(builder); watchfaceService.requestCurrentWatchface(builder); } protected void requestMTU(final TransactionBuilder builder) { writeToChunked2021( builder, CHUNKED2021_ENDPOINT_CONNECTION, CONNECTION_CMD_MTU_REQUEST, false ); } @Override public void phase2Initialize(final TransactionBuilder builder) { LOG.info("2021 phase2Initialize..."); requestMTU(builder); requestBatteryInfo(builder); final GBDeviceEventUpdatePreferences evt = new GBDeviceEventUpdatePreferences() .withPreference(DeviceSettingsPreferenceConst.WIFI_HOTSPOT_STATUS, null) .withPreference(DeviceSettingsPreferenceConst.FTP_SERVER_ADDRESS, null) .withPreference(DeviceSettingsPreferenceConst.FTP_SERVER_USERNAME, null) .withPreference(DeviceSettingsPreferenceConst.FTP_SERVER_STATUS, null); evaluateGBDeviceEvent(evt); } @Override public void phase3Initialize(final TransactionBuilder builder) { // Make sure that performInitialized is not called accidentally in here // (eg. by creating a new TransactionBuilder). // In those cases, the device will be initialized twice, which will change the shared // session key during these phase3 requests and decrypting messages will fail final Huami2021Coordinator coordinator = getCoordinator(); LOG.info("2021 phase3Initialize..."); setUserInfo(builder); for (final HuamiVibrationPatternNotificationType type : coordinator.getVibrationPatternNotificationTypes(gbDevice)) { // FIXME: Can we read these from the band? final String typeKey = type.name().toLowerCase(Locale.ROOT); setVibrationPattern(builder, HuamiConst.PREF_HUAMI_VIBRATION_PROFILE_PREFIX + typeKey); } cannedMessagesService.requestCannedMessages(builder); alarmsService.requestAlarms(builder); for (AbstractZeppOsService service : mServiceMap.values()) { service.initialize(builder); } if (coordinator.supportsBluetoothPhoneCalls(gbDevice)) { phoneService.requestCapabilities(builder); phoneService.requestEnabled(builder); } } @Override public UpdateFirmwareOperation createUpdateFirmwareOperation(final Uri uri) { return new UpdateFirmwareOperation2021(uri, this); } @Override public int getActivitySampleSize() { return 8; } @Override public boolean force2021Protocol() { return true; } @Override protected Huami2021Coordinator getCoordinator() { return (Huami2021Coordinator) DeviceHelper.getInstance().getCoordinator(gbDevice); } @Override protected void setRawSensor(final boolean enable) { LOG.info("Set raw sensor to {}", enable); rawSensor = enable; try { final TransactionBuilder builder = performInitialized("set raw sensor"); if (enable) { builder.write(getCharacteristic(HuamiService.UUID_CHARACTERISTIC_RAW_SENSOR_CONTROL), Huami2021Service.CMD_RAW_SENSOR_START_1); builder.write(getCharacteristic(HuamiService.UUID_CHARACTERISTIC_RAW_SENSOR_CONTROL), Huami2021Service.CMD_RAW_SENSOR_START_2); builder.write(getCharacteristic(HuamiService.UUID_CHARACTERISTIC_RAW_SENSOR_CONTROL), Huami2021Service.CMD_RAW_SENSOR_START_3); } else { builder.write(getCharacteristic(HuamiService.UUID_CHARACTERISTIC_RAW_SENSOR_CONTROL), Huami2021Service.CMD_RAW_SENSOR_STOP); } builder.notify(getCharacteristic(HuamiService.UUID_CHARACTERISTIC_RAW_SENSOR_DATA), enable); builder.queue(getQueue()); } catch (final IOException e) { LOG.error("Unable to set raw sensor", e); } } @Override protected void handleRawSensorData(final byte[] value) { // The g values seem to vary between -4100 and 4100, so we scale them final float scaleFactor = 4100f; final float gravity = -9.81f; final ByteBuffer buf = ByteBuffer.wrap(value).order(ByteOrder.LITTLE_ENDIAN); final byte type = buf.get(); final int index = buf.get() & 0xff; // always incrementing, for each type if (type == 0x00) { // g-sensor x y z values, per second if ((value.length - 2) % 6 != 0) { LOG.warn("Raw sensor value for type 0 not divisible by 6"); return; } for (int i = 2; i < value.length; i += 6) { final int x = (BLETypeConversions.toUint16(value, i) << 16) >> 16; final int y = (BLETypeConversions.toUint16(value, i + 2) << 16) >> 16; final int z = (BLETypeConversions.toUint16(value, i + 4) << 16) >> 16; final float gx = (x * gravity) / scaleFactor; final float gy = (y * gravity) / scaleFactor; final float gz = (z * gravity) / scaleFactor; LOG.info("Raw sensor g: x={} y={} z={}", gx, gy, gz); } } else if (type == 0x01) { // TODO not sure what this is? if ((value.length - 2) % 4 != 0) { LOG.warn("Raw sensor value for type 1 not divisible by 4"); return; } for (int i = 2; i < value.length; i += 4) { int val = BLETypeConversions.toUint32(value, i); LOG.info("Raw sensor 1: {}", val); } } else if (type == 0x07) { // Timestamp for the targetType, sent in intervals of ~10 seconds final int targetType = buf.get() & 0xff; final long tsMillis = buf.getLong(); LOG.debug("Raw sensor timestamp for type={} index={}: {}", targetType, index, new Date(tsMillis)); } else { LOG.warn("Unknown raw sensor type: {}", GB.hexdump(value)); } } @Override public void handle2021Payload(final short type, final byte[] payload) { if (payload == null || payload.length == 0) { LOG.warn("Empty or null payload for {}", String.format("0x%04x", type)); return; } final AbstractZeppOsService service = mServiceMap.get(type); if (service != null) { service.handlePayload(payload); return; } switch (type) { case CHUNKED2021_ENDPOINT_AUTH: LOG.warn("Unexpected auth payload {}", GB.hexdump(payload)); return; case CHUNKED2021_ENDPOINT_COMPAT: LOG.warn("Unexpected compat payload {}", GB.hexdump(payload)); return; case CHUNKED2021_ENDPOINT_WEATHER: handle2021Weather(payload); return; case CHUNKED2021_ENDPOINT_WORKOUT: handle2021Workout(payload); return; case CHUNKED2021_ENDPOINT_FIND_DEVICE: handle2021FindDevice(payload); return; case CHUNKED2021_ENDPOINT_HEARTRATE: handle2021HeartRate(payload); return; case CHUNKED2021_ENDPOINT_CONNECTION: handle2021Connection(payload); return; case CHUNKED2021_ENDPOINT_USER_INFO: handle2021UserInfo(payload); return; case CHUNKED2021_ENDPOINT_STEPS: handle2021Steps(payload); return; case CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS: handle2021VibrationPatterns(payload); return; case CHUNKED2021_ENDPOINT_BATTERY: handle2021Battery(payload); return; case CHUNKED2021_ENDPOINT_SILENT_MODE: handle2021SilentMode(payload); return; default: LOG.warn("Unhandled 2021 payload {}", String.format("0x%04x", type)); } } protected void handle2021Workout(final byte[] payload) { switch (payload[0]) { case WORKOUT_CMD_APP_OPEN: final Huami2021WorkoutTrackActivityType activityType = Huami2021WorkoutTrackActivityType.fromCode(payload[3]); final boolean workoutNeedsGps = (payload[2] == 1); final int activityKind; if (activityType == null) { LOG.warn("Unknown workout activity type {}", String.format("0x%x", payload[3])); activityKind = ActivityKind.TYPE_UNKNOWN; } else { activityKind = activityType.toActivityKind(); } LOG.info("Workout starting on band: {}, needs gps = {}", activityType, workoutNeedsGps); onWorkoutOpen(workoutNeedsGps, activityKind); return; case WORKOUT_CMD_STATUS: switch (payload[1]) { case WORKOUT_STATUS_START: LOG.info("Workout Start"); onWorkoutStart(); break; case WORKOUT_STATUS_END: LOG.info("Workout End"); onWorkoutEnd(); break; default: LOG.warn("Unexpected workout status {}", String.format("0x%02x", payload[1])); break; } return; default: LOG.warn("Unexpected workout byte {}", String.format("0x%02x", payload[0])); } } /** * A handler to schedule the find phone event. */ private final Handler findPhoneHandler = new Handler(); private boolean findPhoneStarted; protected void handle2021FindDevice(final byte[] payload) { final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); switch (payload[0]) { case FIND_BAND_ACK: LOG.info("Band acknowledged find band command"); return; case FIND_PHONE_START: LOG.info("Find Phone Start"); acknowledgeFindPhone(); // FIXME: Premature, but the band will only send the mode after we ack // Delay the find phone start, because we might get the FIND_PHONE_MODE findPhoneHandler.postDelayed(() -> { findPhoneEvent.event = GBDeviceEventFindPhone.Event.START; evaluateGBDeviceEvent(findPhoneEvent); }, 1500); break; case FIND_BAND_STOP_FROM_BAND: LOG.info("Find Band Stop from Band"); break; case FIND_PHONE_STOP_FROM_BAND: LOG.info("Find Phone Stop"); findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP; evaluateGBDeviceEvent(findPhoneEvent); break; case FIND_PHONE_MODE: findPhoneHandler.removeCallbacksAndMessages(null); final int mode = payload[1] & 0xff; // 0 to only vibrate, 1 to ring LOG.info("Find Phone Mode: {}", mode); if (findPhoneStarted) { // Already started, just change the mode findPhoneEvent.event = mode == 1 ? GBDeviceEventFindPhone.Event.RING : GBDeviceEventFindPhone.Event.VIBRATE; } else { findPhoneEvent.event = mode == 1 ? GBDeviceEventFindPhone.Event.START : GBDeviceEventFindPhone.Event.START_VIBRATE; } evaluateGBDeviceEvent(findPhoneEvent); break; default: LOG.warn("Unexpected find phone byte {}", String.format("0x%02x", payload[0])); } } protected void handle2021HeartRate(final byte[] payload) { switch (payload[0]) { case HEART_RATE_CMD_REALTIME_ACK: // what does the status mean? Seems to be 0 on success LOG.info("Band acknowledged heart rate command, status = {}", payload[1]); return; case HEART_RATE_CMD_SLEEP: switch (payload[1]) { case HEART_RATE_FALL_ASLEEP: LOG.info("Fell asleep"); processDeviceEvent(HuamiDeviceEvent.FELL_ASLEEP); break; case HEART_RATE_WAKE_UP: LOG.info("Woke up"); processDeviceEvent(HuamiDeviceEvent.WOKE_UP); break; default: LOG.warn("Unexpected sleep byte {}", String.format("0x%02x", payload[1])); break; } return; default: LOG.warn("Unexpected heart rate byte {}", String.format("0x%02x", payload[0])); } } protected void handle2021Weather(final byte[] payload) { switch (payload[0]) { case WEATHER_CMD_DEFAULT_LOCATION_ACK: LOG.info("Weather default location ACK, status = {}", payload[1]); return; default: LOG.warn("Unexpected weather byte {}", String.format("0x%02x", payload[0])); } } protected void handle2021Connection(final byte[] payload) { switch (payload[0]) { case CONNECTION_CMD_MTU_RESPONSE: final int mtu = BLETypeConversions.toUint16(payload, 1) + 3; LOG.info("Device announced MTU change: {}", mtu); setMtu(mtu); return; case CONNECTION_CMD_UNKNOWN_3: // Some ping? Band sometimes sends 0x03, phone replies with 0x04 LOG.info("Got unknown 3, replying with unknown 4"); writeToChunked2021("respond connection unknown 4", CHUNKED2021_ENDPOINT_CONNECTION, CONNECTION_CMD_UNKNOWN_4, false); return; } LOG.warn("Unexpected connection payload byte {}", String.format("0x%02x", payload[0])); } protected void handle2021UserInfo(final byte[] payload) { switch (payload[0]) { case USER_INFO_CMD_SET_ACK: LOG.info("Got user info set ack, status = {}", payload[1]); return; } LOG.warn("Unexpected user info payload byte {}", String.format("0x%02x", payload[0])); } protected void handle2021Steps(final byte[] payload) { switch (payload[0]) { case STEPS_CMD_REPLY: LOG.info("Got steps reply, status = {}", payload[1]); if (payload.length != 15) { LOG.error("Unexpected steps reply payload length {}", payload.length); return; } handleRealtimeSteps(subarray(payload, 2, 15)); return; case STEPS_CMD_ENABLE_REALTIME_ACK: LOG.info("Band acknowledged realtime steps, status = {}, enabled = {}", payload[1], payload[2]); return; case STEPS_CMD_REALTIME_NOTIFICATION: LOG.info("Got steps notification"); if (payload.length != 14) { LOG.error("Unexpected steps reply payload length {}", payload.length); return; } handleRealtimeSteps(subarray(payload, 1, 14)); return; default: LOG.warn("Unexpected steps payload byte {}", String.format("0x%02x", payload[0])); } } protected void handle2021VibrationPatterns(final byte[] payload) { switch (payload[0]) { case VIBRATION_PATTERN_ACK: LOG.info("Vibration Patterns ACK, status = {}", payload[1]); return; default: LOG.warn("Unexpected Vibration Patterns payload byte {}", String.format("0x%02x", payload[0])); } } protected void handle2021Battery(final byte[] payload) { if (payload[0] != BATTERY_REPLY) { LOG.warn("Unexpected battery payload byte {}", String.format("0x%02x", payload[0])); return; } if (payload.length != 21) { LOG.warn("Unexpected battery payload length: {}", payload.length); } final HuamiBatteryInfo batteryInfo = new HuamiBatteryInfo(subarray(payload, 1, payload.length)); handleGBDeviceEvent(batteryInfo.toDeviceEvent()); } protected void handle2021SilentMode(final byte[] payload) { switch (payload[0]) { case SILENT_MODE_CMD_NOTIFY_BAND_ACK: LOG.info("Band acknowledged current phone silent mode, status = {}", payload[1]); return; case SILENT_MODE_CMD_QUERY: LOG.info("Got silent mode query from band"); // TODO sendCurrentSilentMode(); return; case SILENT_MODE_CMD_SET: LOG.info("Band setting silent mode = {}", payload[1]); // TODO ackSilentModeSet(); // TODO setSilentMode(payload[1] == 0x01); // TODO sendCurrentSilentMode(); return; default: LOG.warn("Unexpected silent mode payload byte {}", String.format("0x%02x", payload[0])); } } @Override public void onFileUploadFinish(final boolean success) { LOG.warn("Unexpected file upload finish: {}", success); } @Override public void onFileUploadProgress(final int progress) { LOG.warn("Unexpected file upload progress: {}", progress); } @Override public void onFileDownloadFinish(final String url, final String filename, final byte[] data) { LOG.info("File received: url={} filename={} length={}", url, filename, data.length); if (filename.startsWith("screenshot-")) { GBDeviceEventScreenshot gbDeviceEventScreenshot = new GBDeviceEventScreenshot(data); evaluateGBDeviceEvent(gbDeviceEventScreenshot); return; } final String fileDownloadsDir = "zepp-os-received-files"; final File targetFile; try { final String validFilename = FileUtils.makeValidFileName(filename); final File targetFolder = new File(FileUtils.getExternalFilesDir(), fileDownloadsDir); targetFolder.mkdirs(); targetFile = new File(targetFolder, validFilename); } catch (final IOException e) { LOG.error("Failed create folder to save file", e); return; } try (FileOutputStream outputStream = new FileOutputStream(targetFile)) { final File targetFolder = new File(FileUtils.getExternalFilesDir(), fileDownloadsDir); targetFolder.mkdirs(); outputStream.write(data); } catch (final IOException e) { LOG.error("Failed to save file bytes", e); } } private byte bool(final boolean b) { return (byte) (b ? 1 : 0); } }