Zepp OS: Extract services: battery, connection, vibration patterns, weather

This commit is contained in:
José Rebelo
2025-04-05 23:31:33 +01:00
parent 2996474177
commit d4d15491dd
8 changed files with 445 additions and 232 deletions
@@ -20,13 +20,9 @@ public class Huami2021Service {
/**
* Endpoints for 2021 chunked protocol
*/
public static final short CHUNKED2021_ENDPOINT_WEATHER = 0x000e;
public static final short CHUNKED2021_ENDPOINT_CONNECTION = 0x0015;
public static final short CHUNKED2021_ENDPOINT_STEPS = 0x0016;
public static final short CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS = 0x0018;
public static final short CHUNKED2021_ENDPOINT_WORKOUT = 0x0019;
public static final short CHUNKED2021_ENDPOINT_HEARTRATE = 0x001d;
public static final short CHUNKED2021_ENDPOINT_BATTERY = 0x0029;
public static final short CHUNKED2021_ENDPOINT_AUTH = 0x0082;
public static final short CHUNKED2021_ENDPOINT_COMPAT = 0x0090;
@@ -39,26 +35,6 @@ public class Huami2021Service {
public static final byte STEPS_CMD_ENABLE_REALTIME_ACK = 0x06;
public static final byte STEPS_CMD_REALTIME_NOTIFICATION = 0x07;
/**
* Vibration Patterns, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS}.
*/
public static final byte VIBRATION_PATTERN_SET = 0x03;
public static final byte VIBRATION_PATTERN_ACK = 0x04;
/**
* Battery, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_BATTERY}.
*/
public static final byte BATTERY_REQUEST = 0x03;
public static final byte BATTERY_REPLY = 0x04;
/**
* Connection, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_CONNECTION}.
*/
public static final byte CONNECTION_CMD_MTU_REQUEST = 0x01;
public static final byte CONNECTION_CMD_MTU_RESPONSE = 0x02;
public static final byte CONNECTION_CMD_UNKNOWN_3 = 0x03;
public static final byte CONNECTION_CMD_UNKNOWN_4 = 0x04;
/**
* Notifications, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_HEARTRATE}.
*/
@@ -82,12 +58,6 @@ public class Huami2021Service {
public static final byte WORKOUT_STATUS_START = 0x01;
public static final byte WORKOUT_STATUS_END = 0x04;
/**
* Weather, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_WEATHER}.
*/
public static final byte WEATHER_CMD_SET_DEFAULT_LOCATION = 0x09;
public static final byte WEATHER_CMD_DEFAULT_LOCATION_ACK = 0x0a;
/**
* Raw sensor control.
*/
@@ -55,7 +55,6 @@ import java.time.zone.ZoneRules;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashSet;
@@ -590,7 +589,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements
private static final byte[] startHeartMeasurementContinuous = new byte[]{0x15, MiBandService.COMMAND_SET__HR_CONTINUOUS, 1};
private static final byte[] stopHeartMeasurementContinuous = new byte[]{0x15, MiBandService.COMMAND_SET__HR_CONTINUOUS, 0};
protected HuamiSupport requestBatteryInfo(TransactionBuilder builder) {
private HuamiSupport requestBatteryInfo(TransactionBuilder builder) {
LOG.debug("Requesting Battery Info!");
BluetoothGattCharacteristic characteristic = getCharacteristic(HuamiService.UUID_CHARACTERISTIC_6_BATTERY_INFO);
builder.read(characteristic);
@@ -1645,7 +1644,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements
}
protected int getFindDeviceInterval() {
return HuamiUtils.getFindDeviceInterval(getDevice(), supportsDeviceDefaultVibrationProfiles());
return HuamiUtils.getFindDeviceInterval(getDevice(), false);
}
protected void sendFindDeviceCommand(boolean start) {
@@ -3010,7 +3009,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements
}
}
protected HuamiSupport setVibrationPattern(final TransactionBuilder builder, final String preferenceKey) {
private HuamiSupport setVibrationPattern(final TransactionBuilder builder, final String preferenceKey) {
// The preference key has one of the 3 prefixes
final String notificationTypeName = preferenceKey.replace(PREF_HUAMI_VIBRATION_COUNT_PREFIX, "")
.replace(PREF_HUAMI_VIBRATION_PROFILE_PREFIX, "")
@@ -3022,7 +3021,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements
final VibrationProfile vibrationProfile = HuamiCoordinator.getVibrationProfile(
getDevice().getAddress(),
notificationType,
supportsDeviceDefaultVibrationProfiles()
false
);
setVibrationPattern(builder, notificationType, isTry, vibrationProfile);
@@ -3030,13 +3029,6 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements
return this;
}
/**
* Whether the device supports built-in default vibration profiles.
*/
protected boolean supportsDeviceDefaultVibrationProfiles() {
return false;
}
/**
* Test or set a {@link VibrationProfile}.
*
@@ -3045,7 +3037,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements
* @param test test the pattern (only vibrate the band, do not set it)
* @param profile the {@link VibrationProfile}
*/
protected void setVibrationPattern(final TransactionBuilder builder,
private void setVibrationPattern(final TransactionBuilder builder,
final HuamiVibrationPatternNotificationType notificationType,
final boolean test,
final VibrationProfile profile) {
@@ -3057,7 +3049,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements
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<Short> onOff = truncateVibrationsOnOff(profile, MAX_TOTAL_LENGTH_MS);
final List<Short> onOff = HuamiUtils.truncateVibrationsOnOff(profile, MAX_TOTAL_LENGTH_MS);
final ByteBuffer buf = ByteBuffer.allocate(3 + 2 * onOff.size());
buf.order(ByteOrder.LITTLE_ENDIAN);
@@ -3078,35 +3070,6 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements
writeToChunked(builder, 2, buf.array());
}
protected List<Short> truncateVibrationsOnOff(final VibrationProfile profile, final int limitMillis) {
if (profile == null) {
return Collections.emptyList();
}
int totalLengthMs = 0;
// The on-off sequence, until the max total length is reached
final List<Short> onOff = new ArrayList<>(profile.getOnOffSequence().length);
for (int c = 0; c < profile.getRepeat(); c++) {
for (int i = 0; i < profile.getOnOffSequence().length; i += 2) {
final short on = (short) profile.getOnOffSequence()[i];
final short off = (short) profile.getOnOffSequence()[i + 1];
if (totalLengthMs + on + off > limitMillis) {
LOG.warn("VibrationProfile {} too long, truncating to {} ms", profile.getId(), limitMillis);
break;
}
onOff.add(on);
onOff.add(off);
totalLengthMs += on + off;
}
}
return onOff;
}
@Override
public void onSendWeather(ArrayList<WeatherSpec> weatherSpecs) {
final DeviceCoordinator coordinator = gbDevice.getDeviceCoordinator();
@@ -1,10 +1,35 @@
/* Copyright (C) 2025 José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.miband.VibrationProfile;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class HuamiUtils {
private static final Logger LOG = LoggerFactory.getLogger(HuamiUtils.class);
private HuamiUtils() {
// Utility class
}
@@ -35,4 +60,34 @@ public class HuamiUtils {
return findDeviceInterval;
}
public static List<Short> truncateVibrationsOnOff(final VibrationProfile profile,
final int limitMillis) {
if (profile == null) {
return Collections.emptyList();
}
int totalLengthMs = 0;
// The on-off sequence, until the max total length is reached
final List<Short> onOff = new ArrayList<>(profile.getOnOffSequence().length);
for (int c = 0; c < profile.getRepeat(); c++) {
for (int i = 0; i < profile.getOnOffSequence().length; i += 2) {
final short on = (short) profile.getOnOffSequence()[i];
final short off = (short) profile.getOnOffSequence()[i + 1];
if (totalLengthMs + on + off > limitMillis) {
LOG.warn("VibrationProfile {} too long, truncating to {} ms", profile.getId(), limitMillis);
break;
}
onOff.add(on);
onOff.add(off);
totalLengthMs += on + off;
}
}
return onOff;
}
}
@@ -50,13 +50,11 @@ import androidx.annotation.Nullable;
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.sql.Timestamp;
import java.util.ArrayList;
import java.util.Calendar;
@@ -91,14 +89,12 @@ import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator;
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.externalevents.sleepasandroid.SleepAsAndroidAction;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp;
@@ -120,11 +116,9 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021ChunkedDecoder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021ChunkedEncoder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiPhoneGpsStatus;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibrationPatternNotificationType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.update.UpdateFirmwareOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations.ZeppOsFirmwareUpdateOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations.ZeppOsAgpsUpdateOperation;
@@ -135,8 +129,10 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAssistantService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAppsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAuthenticationService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsBatteryService;
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.ZeppOsConnectionService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsDisplayItemsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsFindDeviceService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsHttpService;
@@ -156,8 +152,10 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsPhoneService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsSilentModeService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsUserInfoService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsVibrationPatternsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsVoiceMemosService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWatchfaceService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWeatherService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWifiService;
import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
@@ -206,6 +204,10 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
private final ZeppOsFindDeviceService findDeviceService = new ZeppOsFindDeviceService(this);
private final ZeppOsSilentModeService silentModeService = new ZeppOsSilentModeService(this);
private final ZeppOsUserInfoService userInfoService = new ZeppOsUserInfoService(this);
private final ZeppOsVibrationPatternsService vibrationPatternsService = new ZeppOsVibrationPatternsService(this);
private final ZeppOsBatteryService batteryService = new ZeppOsBatteryService(this);
private final ZeppOsWeatherService weatherService = new ZeppOsWeatherService(this);
private final ZeppOsConnectionService connectionService = new ZeppOsConnectionService(this);
private final Set<Short> mSupportedServices = new HashSet<>();
// FIXME: We need to keep track of which services are encrypted for now, since not all of them were yet migrated to a service
@@ -213,6 +215,8 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
private final Map<Short, AbstractZeppOsService> mServiceMap = new LinkedHashMap<Short, AbstractZeppOsService>() {{
put(servicesService.getEndpoint(), servicesService);
put(authenticationService.getEndpoint(), authenticationService);
put(batteryService.getEndpoint(), batteryService);
put(connectionService.getEndpoint(), connectionService);
put(fileTransferService.getEndpoint(), fileTransferService);
put(configService.getEndpoint(), configService);
put(agpsService.getEndpoint(), agpsService);
@@ -241,6 +245,8 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
put(findDeviceService.getEndpoint(), findDeviceService);
put(silentModeService.getEndpoint(), silentModeService);
put(userInfoService.getEndpoint(), userInfoService);
put(vibrationPatternsService.getEndpoint(), vibrationPatternsService);
put(weatherService.getEndpoint(), weatherService);
}};
public ZeppOsSupport() {
@@ -455,15 +461,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
}
}
@Override
protected ZeppOsSupport requestBatteryInfo(TransactionBuilder builder) {
LOG.debug("Requesting Battery Info");
writeToChunked2021(builder, CHUNKED2021_ENDPOINT_BATTERY, BATTERY_REQUEST, false);
return this;
}
@Override
protected ZeppOsSupport setFitnessGoal(final TransactionBuilder builder) {
final int goalSteps = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_STEPS_GOAL, ActivityUser.defaultUserStepsGoal);
@@ -620,6 +617,14 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
throw new UnsupportedOperationException("this method should not be used");
}
/**
* FIXME: Visibility hack for easier refactoring.
*/
@Override
public void setMtu(final int mtu) {
super.setMtu(mtu);
}
@Override
public void onInstallApp(final Uri uri) {
final ZeppOsAgpsInstallHandler agpsHandler = new ZeppOsAgpsInstallHandler(uri, getContext());
@@ -853,66 +858,9 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
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<Short> 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 ArrayList<WeatherSpec> weatherSpecs) {
final WeatherSpec weatherSpec = weatherSpecs.get(0);
// 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);
}
weatherService.onSendWeather(weatherSpecs);
}
@Override
@@ -1187,21 +1135,10 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
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...");
setCurrentTimeWithService(builder);
requestMTU(builder);
requestBatteryInfo(builder);
final GBDeviceEventUpdatePreferences evt = new GBDeviceEventUpdatePreferences()
.withPreference(DeviceSettingsPreferenceConst.WIFI_HOTSPOT_STATUS, null)
@@ -1251,16 +1188,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
final ZeppOsCoordinator coordinator = getCoordinator();
// TODO move this to a service
setUserInfo(builder);
// TODO move this to a service
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);
}
for (AbstractZeppOsService service : mServiceMap.values()) {
if (mSupportedServices.contains(service.getEndpoint())) {
// Only initialize supported services
@@ -1401,27 +1328,15 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
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_HEARTRATE:
handle2021HeartRate(payload);
return;
case CHUNKED2021_ENDPOINT_CONNECTION:
handle2021Connection(payload);
return;
case CHUNKED2021_ENDPOINT_STEPS:
handle2021Steps(payload);
return;
case CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS:
handle2021VibrationPatterns(payload);
return;
case CHUNKED2021_ENDPOINT_BATTERY:
handle2021Battery(payload);
return;
default:
LOG.warn("Unhandled 2021 payload {}", String.format("0x%04x", type));
}
@@ -1496,34 +1411,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
}
}
protected void handle2021Weather(final byte[] payload) {
//noinspection SwitchStatementWithTooFewBranches
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 handle2021Steps(final byte[] payload) {
switch (payload[0]) {
case STEPS_CMD_REPLY:
@@ -1550,31 +1437,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
}
}
protected void handle2021VibrationPatterns(final byte[] payload) {
//noinspection SwitchStatementWithTooFewBranches
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());
}
@Override
public void onFileDownloadFinish(final String url, final String filename, final byte[] data) {
LOG.info("File received: url={} filename={} length={}", url, filename, data.length);
@@ -0,0 +1,71 @@
/* Copyright (C) 2025 José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services;
import static org.apache.commons.lang3.ArrayUtils.subarray;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
public class ZeppOsBatteryService extends AbstractZeppOsService {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsBatteryService.class);
private static final short ENDPOINT = 0x0029;
public static final byte CMD_BATTERY_REQUEST = 0x03;
public static final byte CMD_BATTERY_REPLY = 0x04;
public ZeppOsBatteryService(final ZeppOsSupport support) {
super(support, true);
}
@Override
public short getEndpoint() {
return ENDPOINT;
}
@Override
public void handlePayload(final byte[] payload) {
if (payload[0] != CMD_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));
getSupport().evaluateGBDeviceEvent(batteryInfo.toDeviceEvent());
}
@Override
public void initialize(final TransactionBuilder builder) {
requestBatteryInfo(builder);
}
public void requestBatteryInfo(final TransactionBuilder builder) {
LOG.debug("Requesting Battery Info");
write(builder, CMD_BATTERY_REQUEST);
}
}
@@ -0,0 +1,67 @@
/* Copyright (C) 2025 José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
public class ZeppOsConnectionService extends AbstractZeppOsService {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsConnectionService.class);
private static final short ENDPOINT = 0x0015;
private static final byte CMD_MTU_REQUEST = 0x01;
private static final byte CMD_MTU_RESPONSE = 0x02;
private static final byte CMD_UNKNOWN_3 = 0x03;
private static final byte CMD_UNKNOWN_4 = 0x04;
public ZeppOsConnectionService(final ZeppOsSupport support) {
super(support, true);
}
@Override
public short getEndpoint() {
return ENDPOINT;
}
@Override
public void handlePayload(final byte[] payload) {
switch (payload[0]) {
case CMD_MTU_RESPONSE:
final int mtu = BLETypeConversions.toUint16(payload, 1) + 3;
LOG.info("Device announced MTU change: {}", mtu);
getSupport().setMtu(mtu);
return;
case CMD_UNKNOWN_3:
// Some ping? Band sometimes sends 0x03, phone replies with 0x04
LOG.info("Got unknown 3, replying with unknown 4");
write("respond connection unknown 4", CMD_UNKNOWN_4);
return;
}
LOG.warn("Unexpected connection payload byte {}", String.format("0x%02x", payload[0]));
}
public void requestMTU(final TransactionBuilder builder) {
write(builder, CMD_MTU_REQUEST);
}
}
@@ -0,0 +1,136 @@
/* Copyright (C) 2025 José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_HUAMI_VIBRATION_COUNT_PREFIX;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_HUAMI_VIBRATION_PROFILE_PREFIX;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_HUAMI_VIBRATION_TRY_PREFIX;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.miband.VibrationProfile;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiUtils;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibrationPatternNotificationType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class ZeppOsVibrationPatternsService extends AbstractZeppOsService {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsVibrationPatternsService.class);
private static final short ENDPOINT = 0x0018;
private static final byte VIBRATION_PATTERN_SET = 0x03;
private static final byte VIBRATION_PATTERN_ACK = 0x04;
public ZeppOsVibrationPatternsService(final ZeppOsSupport support) {
super(support, true);
}
@Override
public short getEndpoint() {
return ENDPOINT;
}
@Override
public void handlePayload(final byte[] payload) {
//noinspection SwitchStatementWithTooFewBranches
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]));
}
}
@Override
public void initialize(final TransactionBuilder builder) {
for (final HuamiVibrationPatternNotificationType type : getCoordinator().getVibrationPatternNotificationTypes(getSupport().getDevice())) {
// 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);
}
}
@Override
public boolean onSendConfiguration(final String config, final Prefs prefs) {
if (config.startsWith(HuamiConst.PREF_HUAMI_VIBRATION_PROFILE_PREFIX) ||
config.startsWith(HuamiConst.PREF_HUAMI_VIBRATION_COUNT_PREFIX) ||
config.startsWith(HuamiConst.PREF_HUAMI_VIBRATION_TRY_PREFIX)) {
final TransactionBuilder builder = new TransactionBuilder("send " + config);
setVibrationPattern(builder, config);
builder.queue(getSupport().getQueue());
return true;
}
return false;
}
protected void setVibrationPattern(final TransactionBuilder builder, final String preferenceKey) {
// The preference key has one of the 3 prefixes
final String notificationTypeName = preferenceKey.replace(PREF_HUAMI_VIBRATION_COUNT_PREFIX, "")
.replace(PREF_HUAMI_VIBRATION_PROFILE_PREFIX, "")
.replace(PREF_HUAMI_VIBRATION_TRY_PREFIX, "")
.toUpperCase(Locale.ROOT);
final HuamiVibrationPatternNotificationType notificationType = HuamiVibrationPatternNotificationType.valueOf(notificationTypeName);
final boolean isTry = preferenceKey.startsWith(PREF_HUAMI_VIBRATION_TRY_PREFIX);
final VibrationProfile vibrationProfile = HuamiCoordinator.getVibrationProfile(
getSupport().getDevice().getAddress(),
notificationType,
true
);
setVibrationPattern(builder, notificationType, isTry, vibrationProfile);
}
private 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<Short> onOff = HuamiUtils.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);
}
write(builder, buf.array());
}
}
@@ -0,0 +1,89 @@
/* Copyright (C) 2025 José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
public class ZeppOsWeatherService extends AbstractZeppOsService {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsWeatherService.class);
private static final short ENDPOINT = 0x000e;
private static final byte CMD_SET_DEFAULT_LOCATION = 0x09;
private static final byte CMD_DEFAULT_LOCATION_ACK = 0x0a;
public ZeppOsWeatherService(final ZeppOsSupport support) {
super(support, true);
}
@Override
public short getEndpoint() {
return ENDPOINT;
}
@Override
public void handlePayload(final byte[] payload) {
//noinspection SwitchStatementWithTooFewBranches
switch (payload[0]) {
case 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]));
}
}
public void onSendWeather(final ArrayList<WeatherSpec> weatherSpecs) {
final WeatherSpec weatherSpec = weatherSpecs.get(0);
// 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(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 = new TransactionBuilder("set weather location");
write(builder, baos.toByteArray());
builder.queue(getSupport().getQueue());
} catch (final Exception e) {
LOG.error("Failed to set weather location", e);
}
}
}