Zepp OS: Extract heart rate and steps to standalone services

This commit is contained in:
José Rebelo
2025-04-18 22:31:59 +01:00
parent 31d4b0d354
commit 29670aaea4
12 changed files with 467 additions and 234 deletions
@@ -36,7 +36,11 @@ import nodomain.freeyourgadget.gadgetbridge.model.SleepState;
public class GBDeviceEventSleepStateDetection extends GBDeviceEvent {
private static final Logger LOG = LoggerFactory.getLogger(GBDeviceEventSleepStateDetection.class);
public SleepState sleepState;
public final SleepState sleepState;
public GBDeviceEventSleepStateDetection(final SleepState sleepState) {
this.sleepState = sleepState;
}
@NonNull
@Override
@@ -36,7 +36,11 @@ import nodomain.freeyourgadget.gadgetbridge.model.WearingState;
public class GBDeviceEventWearState extends GBDeviceEvent {
private static final Logger LOG = LoggerFactory.getLogger(GBDeviceEventWearState.class);
public WearingState wearingState = WearingState.UNKNOWN;
public final WearingState wearingState;
public GBDeviceEventWearState(final WearingState wearingState) {
this.wearingState = wearingState;
}
@NonNull
@Override
@@ -17,35 +17,6 @@
package nodomain.freeyourgadget.gadgetbridge.devices.huami;
public class Huami2021Service {
/**
* Endpoints for 2021 chunked protocol
*/
public static final short CHUNKED2021_ENDPOINT_STEPS = 0x0016;
public static final short CHUNKED2021_ENDPOINT_HEARTRATE = 0x001d;
public static final short CHUNKED2021_ENDPOINT_AUTH = 0x0082;
public static final short CHUNKED2021_ENDPOINT_COMPAT = 0x0090;
/**
* Steps, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_STEPS}.
*/
public static final byte STEPS_CMD_GET = 0x03;
public static final byte STEPS_CMD_REPLY = 0x04;
public static final byte STEPS_CMD_ENABLE_REALTIME = 0x05;
public static final byte STEPS_CMD_ENABLE_REALTIME_ACK = 0x06;
public static final byte STEPS_CMD_REALTIME_NOTIFICATION = 0x07;
/**
* Notifications, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_HEARTRATE}.
*/
public static final byte HEART_RATE_CMD_REALTIME_SET = 0x04;
public static final byte HEART_RATE_CMD_REALTIME_ACK = 0x05;
public static final byte HEART_RATE_CMD_SLEEP = 0x06;
public static final byte HEART_RATE_FALL_ASLEEP = 0x01;
public static final byte HEART_RATE_WAKE_UP = 0x00;
public static final byte HEART_RATE_REALTIME_MODE_STOP = 0x00;
public static final byte HEART_RATE_REALTIME_MODE_START = 0x01;
public static final byte HEART_RATE_REALTIME_MODE_CONTINUE = 0x02;
/**
* Raw sensor control.
*/
@@ -148,8 +148,7 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
@Override
public boolean supportsManualHeartRateMeasurement(final GBDevice device) {
// TODO: It should be supported, but not yet properly implemented
return false;
return true;
}
@Override
@@ -153,11 +153,6 @@ public interface DeviceService extends EventHandler {
String EXTRA_CAMERA_EVENT = "event";
String EXTRA_CAMERA_FILENAME = "filename";
/**
* Use EXTRA_REALTIME_SAMPLE instead
*/
@Deprecated
String EXTRA_REALTIME_STEPS = "realtime_steps";
String EXTRA_REALTIME_SAMPLE = "realtime_sample";
String EXTRA_TIMESTAMP = "timestamp";
/**
@@ -73,7 +73,6 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSett
import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
@@ -88,7 +87,6 @@ import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.ActivateDisplayOnLift;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.ActivateDisplayOnLiftSensitivity;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.DisconnectNotificationSetting;
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.HuamiFWHelper;
@@ -272,6 +270,8 @@ import static nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacterist
public abstract class HuamiSupport extends AbstractBTLEDeviceSupport
implements Huami2021Handler, HuamiFetcher.HuamiFetchSupport {
public static final short CHUNKED2021_ENDPOINT_COMPAT = 0x0090;
// We introduce key press counter for notification purposes
private static int currentButtonActionId = 0;
private static int currentButtonPressCount = 0;
@@ -1693,7 +1693,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport
break;
case HuamiDeviceEvent.START_NONWEAR:
LOG.info("non-wear start detected");
processDeviceEvent(HuamiDeviceEvent.START_NONWEAR);
evaluateGBDeviceEvent(new GBDeviceEventWearState(WearingState.NOT_WEARING));
break;
case HuamiDeviceEvent.ALARM_TOGGLED:
case HuamiDeviceEvent.ALARM_CHANGED:
@@ -1704,11 +1704,11 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport
break;
case HuamiDeviceEvent.FELL_ASLEEP:
LOG.info("Fell asleep");
processDeviceEvent(HuamiDeviceEvent.FELL_ASLEEP);
evaluateGBDeviceEvent(new GBDeviceEventSleepStateDetection(SleepState.ASLEEP));
break;
case HuamiDeviceEvent.WOKE_UP:
LOG.info("Woke up");
processDeviceEvent(HuamiDeviceEvent.WOKE_UP);
evaluateGBDeviceEvent(new GBDeviceEventSleepStateDetection(SleepState.AWAKE));
break;
case HuamiDeviceEvent.STEPSGOAL_REACHED:
LOG.info("Steps goal reached");
@@ -1966,30 +1966,6 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport
writeToConfiguration(builder, cmd);
}
protected void processDeviceEvent(int deviceEvent){
LOG.debug("Handling device event: " + deviceEvent);
GBDeviceEvent event;
switch (deviceEvent) {
case HuamiDeviceEvent.WOKE_UP:
event = new GBDeviceEventSleepStateDetection();
((GBDeviceEventSleepStateDetection) event).sleepState = SleepState.AWAKE;
break;
case HuamiDeviceEvent.FELL_ASLEEP:
event = new GBDeviceEventSleepStateDetection();
((GBDeviceEventSleepStateDetection) event).sleepState = SleepState.ASLEEP;
break;
case HuamiDeviceEvent.START_NONWEAR:
event = new GBDeviceEventWearState();
((GBDeviceEventWearState) event).wearingState = WearingState.NOT_WEARING;
break;
default:
LOG.warn("Unhandled device event {}", deviceEvent);
return;
}
evaluateGBDeviceEvent(event);
}
private void handleLongButtonEvent(){
Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
@@ -2176,7 +2152,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport
}
}
protected void handleRealtimeSteps(byte[] value) {
private void handleRealtimeSteps(byte[] value) {
if (value == null) {
LOG.error("realtime steps: value is null");
return;
@@ -3794,7 +3770,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport
}
byte[] command = ArrayUtils.addAll(new byte[]{0x00, 0x00, (byte) (0xc0 | type), 0x00}, data);
writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_COMPAT, command, encrypt);
writeToChunked2021(builder, CHUNKED2021_ENDPOINT_COMPAT, command, encrypt);
} else {
writeToChunkedOld(builder, type, data);
}
@@ -3853,7 +3829,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport
public void writeToConfiguration(TransactionBuilder builder, byte[] data) {
if (force2021Protocol()) {
data = ArrayUtils.insert(0, data, (byte) 1);
writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_COMPAT, data, true);
writeToChunked2021(builder, CHUNKED2021_ENDPOINT_COMPAT, data, true);
} else {
builder.write(getCharacteristic(HuamiService.UUID_CHARACTERISTIC_3_CONFIGURATION), data);
}
@@ -4015,7 +3991,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport
@Override
public void handle2021Payload(short type, byte[] payload) {
if (type == Huami2021Service.CHUNKED2021_ENDPOINT_COMPAT) {
if (type == CHUNKED2021_ENDPOINT_COMPAT) {
LOG.info("got configuration data");
type = 0;
handleConfigurationInfo(ArrayUtils.remove(payload, 0));
@@ -32,7 +32,6 @@ import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
@@ -49,6 +48,8 @@ public class InitOperation2021 extends InitOperation implements Huami2021Handler
private final byte[] privateEC = new byte[24];
private byte[] publicEC;
public static final short CHUNKED2021_ENDPOINT_AUTH = 0x0082;
private final BluetoothGattCharacteristic characteristicChunked2021Write;
private final Huami2021ChunkedEncoder huami2021ChunkedEncoder;
private final Huami2021ChunkedDecoder huami2021ChunkedDecoder;
@@ -82,7 +83,7 @@ public class InitOperation2021 extends InitOperation implements Huami2021Handler
sendPubKeyCommand[2] = 0x00;
sendPubKeyCommand[3] = 0x02;
System.arraycopy(publicEC, 0, sendPubKeyCommand, 4, 48);
huami2021ChunkedEncoder.write(chunk -> builder.write(characteristicChunked2021Write, chunk), Huami2021Service.CHUNKED2021_ENDPOINT_AUTH, sendPubKeyCommand, true, false);
huami2021ChunkedEncoder.write(chunk -> builder.write(characteristicChunked2021Write, chunk), CHUNKED2021_ENDPOINT_AUTH, sendPubKeyCommand, true, false);
}
private void generateKeyPair() {
@@ -115,7 +116,7 @@ public class InitOperation2021 extends InitOperation implements Huami2021Handler
@Override
public void handle2021Payload(final short type, final byte[] payload) {
if (type != Huami2021Service.CHUNKED2021_ENDPOINT_AUTH) {
if (type != CHUNKED2021_ENDPOINT_AUTH) {
this.huamiSupport.handle2021Payload(type, payload);
return;
}
@@ -151,7 +152,7 @@ public class InitOperation2021 extends InitOperation implements Huami2021Handler
System.arraycopy(encryptedRandom1, 0, command, 1, 16);
System.arraycopy(encryptedRandom2, 0, command, 17, 16);
TransactionBuilder builder = createTransactionBuilder("Sending double encryted random to device");
huami2021ChunkedEncoder.write(chunk -> builder.write(characteristicChunked2021Write, chunk), Huami2021Service.CHUNKED2021_ENDPOINT_AUTH, command, true, false);
huami2021ChunkedEncoder.write(chunk -> builder.write(characteristicChunked2021Write, chunk), CHUNKED2021_ENDPOINT_AUTH, command, true, false);
huamiSupport.performImmediately(builder);
}
} catch (final Exception e) {
@@ -16,9 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos;
import static org.apache.commons.lang3.ArrayUtils.subarray;
import static java.lang.Thread.sleep;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.*;
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;
@@ -27,6 +25,7 @@ import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.
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.SLEEP_HIGH_ACCURACY_MONITORING;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.content.Context;
@@ -102,7 +101,6 @@ 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.HuamiDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.update.UpdateFirmwareOperation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations.ZeppOsFirmwareUpdateOperation;
@@ -120,6 +118,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service
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.ZeppOsHeartRateService;
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;
@@ -136,6 +135,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service
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.ZeppOsSilentModeService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsStepsService;
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;
@@ -148,17 +148,15 @@ import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.util.RealtimeSamplesAggregator;
public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferService.DownloadCallback {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsSupport.class);
// Tracks whether realtime HR monitoring is already started, so we can just
// send CONTINUE commands
private boolean heartRateRealtimeStarted;
private ScheduledExecutorService heartRateRealtimeScheduler;
// Keep track of whether the rawSensor is enabled
private boolean rawSensor = false;
private ScheduledExecutorService rawSensorScheduler;
private RealtimeSamplesAggregator realtimeSamplesAggregator;
// Services
private final ZeppOsServicesService servicesService = new ZeppOsServicesService(this);
@@ -197,11 +195,11 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
private final ZeppOsConnectionService connectionService = new ZeppOsConnectionService(this);
private final ZeppOsWorldClocksService worldClocksService = new ZeppOsWorldClocksService(this);
private final ZeppOsWorkoutService workoutService = new ZeppOsWorkoutService(this);
private final ZeppOsHeartRateService heartRateService = new ZeppOsHeartRateService(this);
private final ZeppOsStepsService stepsService = new ZeppOsStepsService(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
private final Set<Short> mIsEncrypted = new HashSet<>();
private final Map<Short, AbstractZeppOsService> mServiceMap = new LinkedHashMap<Short, AbstractZeppOsService>() {{
private final Map<Short, AbstractZeppOsService> mServiceMap = new LinkedHashMap<>() {{
put(servicesService.getEndpoint(), servicesService);
put(authenticationService.getEndpoint(), authenticationService);
put(batteryService.getEndpoint(), batteryService);
@@ -238,6 +236,8 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
put(weatherService.getEndpoint(), weatherService);
put(worldClocksService.getEndpoint(), worldClocksService);
put(workoutService.getEndpoint(), workoutService);
put(heartRateService.getEndpoint(), heartRateService);
put(stepsService.getEndpoint(), stepsService);
}};
public ZeppOsSupport() {
@@ -248,6 +248,15 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
super(logger);
}
@Override
public void setContext(final GBDevice gbDevice, final BluetoothAdapter btAdapter, final Context context) {
super.setContext(gbDevice, btAdapter, context);
heartRateService.setSleepAsAndroidSender(sleepAsAndroidSender);
realtimeSamplesAggregator = new RealtimeSamplesAggregator(getContext(), getDevice());
heartRateService.setRealtimeSamplesAggregator(realtimeSamplesAggregator);
stepsService.setRealtimeSamplesAggregator(realtimeSamplesAggregator);
}
@Override
protected byte getAuthFlags() {
return 0x00;
@@ -390,41 +399,12 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
@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);
}
heartRateService.onHeartRateTest();
}
@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);
}
heartRateService.onEnableRealtimeHeartRateMeasurement(enable);
}
@Override
@@ -497,9 +477,7 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
@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);
stepsService.onEnableRealtimeSteps(enable);
}
@Override
@@ -779,13 +757,13 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
break;
// Received when the app starts sleep tracking
case SleepAsAndroidAction.START_TRACKING:
enableRealtimeHeartRateMeasurement(true);
heartRateService.onEnableRealtimeHeartRateMeasurement(true);
enableRawSensor(true);
sleepAsAndroidSender.startTracking();
break;
// Received when the app stops sleep tracking
case SleepAsAndroidAction.STOP_TRACKING:
enableRealtimeHeartRateMeasurement(false);
heartRateService.onEnableRealtimeHeartRateMeasurement(false);
enableRawSensor(false);
sleepAsAndroidSender.stopTracking();
break;
@@ -864,37 +842,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
GBApplication.deviceService(gbDevice).onSetAlarms(alarms);
}
private ScheduledExecutorService startRealtimeHeartRateMeasurement() {
ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
service.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
if (heartRateRealtimeStarted) {
onEnableRealtimeHeartRateMeasurement(true);
}
}
}, 0, 1000, TimeUnit.MILLISECONDS);
return service;
}
private void stopRealtimeHeartRateMeasurement() {
if (heartRateRealtimeScheduler != null) {
heartRateRealtimeScheduler.shutdown();
heartRateRealtimeScheduler = null;
}
}
private void enableRealtimeHeartRateMeasurement(boolean enable) {
onEnableRealtimeHeartRateMeasurement(enable);
if (enable) {
heartRateRealtimeScheduler = startRealtimeHeartRateMeasurement();
}
else {
stopRealtimeHeartRateMeasurement();
}
}
private void stopRawSensors() {
if (rawSensorScheduler != null) {
rawSensorScheduler.shutdown();
@@ -953,13 +900,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
LOG.warn("writeToChunkedOld is not supported");
}
@Override
public void writeToChunked2021(final TransactionBuilder builder, final short endpoint, final byte[] data, final boolean encryptIgnored) {
// Ensure communication for all services contains the encrypted flag reported by the service, since not all
// watches have the same services encrypted (eg. #3308).
huami2021ChunkedEncoder.write(chunk -> builder.write(characteristicChunked2021Write, chunk), endpoint, data, force2021Protocol(), mIsEncrypted.contains(endpoint));
}
@Override
public void writeToConfiguration(final TransactionBuilder builder, final byte[] data) {
LOG.warn("writeToConfiguration is not supported");
@@ -1015,7 +955,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
// In here, we only request the list of supported services - they will all be initialized in
// initializeServices below
mSupportedServices.clear();
mIsEncrypted.clear();
servicesService.requestServices(builder);
}
@@ -1027,9 +966,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
public void addSupportedService(final short endpoint, final boolean encrypted) {
mSupportedServices.add(endpoint);
if (encrypted) {
mIsEncrypted.add(endpoint);
}
}
public void initializeServices() {
@@ -1157,6 +1093,9 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
} else if (HuamiService.UUID_CHARACTERISTIC_ZEPP_OS_FILE_TRANSFER_V3_RECEIVE.equals(characteristicUUID)) {
fileTransferService.onCharacteristicChanged(characteristic);
return true;
} else if (GattCharacteristic.UUID_CHARACTERISTIC_HEART_RATE_MEASUREMENT.equals(characteristicUUID)) {
heartRateService.handleHeartRate(characteristic.getValue());
return true;
}
return super.onCharacteristicChanged(gatt, characteristic);
@@ -1175,17 +1114,7 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
return;
}
// TODO: Move these services to dedicated classes, so they get the encryption correctly
switch (type) {
case CHUNKED2021_ENDPOINT_HEARTRATE:
handle2021HeartRate(payload);
return;
case CHUNKED2021_ENDPOINT_STEPS:
handle2021Steps(payload);
return;
default:
LOG.warn("Unhandled 2021 payload {}", String.format("0x%04x", type));
}
LOG.warn("Unhandled 2021 payload {}", String.format("0x%04x", type));
}
public void setEncryptionParameters(final int encryptedSequenceNr, final byte[] sharedSessionKey) {
@@ -1193,58 +1122,6 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer
huami2021ChunkedDecoder.setEncryptionParameters(sharedSessionKey);
}
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 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 realtime notification payload length {}", payload.length);
return;
}
handleRealtimeSteps(subarray(payload, 1, 14));
return;
default:
LOG.warn("Unexpected steps payload byte {}", String.format("0x%02x", payload[0]));
}
}
@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,185 @@
/* 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 android.bluetooth.BluetoothGattCharacteristic;
import android.os.Handler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventSleepStateDetection;
import nodomain.freeyourgadget.gadgetbridge.model.SleepState;
import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic;
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;
import nodomain.freeyourgadget.gadgetbridge.util.RealtimeSamplesAggregator;
public class ZeppOsHeartRateService extends AbstractZeppOsService {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsHeartRateService.class);
private static final short ENDPOINT = 0x001d;
public static final byte CMD_REALTIME_SET = 0x04;
public static final byte CMD_REALTIME_ACK = 0x05;
public static final byte CMD_SLEEP = 0x06;
public static final byte SLEEP_EVENT_FALL_ASLEEP = 0x01;
public static final byte SLEEP_EVENT_WAKE_UP = 0x00;
public static final byte REALTIME_MODE_STOP = 0x00;
public static final byte REALTIME_MODE_START = 0x01;
public static final byte REALTIME_MODE_CONTINUE = 0x02;
private final Handler realtimeHandler = new Handler();
private SleepAsAndroidSender sleepAsAndroidSender;
private RealtimeSamplesAggregator realtimeSamplesAggregator;
// Tracks whether realtime HR monitoring is already started, so we can just
// send CONTINUE commands
private boolean realtimeStarted = false;
private boolean realtimeOneShot = false;
public ZeppOsHeartRateService(final ZeppOsSupport support) {
super(support, false);
}
@Override
public short getEndpoint() {
return ENDPOINT;
}
@Override
public void handlePayload(final byte[] payload) {
switch (payload[0]) {
case 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 CMD_SLEEP:
switch (payload[1]) {
case SLEEP_EVENT_FALL_ASLEEP:
LOG.info("Fell asleep");
evaluateGBDeviceEvent(new GBDeviceEventSleepStateDetection(SleepState.ASLEEP));
break;
case SLEEP_EVENT_WAKE_UP:
LOG.info("Woke up");
evaluateGBDeviceEvent(new GBDeviceEventSleepStateDetection(SleepState.AWAKE));
break;
default:
LOG.warn("Unexpected sleep byte {}", String.format("0x%02x", payload[1]));
break;
}
return;
}
LOG.warn("Unexpected heart rate byte {}", String.format("0x%02x", payload[0]));
}
@Override
public void dispose() {
realtimeHandler.removeCallbacksAndMessages(null);
}
public void setSleepAsAndroidSender(final SleepAsAndroidSender sleepAsAndroidSender) {
this.sleepAsAndroidSender = sleepAsAndroidSender;
}
public void setRealtimeSamplesAggregator(final RealtimeSamplesAggregator realtimeSamplesAggregator) {
this.realtimeSamplesAggregator = realtimeSamplesAggregator;
}
public void onHeartRateTest() {
LOG.debug("Trigger heart rate one-shot test");
realtimeStarted = true;
realtimeOneShot = true;
final TransactionBuilder builder = new TransactionBuilder("HeartRateTest");
builder.notify(getSupport().getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_HEART_RATE_MEASUREMENT), true);
write(builder, new byte[]{CMD_REALTIME_SET, REALTIME_MODE_START});
builder.queue(getSupport().getQueue());
realtimeHandler.removeCallbacksAndMessages(null);
scheduleContinue();
}
public void onEnableRealtimeHeartRateMeasurement(final boolean enable) {
LOG.debug("Enable realtime hr: {}", enable);
if (enable == realtimeStarted) {
// same state, ignore
return;
}
final byte hrCmd = enable ? REALTIME_MODE_START : REALTIME_MODE_STOP;
realtimeStarted = enable;
realtimeOneShot = false;
final TransactionBuilder builder = new TransactionBuilder("set realtime heart rate measurement = " + enable);
final BluetoothGattCharacteristic hrCharacteristic = getSupport().getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_HEART_RATE_MEASUREMENT);
if (hrCharacteristic != null) {
builder.notify(getSupport().getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_HEART_RATE_MEASUREMENT), enable);
}
write(builder, new byte[]{CMD_REALTIME_SET, hrCmd});
builder.queue(getSupport().getQueue());
realtimeHandler.removeCallbacksAndMessages(null);
if (enable) {
scheduleContinue();
}
}
public void handleHeartRate(final byte[] value) {
if (!realtimeOneShot && !realtimeStarted) {
// Failsafe in case it gets out of sync, stop it
onEnableRealtimeHeartRateMeasurement(false);
return;
}
if (value.length == 2 && value[0] == 0) {
final int hrValue = (value[1] & 0xff);
LOG.debug("Real-time hr: {}", hrValue);
if (realtimeSamplesAggregator != null) {
realtimeSamplesAggregator.broadcastHeartRate(hrValue);
}
if (sleepAsAndroidSender != null) {
sleepAsAndroidSender.onHrChanged(hrValue, 0);
}
}
if (realtimeOneShot) {
onEnableRealtimeHeartRateMeasurement(false);
}
}
private void scheduleContinue() {
realtimeHandler.postDelayed(() -> {
sendContinue();
scheduleContinue();
}, 1000L);
}
private void sendContinue() {
final TransactionBuilder builder = new TransactionBuilder("hr continue");
write(builder, new byte[]{CMD_REALTIME_SET, REALTIME_MODE_CONTINUE});
builder.queue(getSupport().getQueue());
}
}
@@ -0,0 +1,100 @@
/* 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.Logging;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.ZeppOsSupport;
import nodomain.freeyourgadget.gadgetbridge.util.RealtimeSamplesAggregator;
public class ZeppOsStepsService extends AbstractZeppOsService {
private static final Logger LOG = LoggerFactory.getLogger(ZeppOsStepsService.class);
private static final short ENDPOINT = 0x0016;
public static final byte CMD_GET = 0x03;
public static final byte CMD_REPLY = 0x04;
public static final byte CMD_ENABLE_REALTIME = 0x05;
public static final byte CMD_ENABLE_REALTIME_ACK = 0x06;
public static final byte CMD_REALTIME_NOTIFICATION = 0x07;
private RealtimeSamplesAggregator realtimeSamplesAggregator;
public ZeppOsStepsService(final ZeppOsSupport support) {
super(support, false);
}
@Override
public short getEndpoint() {
return ENDPOINT;
}
@Override
public void handlePayload(final byte[] payload) {
switch (payload[0]) {
case 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 CMD_ENABLE_REALTIME_ACK:
LOG.info("Band acknowledged realtime steps, status = {}, enabled = {}", payload[1], payload[2]);
return;
case CMD_REALTIME_NOTIFICATION:
LOG.info("Got steps notification");
if (payload.length != 14) {
LOG.error("Unexpected realtime notification payload length {}", payload.length);
return;
}
handleRealtimeSteps(subarray(payload, 1, 14));
return;
}
LOG.warn("Unexpected steps payload byte {}", String.format("0x%02x", payload[0]));
}
public void setRealtimeSamplesAggregator(final RealtimeSamplesAggregator realtimeSamplesAggregator) {
this.realtimeSamplesAggregator = realtimeSamplesAggregator;
}
public void onEnableRealtimeSteps(final boolean enable) {
write("toggle realtime steps", new byte[]{CMD_ENABLE_REALTIME, bool(enable)});
}
protected void handleRealtimeSteps(final byte[] value) {
if (value.length != 13) {
LOG.warn("Unrecognized realtime steps value: {}", Logging.formatBytes(value));
return;
}
final int steps = BLETypeConversions.toUint16(value, 1);
LOG.debug("realtime steps: {}", steps);
if (realtimeSamplesAggregator != null) {
realtimeSamplesAggregator.broadcastSteps(steps);
}
}
}
@@ -678,8 +678,7 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi
LOG.debug("Current wearing state = {}, new wearing state = {}", currentWearingState, newState);
if (currentWearingState != WearingState.UNKNOWN && currentWearingState != newState) {
GBDeviceEventWearState event = new GBDeviceEventWearState();
event.wearingState = newState;
GBDeviceEventWearState event = new GBDeviceEventWearState(newState);
getSupport().evaluateGBDeviceEvent(event);
}
@@ -704,8 +703,7 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi
LOG.debug("Current sleep detection state = {}, new sleep detection state = {}", currentSleepDetectionState, newState);
if (currentSleepDetectionState != SleepState.UNKNOWN && currentSleepDetectionState != newState) {
GBDeviceEventSleepStateDetection event = new GBDeviceEventSleepStateDetection();
event.sleepState = newState;
GBDeviceEventSleepStateDetection event = new GBDeviceEventSleepStateDetection(newState);
getSupport().evaluateGBDeviceEvent(event);
}
@@ -782,8 +780,7 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi
// send event if the previous state is known and the new state is different from cached
if (currentSleepDetectionState != SleepState.UNKNOWN && currentSleepDetectionState != newSleepState) {
GBDeviceEventSleepStateDetection event = new GBDeviceEventSleepStateDetection();
event.sleepState = newSleepState;
GBDeviceEventSleepStateDetection event = new GBDeviceEventSleepStateDetection(newSleepState);
getSupport().evaluateGBDeviceEvent(event);
}
@@ -796,8 +793,7 @@ public class XiaomiSystemService extends AbstractXiaomiService implements Xiaomi
LOG.debug("Previous wearing state: {}, new wearing state: {}", currentWearingState, newWearingState);
if (currentWearingState != WearingState.UNKNOWN && currentWearingState != newWearingState) {
GBDeviceEventWearState event = new GBDeviceEventWearState();
event.wearingState = newWearingState;
GBDeviceEventWearState event = new GBDeviceEventWearState(newWearingState);
getSupport().evaluateGBDeviceEvent(event);
}
@@ -0,0 +1,125 @@
/* 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.util;
import android.content.Context;
import android.content.Intent;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
/**
* A simple sample aggregator that takes values from multiple sources for steps and HR, and broadcasts
* the resulting activity sample. Invalidates previously seen values after a certain timeout.
*/
public class RealtimeSamplesAggregator {
private static final Logger LOG = LoggerFactory.getLogger(RealtimeSamplesAggregator.class);
private final Context context;
private final GBDevice gbDevice;
private long lastHeartRateTime = -1;
private int heartRate = -1;
private long lastStepsTime = -1;
private int previousSteps = -1;
public RealtimeSamplesAggregator(final Context context, final GBDevice gbDevice) {
this.context = context;
this.gbDevice = gbDevice;
}
public void broadcastSteps(final int steps) {
if (System.currentTimeMillis() - lastStepsTime > 60_000L) {
previousSteps = -1;
}
lastStepsTime = System.currentTimeMillis();
if (previousSteps == -1) {
previousSteps = steps;
}
broadcast(heartRate, steps - previousSteps);
}
public void broadcastHeartRate(final int newHeartRate) {
lastHeartRateTime = System.currentTimeMillis();
heartRate = newHeartRate;
broadcast(heartRate, 0);
}
private void broadcast(final int hr, final int steps) {
final AbstractActivitySample sample;
try (final DBHandler dbHandler = GBApplication.acquireDB()) {
final DaoSession session = dbHandler.getDaoSession();
final Device device = DBHelper.getDevice(gbDevice, session);
final User user = DBHelper.getUser(session);
final SampleProvider<? extends ActivitySample> sampleProvider = gbDevice.getDeviceCoordinator().getSampleProvider(gbDevice, session);
sample = sampleProvider.createActivitySample();
if (!(sample instanceof Serializable)) {
LOG.error("Activity sample {} is not Serializable!", sample.getClass());
return;
}
sample.setProvider(sampleProvider);
sample.setDeviceId(device.getId());
sample.setUserId(user.getId());
sample.setTimestamp((int) (System.currentTimeMillis() / 1000));
if (System.currentTimeMillis() - lastHeartRateTime < 10_000L) {
sample.setHeartRate(hr);
} else {
sample.setSteps(-1);
}
if (System.currentTimeMillis() - lastStepsTime < 60_000L) {
sample.setSteps(steps);
} else {
sample.setSteps(0);
}
sample.setRawKind(sampleProvider.toRawActivityKind(ActivityKind.UNKNOWN));
sample.setRawIntensity(ActivitySample.NOT_MEASURED);
} catch (final Exception e) {
LOG.error("Error creating activity sample", e);
return;
}
final Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES)
.putExtra(GBDevice.EXTRA_DEVICE, gbDevice)
.putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, (Serializable) sample);
LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
}
}