mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge
synced 2024-12-01 06:22:55 +01:00
Xiaomi: introduce XiaomiSppSupport
This commit is contained in:
parent
ac1991104b
commit
ce179a29ae
@ -19,6 +19,6 @@ package nodomain.freeyourgadget.gadgetbridge.devices;
|
||||
public abstract class AbstractBLClassicDeviceCoordinator extends AbstractDeviceCoordinator {
|
||||
@Override
|
||||
public ConnectionType getConnectionType() {
|
||||
return ConnectionType.BL_CLASSIC;
|
||||
return ConnectionType.BT_CLASSIC;
|
||||
}
|
||||
}
|
||||
|
@ -96,7 +96,7 @@ public interface DeviceCoordinator {
|
||||
|
||||
enum ConnectionType{
|
||||
BLE(false, true),
|
||||
BL_CLASSIC(true, false),
|
||||
BT_CLASSIC(true, false),
|
||||
BOTH(true, true)
|
||||
;
|
||||
boolean usesBluetoothClassic, usesBluetoothLE;
|
||||
|
@ -57,7 +57,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.SleepRespiratoryRateSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiBleUuids;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiUuids;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiPreferences;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.impl.WorkoutSummaryParser;
|
||||
@ -71,7 +71,7 @@ public abstract class XiaomiCoordinator extends AbstractBLEDeviceCoordinator {
|
||||
@Override
|
||||
public Collection<? extends ScanFilter> createBLEScanFilters() {
|
||||
final List<ScanFilter> filters = new ArrayList<>();
|
||||
for (final UUID uuid : XiaomiBleUuids.UUIDS.keySet()) {
|
||||
for (final UUID uuid : XiaomiUuids.BLE_UUIDS.keySet()) {
|
||||
final ParcelUuid service = new ParcelUuid(uuid);
|
||||
final ScanFilter filter = new ScanFilter.Builder().setServiceUuid(service).build();
|
||||
filters.add(filter);
|
||||
|
@ -60,6 +60,12 @@ public abstract class AbstractBTBRDeviceSupport extends AbstractDeviceSupport im
|
||||
return mQueue.connect();
|
||||
}
|
||||
|
||||
public void disconnect() {
|
||||
if (mQueue != null) {
|
||||
mQueue.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclasses should populate the given builder to initialize the device (if necessary).
|
||||
*
|
||||
|
@ -16,51 +16,49 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.btbr;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
import android.bluetooth.BluetoothDevice;
|
||||
import android.bluetooth.BluetoothSocket;
|
||||
import android.content.Context;
|
||||
import android.os.ParcelUuid;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
|
||||
public final class BtBRQueue {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(BtBRQueue.class);
|
||||
|
||||
private BluetoothAdapter mBtAdapter = null;
|
||||
private BluetoothSocket mBtSocket = null;
|
||||
private GBDevice mGbDevice;
|
||||
private SocketCallback mCallback;
|
||||
private UUID mService;
|
||||
private final GBDevice mGbDevice;
|
||||
private final SocketCallback mCallback;
|
||||
private final UUID mService;
|
||||
|
||||
private final BlockingQueue<AbstractTransaction> mTransactions = new LinkedBlockingQueue<>();
|
||||
private volatile boolean mDisposed;
|
||||
private volatile boolean mCrashed;
|
||||
|
||||
private Context mContext;
|
||||
private final Context mContext;
|
||||
private CountDownLatch mConnectionLatch;
|
||||
private CountDownLatch mAvailableData;
|
||||
private int mBufferSize;
|
||||
private final int mBufferSize;
|
||||
|
||||
private Thread writeThread = new Thread("Gadgetbridge IO writeThread") {
|
||||
private Thread writeThread = new Thread("Write Thread") {
|
||||
@Override
|
||||
public void run() {
|
||||
LOG.debug("Socket Write Thread started.");
|
||||
LOG.debug("Started write thread for {} (address {})", mGbDevice.getName(), mGbDevice.getAddress());
|
||||
|
||||
while (!mDisposed && !mCrashed) {
|
||||
try {
|
||||
@ -102,10 +100,14 @@ public final class BtBRQueue {
|
||||
}
|
||||
};
|
||||
|
||||
private Thread readThread = new Thread("Gadgetbridge IO readThread") {
|
||||
private Thread readThread = new Thread("Read Thread") {
|
||||
@Override
|
||||
public void run() {
|
||||
LOG.debug("Queue Read Thread started.");
|
||||
byte[] buffer = new byte[mBufferSize];
|
||||
int nRead;
|
||||
|
||||
LOG.debug("Read thread started, entering loop");
|
||||
|
||||
while (!mDisposed && !mCrashed) {
|
||||
try {
|
||||
if (!isConnected()) {
|
||||
@ -119,24 +121,43 @@ public final class BtBRQueue {
|
||||
mConnectionLatch.await();
|
||||
mConnectionLatch = null;
|
||||
}
|
||||
|
||||
if (mAvailableData != null) {
|
||||
if (mBtSocket.getInputStream().available() == 0) {
|
||||
mAvailableData.countDown();
|
||||
}
|
||||
}
|
||||
byte[] data = new byte[mBufferSize];
|
||||
int len = mBtSocket.getInputStream().read(data);
|
||||
LOG.debug("Received data: " + StringUtils.bytesToHex(data));
|
||||
mCallback.onSocketRead(Arrays.copyOf(data, len));
|
||||
|
||||
nRead = mBtSocket.getInputStream().read(buffer);
|
||||
|
||||
// safety measure
|
||||
if (nRead == -1) {
|
||||
throw new IOException("End of stream");
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
mConnectionLatch = null;
|
||||
LOG.debug("Thread interrupted");
|
||||
} catch (Throwable ex) {
|
||||
LOG.error("IO Read Thread died: " + ex.getMessage(), ex);
|
||||
mCrashed = true;
|
||||
mConnectionLatch = null;
|
||||
continue;
|
||||
} catch (Throwable ex) {
|
||||
if (mAvailableData == null) {
|
||||
LOG.error("IO read thread died: " + ex.getMessage(), ex);
|
||||
mCrashed = true;
|
||||
}
|
||||
|
||||
mConnectionLatch = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
LOG.debug("Received {} bytes: {}", nRead, GB.hexdump(buffer, 0, nRead));
|
||||
|
||||
try {
|
||||
mCallback.onSocketRead(Arrays.copyOf(buffer, nRead));
|
||||
} catch (Throwable ex) {
|
||||
LOG.error("Failed to process received bytes in onSocketRead callback: ", ex);
|
||||
}
|
||||
}
|
||||
|
||||
LOG.debug("Exited read thread loop");
|
||||
}
|
||||
};
|
||||
|
||||
@ -147,9 +168,6 @@ public final class BtBRQueue {
|
||||
mCallback = socketCallback;
|
||||
mService = supportedService;
|
||||
mBufferSize = bufferSize;
|
||||
|
||||
writeThread.start();
|
||||
readThread.start();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -159,39 +177,51 @@ public final class BtBRQueue {
|
||||
*
|
||||
* @return <code>true</code> whether the connection attempt was successfully triggered and <code>false</code> if that failed or if there is already a connection
|
||||
*/
|
||||
|
||||
protected boolean connect() {
|
||||
@SuppressLint("MissingPermission")
|
||||
public boolean connect() {
|
||||
if (isConnected()) {
|
||||
LOG.warn("Ignoring connect() because already connected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG.info("Attemping to connect to " + mGbDevice.getName());
|
||||
LOG.info("Attempting to connect to {} ({})", mGbDevice.getName(), mGbDevice.getAddress());
|
||||
|
||||
// stop discovery before connection is made
|
||||
mBtAdapter.cancelDiscovery();
|
||||
|
||||
// revert to original state upon exception
|
||||
GBDevice.State originalState = mGbDevice.getState();
|
||||
setDeviceConnectionState(GBDevice.State.CONNECTING);
|
||||
|
||||
try {
|
||||
BluetoothDevice btDevice = mBtAdapter.getRemoteDevice(mGbDevice.getAddress());
|
||||
// UUID should be in a BluetoothSocket class and not in BluetoothSocketCharacteristic
|
||||
mBtSocket = btDevice.createRfcommSocketToServiceRecord(mService);
|
||||
|
||||
LOG.debug("RFCOMM socket created, connecting");
|
||||
|
||||
// TODO this call is blocking, which makes this method preferably called from a background thread
|
||||
mBtSocket.connect();
|
||||
if (mBtSocket.isConnected()) {
|
||||
|
||||
LOG.info("Connected to RFCOMM socket for {}", mGbDevice.getName());
|
||||
setDeviceConnectionState(GBDevice.State.CONNECTED);
|
||||
} else {
|
||||
LOG.debug("Connection not established");
|
||||
}
|
||||
if (mConnectionLatch != null) {
|
||||
mConnectionLatch.countDown();
|
||||
}
|
||||
|
||||
// update thread names to show device names in logs
|
||||
readThread.setName(String.format(Locale.ENGLISH,
|
||||
"Read Thread for %s", mGbDevice.getName()));
|
||||
writeThread.setName(String.format(Locale.ENGLISH,
|
||||
"Write Thread for %s", mGbDevice.getName()));
|
||||
|
||||
// now that connect has been created, start the threads
|
||||
readThread.start();
|
||||
writeThread.start();
|
||||
} catch (IOException e) {
|
||||
LOG.error("Server socket cannot be started.", e);
|
||||
LOG.error("Unable to connect to RFCOMM endpoint: ", e);
|
||||
setDeviceConnectionState(originalState);
|
||||
mBtSocket = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
onConnectionEstablished();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -203,19 +233,28 @@ public final class BtBRQueue {
|
||||
if (mBtSocket != null) {
|
||||
try {
|
||||
mAvailableData = new CountDownLatch(1);
|
||||
mAvailableData.await();
|
||||
|
||||
if (!mAvailableData.await(1, TimeUnit.SECONDS)) {
|
||||
LOG.warn("disconnect(): Latch timeout reached while waiting for incoming data");
|
||||
}
|
||||
|
||||
mAvailableData = null;
|
||||
mBtSocket.close();
|
||||
} catch (IOException e) {
|
||||
LOG.error(e.getMessage());
|
||||
} catch (InterruptedException e) {
|
||||
} catch (IOException | InterruptedException e) {
|
||||
LOG.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected boolean isConnected() {
|
||||
return mGbDevice.isConnected();
|
||||
/**
|
||||
* Check whether a connection to the device exists and whether a socket connection has been
|
||||
* initialized and connected
|
||||
* @return true if the Bluetooth device is connected and the socket is ready, false otherwise
|
||||
*/
|
||||
private boolean isConnected() {
|
||||
return mGbDevice.isConnected() &&
|
||||
mBtSocket != null &&
|
||||
mBtSocket.isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -230,7 +269,7 @@ public final class BtBRQueue {
|
||||
}
|
||||
}
|
||||
|
||||
protected void setDeviceConnectionState(GBDevice.State newState) {
|
||||
private void setDeviceConnectionState(GBDevice.State newState) {
|
||||
LOG.debug("New device connection state: " + newState);
|
||||
mGbDevice.setState(newState);
|
||||
mGbDevice.sendDeviceUpdateIntent(mContext, GBDevice.DeviceUpdateSubject.CONNECTION_STATE);
|
||||
@ -240,12 +279,13 @@ public final class BtBRQueue {
|
||||
if (mDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
mDisposed = true;
|
||||
disconnect();
|
||||
writeThread.interrupt();
|
||||
writeThread = null;
|
||||
readThread.interrupt();
|
||||
readThread = null;
|
||||
mTransactions.clear();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,73 @@
|
||||
/* Copyright (C) 2015-2023 Andreas Shimokawa, Carsten Pfeiffer, Daniele
|
||||
Gobbetti, Yoran Vulker
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.btbr.actions;
|
||||
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.bluetooth.BluetoothSocket;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.PlainAction;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
|
||||
public class SetProgressAction extends PlainAction {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SetProgressAction.class);
|
||||
|
||||
private final String text;
|
||||
private final boolean ongoing;
|
||||
private final int percentage;
|
||||
private final Context context;
|
||||
|
||||
/**
|
||||
* When run, will update the progress notification.
|
||||
*
|
||||
* @param text Text shown in the notification
|
||||
* @param ongoing State of action, true when the action is still being performed
|
||||
* @param percentage Current percentage indicating how far along the action has progressed
|
||||
* @param context Context in which to create the notification
|
||||
*/
|
||||
public SetProgressAction(String text, boolean ongoing, int percentage, Context context) {
|
||||
this.text = text;
|
||||
this.ongoing = ongoing;
|
||||
this.percentage = percentage;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean run(BluetoothSocket unused) {
|
||||
LOG.info(toString());
|
||||
GB.updateInstallNotification(this.text, this.ongoing, this.percentage, this.context);
|
||||
|
||||
final LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(context);
|
||||
broadcastManager.sendBroadcast(new Intent(GB.ACTION_SET_PROGRESS_BAR).putExtra(GB.PROGRESS_BAR_PROGRESS, percentage));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return getCreationTime() + ": " + getClass().getSimpleName() + ": " + text + "; " + percentage + "%";
|
||||
}
|
||||
}
|
@ -49,8 +49,6 @@ import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.services.AbstractXiaomiService;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
|
||||
@ -82,10 +80,10 @@ public class XiaomiAuthService extends AbstractXiaomiService {
|
||||
}
|
||||
|
||||
// TODO also implement for spp
|
||||
protected void startEncryptedHandshake(final XiaomiBleSupport support, final TransactionBuilder builder) {
|
||||
protected void startEncryptedHandshake(final XiaomiBleSupport support, final nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder builder) {
|
||||
encryptionInitialized = false;
|
||||
|
||||
builder.add(new SetDeviceStateAction(getSupport().getDevice(), GBDevice.State.AUTHENTICATING, getSupport().getContext()));
|
||||
builder.add(new nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction(getSupport().getDevice(), GBDevice.State.AUTHENTICATING, getSupport().getContext()));
|
||||
|
||||
System.arraycopy(getSecretKey(getSupport().getDevice()), 0, secretKey, 0, 16);
|
||||
new SecureRandom().nextBytes(nonce);
|
||||
@ -93,8 +91,19 @@ public class XiaomiAuthService extends AbstractXiaomiService {
|
||||
support.sendCommand(builder, buildNonceCommand(nonce));
|
||||
}
|
||||
|
||||
protected void startClearTextHandshake(final XiaomiBleSupport support, final TransactionBuilder builder) {
|
||||
builder.add(new SetDeviceStateAction(getSupport().getDevice(), GBDevice.State.AUTHENTICATING, getSupport().getContext()));
|
||||
protected void startEncryptedHandshake(final XiaomiSppSupport support, final nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder builder) {
|
||||
encryptionInitialized = false;
|
||||
|
||||
builder.add(new nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.SetDeviceStateAction(getSupport().getDevice(), GBDevice.State.AUTHENTICATING, getSupport().getContext()));
|
||||
|
||||
System.arraycopy(getSecretKey(getSupport().getDevice()), 0, secretKey, 0, 16);
|
||||
new SecureRandom().nextBytes(nonce);
|
||||
|
||||
support.sendCommand(builder, buildNonceCommand(nonce));
|
||||
}
|
||||
|
||||
protected void startClearTextHandshake(final XiaomiBleSupport support, final nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder builder) {
|
||||
builder.add(new nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction(getSupport().getDevice(), GBDevice.State.AUTHENTICATING, getSupport().getContext()));
|
||||
|
||||
final XiaomiProto.Auth auth = XiaomiProto.Auth.newBuilder()
|
||||
.setUserId(getUserId(getSupport().getDevice()))
|
||||
|
@ -1,3 +1,19 @@
|
||||
/* Copyright (C) 2023 José Rebelo, Yoran Vulker
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi;
|
||||
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
@ -40,19 +56,19 @@ public class XiaomiBleSupport extends XiaomiConnectionSupport {
|
||||
|
||||
@Override
|
||||
protected Set<UUID> getSupportedServices() {
|
||||
return XiaomiBleUuids.UUIDS.keySet();
|
||||
return XiaomiUuids.BLE_UUIDS.keySet();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected final TransactionBuilder initializeDevice(final TransactionBuilder builder) {
|
||||
XiaomiBleUuids.XiaomiBleUuidSet uuidSet = null;
|
||||
protected TransactionBuilder initializeDevice(final TransactionBuilder builder) {
|
||||
XiaomiUuids.XiaomiBleUuidSet uuidSet = null;
|
||||
BluetoothGattCharacteristic btCharacteristicCommandRead = null;
|
||||
BluetoothGattCharacteristic btCharacteristicCommandWrite = null;
|
||||
BluetoothGattCharacteristic btCharacteristicActivityData = null;
|
||||
BluetoothGattCharacteristic btCharacteristicDataUpload = null;
|
||||
|
||||
// Attempt to find a known xiaomi service
|
||||
for (Map.Entry<UUID, XiaomiBleUuids.XiaomiBleUuidSet> xiaomiUuid : XiaomiBleUuids.UUIDS.entrySet()) {
|
||||
for (Map.Entry<UUID, XiaomiUuids.XiaomiBleUuidSet> xiaomiUuid : XiaomiUuids.BLE_UUIDS.entrySet()) {
|
||||
if (getSupportedServices().contains(xiaomiUuid.getKey())) {
|
||||
LOG.debug("Found Xiaomi service: {}", xiaomiUuid.getKey());
|
||||
uuidSet = xiaomiUuid.getValue();
|
||||
|
@ -0,0 +1,246 @@
|
||||
/* Copyright (C) 2023 Yoran Vulker
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
|
||||
public class XiaomiSppPacket {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppPacket.class);
|
||||
|
||||
public static final byte[] PACKET_PREAMBLE = new byte[]{(byte) 0xba, (byte) 0xdc, (byte) 0xfe};
|
||||
public static final byte[] PACKET_EPILOGUE = new byte[]{(byte) 0xef};
|
||||
|
||||
public static final int CHANNEL_VERSION = 0;
|
||||
/**
|
||||
* Channel ID for PROTO messages received from device
|
||||
*/
|
||||
public static final int CHANNEL_PROTO_RX = 1;
|
||||
|
||||
/**
|
||||
* Channel ID for PROTO messages sent to device
|
||||
*/
|
||||
public static final int CHANNEL_PROTO_TX = 2;
|
||||
public static final int CHANNEL_FITNESS = 3;
|
||||
public static final int CHANNEL_VOICE = 4;
|
||||
public static final int CHANNEL_MASS = 5;
|
||||
public static final int CHANNEL_OTA = 7;
|
||||
|
||||
public static final int DATA_TYPE_PLAIN = 0;
|
||||
public static final int DATA_TYPE_ENCRYPTED = 1;
|
||||
public static final int DATA_TYPE_AUTH = 2;
|
||||
|
||||
private byte[] payload;
|
||||
private boolean flag, needsResponse;
|
||||
private int channel, opCode, frameSerial, dataType;
|
||||
|
||||
public static class Builder {
|
||||
private byte[] payload = null;
|
||||
private boolean flag = false, needsResponse = false;
|
||||
private int channel = -1, opCode = -1, frameSerial = -1, dataType = -1;
|
||||
|
||||
public XiaomiSppPacket build() {
|
||||
XiaomiSppPacket result = new XiaomiSppPacket();
|
||||
|
||||
result.channel = channel;
|
||||
result.flag = flag;
|
||||
result.needsResponse = needsResponse;
|
||||
result.opCode = opCode;
|
||||
result.frameSerial = frameSerial;
|
||||
result.dataType = dataType;
|
||||
result.payload = payload;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Builder channel(final int channel) {
|
||||
this.channel = channel;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder flag(final boolean flag) {
|
||||
this.flag = flag;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder needsResponse(final boolean needsResponse) {
|
||||
this.needsResponse = needsResponse;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder opCode(final int opCode) {
|
||||
this.opCode = opCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder frameSerial(final int frameSerial) {
|
||||
this.frameSerial = frameSerial;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder dataType(final int dataType) {
|
||||
this.dataType = dataType;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder payload(final byte[] payload) {
|
||||
this.payload = payload;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
public int getChannel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
public int getDataType() {
|
||||
return dataType;
|
||||
}
|
||||
|
||||
public byte[] getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
public boolean needsResponse() {
|
||||
return needsResponse;
|
||||
}
|
||||
|
||||
public boolean hasFlag() {
|
||||
return this.flag;
|
||||
}
|
||||
|
||||
public static XiaomiSppPacket fromXiaomiCommand(final XiaomiProto.Command command, int frameCounter, boolean needsResponse) {
|
||||
return newBuilder().channel(CHANNEL_PROTO_TX).flag(true).needsResponse(needsResponse).dataType(
|
||||
command.getType() == XiaomiAuthService.COMMAND_TYPE && command.getSubtype() >= 17 ? DATA_TYPE_AUTH : DATA_TYPE_ENCRYPTED
|
||||
).frameSerial(frameCounter).opCode(2).payload(command.toByteArray()).build();
|
||||
}
|
||||
|
||||
public static Builder newBuilder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return String.format(Locale.ROOT,
|
||||
"SppPacket{ channel=0x%x, flag=%b, needsResponse=%b, opCode=0x%x, frameSerial=0x%x, dataType=0x%x, payloadSize=%d }",
|
||||
channel, flag, needsResponse, opCode, frameSerial, dataType, payload.length);
|
||||
}
|
||||
|
||||
public static XiaomiSppPacket decode(final byte[] packet) {
|
||||
if (packet.length < 11) {
|
||||
LOG.error("Cannot decode incomplete packet");
|
||||
return null;
|
||||
}
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN);
|
||||
byte[] preamble = new byte[PACKET_PREAMBLE.length];
|
||||
buffer.get(preamble);
|
||||
|
||||
if (!Arrays.equals(PACKET_PREAMBLE, preamble)) {
|
||||
LOG.error("Expected preamble (0x{}) does not match found preamble (0x{})",
|
||||
GB.hexdump(PACKET_PREAMBLE),
|
||||
GB.hexdump(preamble));
|
||||
return null;
|
||||
}
|
||||
|
||||
byte channel = buffer.get();
|
||||
|
||||
if ((channel & 0xf0) != 0) {
|
||||
LOG.warn("Reserved bits in channel byte are non-zero: 0b{}", Integer.toBinaryString((channel & 0xf0) >> 4));
|
||||
channel = 0x0f;
|
||||
}
|
||||
|
||||
byte flags = buffer.get();
|
||||
boolean flag = (flags & 0x80) != 0;
|
||||
boolean needsResponse = (flags & 0x40) != 0;
|
||||
|
||||
if ((flags & 0x0f) != 0) {
|
||||
LOG.warn("Reserved bits in flags byte are non-zero: 0b{}", Integer.toBinaryString(flags & 0x0f));
|
||||
}
|
||||
|
||||
// payload header is included in size
|
||||
int payloadLength = (buffer.getShort() & 0xffff) - 3;
|
||||
|
||||
if (payloadLength + 11 > packet.length) {
|
||||
LOG.error("Packet incomplete (expected length: {}, actual length: {})", payloadLength + 11, packet.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
int opCode = buffer.get() & 0xff;
|
||||
int frameSerial = buffer.get() & 0xff;
|
||||
int dataType = buffer.get() & 0xff;
|
||||
byte[] payload = new byte[payloadLength];
|
||||
buffer.get(payload);
|
||||
|
||||
byte[] epilogue = new byte[PACKET_EPILOGUE.length];
|
||||
buffer.get(epilogue);
|
||||
|
||||
if (!Arrays.equals(PACKET_EPILOGUE, epilogue)) {
|
||||
LOG.error("Expected epilogue (0x{}) does not match actual epilogue (0x{})",
|
||||
GB.hexdump(PACKET_EPILOGUE),
|
||||
GB.hexdump(epilogue));
|
||||
return null;
|
||||
}
|
||||
|
||||
XiaomiSppPacket result = new XiaomiSppPacket();
|
||||
result.channel = channel;
|
||||
result.flag = flag;
|
||||
result.needsResponse = needsResponse;
|
||||
result.opCode = opCode;
|
||||
result.frameSerial = frameSerial;
|
||||
result.dataType = dataType;
|
||||
result.payload = payload;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public byte[] encode(final XiaomiAuthService authService, final AtomicInteger encryptionCounter) {
|
||||
byte[] payload = this.payload;
|
||||
|
||||
if (dataType == DATA_TYPE_ENCRYPTED && channel == CHANNEL_PROTO_TX) {
|
||||
short packetCounter = (short) encryptionCounter.incrementAndGet();
|
||||
payload = authService.encrypt(payload, packetCounter);
|
||||
payload = ByteBuffer.allocate(payload.length + 2).order(ByteOrder.LITTLE_ENDIAN).putShort(packetCounter).put(payload).array();
|
||||
} else if (dataType == DATA_TYPE_ENCRYPTED) {
|
||||
payload = authService.encrypt(payload, (short) 0);
|
||||
}
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.allocate(11 + payload.length).order(ByteOrder.LITTLE_ENDIAN);
|
||||
buffer.put(PACKET_PREAMBLE);
|
||||
|
||||
buffer.put((byte) (channel & 0xf));
|
||||
buffer.put((byte) ((flag ? 0x80 : 0) | (needsResponse ? 0x40 : 0)));
|
||||
buffer.putShort((short) (payload.length + 3));
|
||||
|
||||
buffer.put((byte) (opCode & 0xff));
|
||||
buffer.put((byte) (frameSerial & 0xff));
|
||||
buffer.put((byte) (dataType & 0xff));
|
||||
|
||||
buffer.put(payload);
|
||||
|
||||
buffer.put(PACKET_EPILOGUE);
|
||||
return buffer.array();
|
||||
}
|
||||
}
|
@ -0,0 +1,261 @@
|
||||
/* Copyright (C) 2023 José Rebelo, Yoran Vulker
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.CHANNEL_FITNESS;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.CHANNEL_PROTO_RX;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.PACKET_PREAMBLE;
|
||||
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
import android.content.Context;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btbr.AbstractBTBRDeviceSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.SetDeviceStateAction;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.SetProgressAction;
|
||||
|
||||
public class XiaomiSppSupport extends XiaomiConnectionSupport {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppSupport.class);
|
||||
|
||||
AbstractBTBRDeviceSupport commsSupport = new AbstractBTBRDeviceSupport(LOG) {
|
||||
@Override
|
||||
public boolean useAutoConnect() {
|
||||
return mXiaomiSupport.useAutoConnect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSocketRead(byte[] data) {
|
||||
XiaomiSppSupport.this.onSocketRead(data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getAutoReconnect() {
|
||||
return mXiaomiSupport.getAutoReconnect();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
|
||||
// FIXME unsetDynamicState unsets the fw version, which causes problems..
|
||||
if (getDevice().getFirmwareVersion() == null && mXiaomiSupport.getCachedFirmwareVersion() != null) {
|
||||
getDevice().setFirmwareVersion(mXiaomiSupport.getCachedFirmwareVersion());
|
||||
}
|
||||
|
||||
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
|
||||
mXiaomiSupport.getAuthService().startEncryptedHandshake(XiaomiSppSupport.this, builder);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UUID getSupportedService() {
|
||||
return XiaomiUuids.UUID_SERVICE_SERIAL_PORT_PROFILE;
|
||||
}
|
||||
};
|
||||
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
private final AtomicInteger frameCounter = new AtomicInteger(0);
|
||||
private final AtomicInteger encryptionCounter = new AtomicInteger(0);
|
||||
private final XiaomiSupport mXiaomiSupport;
|
||||
private final Map<Integer, XiaomiChannelHandler> mChannelHandlers = new HashMap<>();
|
||||
|
||||
public XiaomiSppSupport(final XiaomiSupport xiaomiSupport) {
|
||||
this.mXiaomiSupport = xiaomiSupport;
|
||||
|
||||
mChannelHandlers.put(CHANNEL_PROTO_RX, this.mXiaomiSupport::handleCommandBytes);
|
||||
mChannelHandlers.put(CHANNEL_FITNESS, this.mXiaomiSupport.getHealthService().getActivityFetcher()::addChunk);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean connect() {
|
||||
return commsSupport.connect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAuthSuccess() {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUploadProgress(final int textRsrc, final int progressPercent) {
|
||||
try {
|
||||
final TransactionBuilder builder = commsSupport.createTransactionBuilder("send data upload progress");
|
||||
builder.add(new SetProgressAction(
|
||||
commsSupport.getContext().getString(textRsrc),
|
||||
true,
|
||||
progressPercent,
|
||||
commsSupport.getContext()
|
||||
));
|
||||
builder.queue(commsSupport.getQueue());
|
||||
} catch (final Exception e) {
|
||||
LOG.error("Failed to update progress notification", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContext(GBDevice device, BluetoothAdapter adapter, Context context) {
|
||||
this.commsSupport.setContext(device, adapter, context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {
|
||||
this.commsSupport.disconnect();
|
||||
}
|
||||
|
||||
private int findNextPossiblePreamble(final byte[] haystack) {
|
||||
for (int i = 1; i + 2 < haystack.length; i++) {
|
||||
// check if first byte matches
|
||||
if (haystack[i] == PACKET_PREAMBLE[0]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// did not find preamble
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void processBuffer() {
|
||||
// wait until at least an empty packet is in the buffer
|
||||
while (buffer.size() >= 11) {
|
||||
// start preamble compare
|
||||
byte[] bufferState = buffer.toByteArray();
|
||||
ByteBuffer headerBuffer = ByteBuffer.wrap(bufferState, 0, 7).order(ByteOrder.LITTLE_ENDIAN);
|
||||
byte[] preamble = new byte[PACKET_PREAMBLE.length];
|
||||
headerBuffer.get(preamble);
|
||||
|
||||
if (!Arrays.equals(PACKET_PREAMBLE, preamble)) {
|
||||
int preambleOffset = findNextPossiblePreamble(bufferState);
|
||||
|
||||
if (preambleOffset == -1) {
|
||||
LOG.debug("Buffer did not contain a valid (start of) preamble, resetting");
|
||||
buffer.reset();
|
||||
} else {
|
||||
LOG.debug("Found possible preamble at offset {}, dumping preceeding bytes", preambleOffset);
|
||||
byte[] remaining = new byte[bufferState.length - preambleOffset];
|
||||
System.arraycopy(bufferState, preambleOffset, remaining, 0, remaining.length);
|
||||
buffer.reset();
|
||||
try {
|
||||
buffer.write(remaining);
|
||||
} catch (IOException ex) {
|
||||
LOG.error("Failed to write bytes from found preamble offset back to buffer: ", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// continue processing at beginning of new buffer
|
||||
continue;
|
||||
}
|
||||
|
||||
headerBuffer.getShort(); // skip flags and channel ID
|
||||
int payloadSize = headerBuffer.getShort() & 0xffff;
|
||||
int packetSize = payloadSize + 8; // payload size includes payload header
|
||||
|
||||
if (bufferState.length < packetSize) {
|
||||
LOG.debug("Packet buffer not yet satisfied: buffer size {} < expected packet size {}", bufferState.length, packetSize);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.debug("Full packet in buffer (buffer size: {}, packet size: {})", bufferState.length, packetSize);
|
||||
XiaomiSppPacket receivedPacket = XiaomiSppPacket.decode(bufferState); // remaining bytes unaffected
|
||||
|
||||
onPacketReceived(receivedPacket);
|
||||
|
||||
// extract remaining bytes from buffer
|
||||
byte[] remaining = new byte[bufferState.length - packetSize];
|
||||
System.arraycopy(bufferState, packetSize, remaining, 0, remaining.length);
|
||||
|
||||
buffer.reset();
|
||||
|
||||
try {
|
||||
buffer.write(remaining);
|
||||
} catch (IOException ex) {
|
||||
LOG.error("Failed to write remaining packet bytes back to buffer: ", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
commsSupport.dispose();
|
||||
}
|
||||
|
||||
public void onSocketRead(byte[] data) {
|
||||
try {
|
||||
buffer.write(data);
|
||||
} catch (IOException ex) {
|
||||
LOG.error("Exception while writing buffer: ", ex);
|
||||
}
|
||||
|
||||
processBuffer();
|
||||
}
|
||||
|
||||
private void onPacketReceived(final XiaomiSppPacket packet) {
|
||||
if (packet == null) {
|
||||
// likely failed to parse the packet
|
||||
LOG.warn("Received null packet, did we fail to decode?");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.debug("Packet received: {}", packet);
|
||||
// TODO send response if needsResponse is set
|
||||
byte[] payload = packet.getPayload();
|
||||
|
||||
if (packet.getDataType() == 1) {
|
||||
payload = mXiaomiSupport.getAuthService().decrypt(payload);
|
||||
}
|
||||
|
||||
int channel = packet.getChannel();
|
||||
if (mChannelHandlers.containsKey(channel)) {
|
||||
XiaomiChannelHandler handler = mChannelHandlers.get(channel);
|
||||
|
||||
if (handler != null)
|
||||
handler.handle(payload);
|
||||
}
|
||||
|
||||
LOG.warn("Unhandled SppPacket on channel {}", packet.getChannel());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendCommand(String taskName, XiaomiProto.Command command) {
|
||||
XiaomiSppPacket packet = XiaomiSppPacket.fromXiaomiCommand(command, frameCounter.getAndIncrement(), false);
|
||||
LOG.debug("sending packet: {}", packet);
|
||||
TransactionBuilder builder = this.commsSupport.createTransactionBuilder("send " + taskName);
|
||||
builder.write(packet.encode(mXiaomiSupport.getAuthService(), encryptionCounter));
|
||||
builder.queue(this.commsSupport.getQueue());
|
||||
}
|
||||
|
||||
public void sendCommand(final TransactionBuilder builder, final XiaomiProto.Command command) {
|
||||
XiaomiSppPacket packet = XiaomiSppPacket.fromXiaomiCommand(command, frameCounter.getAndIncrement(), false);
|
||||
LOG.debug("sending packet: {}", packet);
|
||||
|
||||
builder.write(packet.encode(mXiaomiSupport.getAuthService(), encryptionCounter));
|
||||
// do not queue here, that's the job of the caller
|
||||
}
|
||||
}
|
@ -122,6 +122,8 @@ public class XiaomiSupport extends AbstractDeviceSupport {
|
||||
case BLE:
|
||||
case BOTH:
|
||||
return new XiaomiBleSupport(this);
|
||||
case BT_CLASSIC:
|
||||
return new XiaomiSppSupport(this);
|
||||
}
|
||||
|
||||
LOG.error("Cannot create connection-specific support, unhanded {} connection type", connType);
|
||||
|
@ -16,16 +16,19 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport.BASE_UUID;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public class XiaomiBleUuids {
|
||||
public static final Map<UUID, XiaomiBleUuidSet> UUIDS = new LinkedHashMap<UUID, XiaomiBleUuidSet>() {{
|
||||
public class XiaomiUuids {
|
||||
public static final UUID UUID_SERVICE_SERIAL_PORT_PROFILE = UUID.fromString(String.format(BASE_UUID, "1101"));
|
||||
public static final Map<UUID, XiaomiBleUuidSet> BLE_UUIDS = new LinkedHashMap<UUID, XiaomiBleUuidSet>() {{
|
||||
// all encrypted devices seem to share the same characteristics
|
||||
// Mi Band 8
|
||||
// Redmi Watch 3 Active
|
||||
// Xiaomi Watch S1 Active
|
||||
// Xiaomi Watch S1 (Active)
|
||||
// Redmi Smart Band 2
|
||||
// Redmi Watch 2 Lite
|
||||
put(UUID.fromString("0000fe95-0000-1000-8000-00805f9b34fb"), new XiaomiBleUuidSet(
|
Loading…
Reference in New Issue
Block a user