1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-12-01 06:22:55 +01:00

Xiaomi: introduce XiaomiSppSupport

This commit is contained in:
MrYoranimo 2024-01-10 23:16:38 +01:00 committed by José Rebelo
parent ac1991104b
commit ce179a29ae
12 changed files with 722 additions and 66 deletions

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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).
*

View File

@ -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();
}
}

View File

@ -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 + "%";
}
}

View File

@ -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()))

View File

@ -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();

View File

@ -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();
}
}

View File

@ -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
}
}

View File

@ -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);

View File

@ -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(