1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-12-28 19:45:50 +01:00

Xiaomi: add support for SPPv2 packet protocol

This commit is contained in:
MrYoranimo 2024-09-03 13:50:21 +02:00
parent 9f566fb7d9
commit cc8b54131d
9 changed files with 1102 additions and 159 deletions

View File

@ -0,0 +1,49 @@
/* Copyright (C) 2024 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 nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiChannelHandler.Channel;
public abstract class AbstractXiaomiSppProtocol {
public static class ParseResult {
public enum Status {
Invalid,
Incomplete,
Complete,
};
final Status status;
int packetSize;
public ParseResult(final Status status) {
this.status = status;
}
public ParseResult(final Status status, final int packetSize) {
this(status);
this.packetSize = packetSize;
}
};
public abstract int findNextPacketOffset(final byte[] buffer);
public abstract ParseResult processPacket(final byte[] buffer);
public abstract byte[] encodePacket(Channel channel, byte[] chunk);
public boolean initializeSession() {
return true;
}
}

View File

@ -1,4 +1,4 @@
/* Copyright (C) 2023-2024 Andreas Shimokawa, José Rebelo
/* Copyright (C) 2023-2024 Andreas Shimokawa, José Rebelo, Yoran Vulker
This file is part of Gadgetbridge.
@ -37,14 +37,19 @@ import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.security.GeneralSecurityException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Locale;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
@ -359,4 +364,32 @@ public class XiaomiAuthService extends AbstractXiaomiService {
blockCipher.init(forEncrypt, new AEADParameters(new KeyParameter(secretKey.getEncoded()), macSizeBits, nonce, null));
return blockCipher;
}
public byte[] encryptV2(final byte[] message) {
try {
// I wish I was kidding
return ctrCrypt(Cipher.ENCRYPT_MODE, encryptionKey, encryptionKey, message);
} catch (final GeneralSecurityException ex) {
throw new RuntimeException("failed to encrypt message", ex);
}
}
public byte[] decryptV2(final byte[] ciphertext) {
try {
// I wish I was kidding
return ctrCrypt(Cipher.DECRYPT_MODE, decryptionKey, decryptionKey, ciphertext);
} catch (final GeneralSecurityException ex) {
throw new RuntimeException("failed to decrypt message", ex);
}
}
public byte[] ctrCrypt(final int op, final byte[] key, final byte[] iv, final byte[] message) throws GeneralSecurityException {
final Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
cipher.init(
op,
new SecretKeySpec(key, "AES"),
new IvParameterSpec(iv)
);
return cipher.doFinal(message);
}
}

View File

@ -1,5 +1,30 @@
/* Copyright (C) 2023-2024 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;
public interface XiaomiChannelHandler {
enum Channel {
Unknown,
Version,
ProtobufCommand,
Activity,
Data,
Authentication,
};
void handle(final byte[] payload);
}

View File

@ -26,12 +26,12 @@ import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto;
public abstract class XiaomiConnectionSupport {
public abstract boolean connect();
public abstract void onAuthSuccess();
public void onAuthSuccess() {}
public abstract void onUploadProgress(int textRsrc, int progressPercent, boolean ongoing);
public abstract void runOnQueue(String taskName, Runnable run);
public abstract void dispose();
public abstract void setContext(final GBDevice device, final BluetoothAdapter adapter, final Context context);
public abstract void sendCommand(final String taskName, final XiaomiProto.Command command);
public abstract void sendDataChunk(final String taskName, final byte[] chunk, @Nullable final XiaomiCharacteristic.SendCallback callback);
public abstract void setAutoReconnect(final boolean enabled);
public void setAutoReconnect(final boolean enabled) {}
}

View File

@ -1,4 +1,4 @@
/* Copyright (C) 2023 Yoran Vulker
/* Copyright (C) 2023-2024 Yoran Vulker
This file is part of Gadgetbridge.
@ -28,10 +28,11 @@ import java.util.Locale;
import java.util.concurrent.atomic.AtomicInteger;
import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto;
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiChannelHandler.Channel;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class XiaomiSppPacket {
private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppPacket.class);
public class XiaomiSppPacketV1 {
private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppPacketV1.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};
@ -55,17 +56,38 @@ public class XiaomiSppPacket {
public static final int DATA_TYPE_ENCRYPTED = 1;
public static final int DATA_TYPE_AUTH = 2;
public static final int OPCODE_READ = 0;
public static final int OPCODE_SEND = 2;
private byte[] payload;
private boolean flag, needsResponse;
private int channel, opCode, frameSerial, dataType;
private Channel channel;
private int rawChannel, opCode, frameSerial, dataType;
public static int getDataTypeForChannel(final Channel channel) {
switch (channel) {
case Authentication:
return DATA_TYPE_AUTH;
case ProtobufCommand:
case Version:
case Data:
return DATA_TYPE_ENCRYPTED;
default:
LOG.warn("getDataTypeForChannel(): cannot determine data type for channel {}", channel);
// fall through
case Activity: // and voice
return DATA_TYPE_PLAIN;
}
}
public static class Builder {
private byte[] payload = null;
private boolean flag = false, needsResponse = false;
private int channel = -1, opCode = -1, frameSerial = -1, dataType = -1;
private byte[] payload = new byte[0];
private boolean flag = true, needsResponse = false;
private Channel channel = Channel.Unknown;
private int opCode = -1, frameSerial = -1, dataType = -1;
public XiaomiSppPacket build() {
XiaomiSppPacket result = new XiaomiSppPacket();
public XiaomiSppPacketV1 build() {
XiaomiSppPacketV1 result = new XiaomiSppPacketV1();
result.channel = channel;
result.flag = flag;
@ -74,11 +96,12 @@ public class XiaomiSppPacket {
result.frameSerial = frameSerial;
result.dataType = dataType;
result.payload = payload;
result.rawChannel = getRawChannel(channel, true);
return result;
}
public Builder channel(final int channel) {
public Builder channel(final Channel channel) {
this.channel = channel;
return this;
}
@ -114,7 +137,7 @@ public class XiaomiSppPacket {
}
}
public int getChannel() {
public Channel getChannel() {
return channel;
}
@ -126,6 +149,29 @@ public class XiaomiSppPacket {
return payload;
}
public byte[] getDecryptedPayload(final XiaomiAuthService authService) {
if (payload == null) {
LOG.warn("getDecryptedPayload(): payload is null");
return null;
}
if (authService == null) {
LOG.warn("getDecryptedPayload(): authService is null");
return payload;
}
if (!authService.isEncryptionInitialized() && dataType == DATA_TYPE_ENCRYPTED) {
LOG.warn("getDecryptedPayload(): authService is not ready to decrypt");
return payload;
}
if (dataType == DATA_TYPE_ENCRYPTED) {
return authService.decrypt(payload);
}
return payload;
}
public boolean needsResponse() {
return needsResponse;
}
@ -134,10 +180,10 @@ public class XiaomiSppPacket {
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(
public static XiaomiSppPacketV1 fromXiaomiCommand(final XiaomiProto.Command command, int frameCounter, boolean needsResponse) {
return newBuilder().channel(Channel.ProtobufCommand).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();
).frameSerial(frameCounter).opCode(OPCODE_SEND).payload(command.toByteArray()).build();
}
public static Builder newBuilder() {
@ -148,11 +194,45 @@ public class XiaomiSppPacket {
@Override
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);
"SppPacket{ channel=%s, rawChannel=%d, flag=%b, needsResponse=%b, opCode=0x%x, frameSerial=0x%x, dataType=0x%x, payloadSize=%d }",
channel, rawChannel, flag, needsResponse, opCode, frameSerial, dataType, payload.length);
}
public static XiaomiSppPacket decode(final byte[] packet) {
public static int getRawChannel(final Channel channel, final boolean tx) {
switch (channel) {
case Version:
return CHANNEL_VERSION;
case Authentication:
case ProtobufCommand:
return tx ? CHANNEL_PROTO_TX : CHANNEL_PROTO_RX;
case Activity:
return CHANNEL_FITNESS;
case Data:
return CHANNEL_MASS;
default:
LOG.warn("Raw channel for {} unknown", channel);
return -1;
}
}
public static Channel getChannel(final byte rawChannel) {
switch (rawChannel & 0xff) {
case CHANNEL_PROTO_RX:
case CHANNEL_PROTO_TX:
return Channel.ProtobufCommand;
case CHANNEL_FITNESS:
return Channel.Activity;
case CHANNEL_MASS:
return Channel.Data;
case CHANNEL_VERSION:
return Channel.Version;
default:
LOG.warn("Cannot convert raw channel {} to known channel", rawChannel & 0xff);
return Channel.Unknown;
}
}
public static XiaomiSppPacketV1 decode(final byte[] packet) {
if (packet.length < 11) {
LOG.error("Cannot decode incomplete packet");
return null;
@ -208,8 +288,9 @@ public class XiaomiSppPacket {
return null;
}
XiaomiSppPacket result = new XiaomiSppPacket();
result.channel = channel;
XiaomiSppPacketV1 result = new XiaomiSppPacketV1();
result.rawChannel = channel;
result.channel = getChannel(channel);
result.flag = flag;
result.needsResponse = needsResponse;
result.opCode = opCode;
@ -223,7 +304,7 @@ public class XiaomiSppPacket {
public byte[] encode(final XiaomiAuthService authService, final AtomicInteger encryptionCounter) {
byte[] payload = this.payload;
if (dataType == DATA_TYPE_ENCRYPTED && channel == CHANNEL_PROTO_TX) {
if (dataType == DATA_TYPE_ENCRYPTED && channel == Channel.ProtobufCommand) {
int packetCounter = encryptionCounter.incrementAndGet();
payload = authService.encrypt(payload, packetCounter);
payload = ByteBuffer.allocate(payload.length + 2).order(ByteOrder.LITTLE_ENDIAN).putShort((short) packetCounter).put(payload).array();
@ -234,7 +315,7 @@ public class XiaomiSppPacket {
ByteBuffer buffer = ByteBuffer.allocate(11 + payload.length).order(ByteOrder.LITTLE_ENDIAN);
buffer.put(PACKET_PREAMBLE);
buffer.put((byte) (channel & 0xf));
buffer.put((byte) (getRawChannel(channel, true) & 0xf));
buffer.put((byte) ((flag ? 0x80 : 0) | (needsResponse ? 0x40 : 0)));
buffer.putShort((short) (payload.length + 3));

View File

@ -0,0 +1,514 @@
/* Copyright (C) 2024 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 nodomain.freeyourgadget.gadgetbridge.util.GB;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiChannelHandler.Channel;
public abstract class XiaomiSppPacketV2 {
private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppPacketV2.class);
public static final byte[] PACKET_PREAMBLE = new byte[]{(byte) 0xa5, (byte) 0xa5};
// TODO NACK
public static final int PACKET_TYPE_UNKNOWN = -1;
public static final int PACKET_TYPE_ACK = 1;
public static final int PACKET_TYPE_SESSION_CONFIG = 2;
public static final int PACKET_TYPE_DATA = 3;
private final int sequenceNumber;
private final int packetType;
protected abstract byte[] getPacketPayloadBytes(XiaomiAuthService authService);
public static abstract class Builder<T extends Builder<T>> {
int packetNumber = -1;
int packetType = PACKET_TYPE_UNKNOWN;
public T setSequenceNumber(int packetNumber) {
this.packetNumber = packetNumber;
return (T) this;
}
/**
* @noinspection UnusedReturnValue
*/
public T setPacketType(int packetType) {
this.packetType = packetType;
return (T) this;
}
public abstract XiaomiSppPacketV2 build();
}
public static class AckPacket extends XiaomiSppPacketV2 {
public static class Builder extends XiaomiSppPacketV2.Builder<Builder> {
public Builder() {
setPacketType(PACKET_TYPE_ACK);
}
@Override
public XiaomiSppPacketV2 build() {
return new AckPacket(this);
}
}
protected AckPacket(final Builder builder) {
super(builder.packetType, builder.packetNumber);
}
@Override
protected byte[] getPacketPayloadBytes(XiaomiAuthService authService) {
return new byte[0];
}
}
public static class SessionConfigPacket extends XiaomiSppPacketV2 {
public static final int OPCODE_START_SESSION_REQUEST = 1;
public static final int OPCODE_START_SESSION_RESPONSE = 2;
public static final int OPCODE_STOP_SESSION_REQUEST = 3;
public static final int OPCODE_STOP_SESSION_RESPONSE = 4;
public static final int KEY_VERSION = 1;
public static final int KEY_MAX_PACKET_SIZE = 2;
public static final int KEY_TX_WIN = 3;
public static final int KEY_SEND_TIMEOUT = 4;
private static final int VALUE_SIZE_VERSION = 3;
private static final int VALUE_SIZE_MAX_PACKET_SIZE = 2;
private static final int VALUE_SIZE_TX_WIN = 2;
private static final int VALUE_SIZE_SEND_TIMEOUT = 2;
public static class Builder extends XiaomiSppPacketV2.Builder<Builder> {
private int opCode = -1;
public Builder() {
setPacketType(PACKET_TYPE_SESSION_CONFIG);
}
public Builder setOpCode(final int opCode) {
this.opCode = opCode;
return this;
}
@Override
public XiaomiSppPacketV2 build() {
return new SessionConfigPacket(this);
}
}
private final int opCode;
protected SessionConfigPacket(final Builder builder) {
super(builder.packetType, builder.packetNumber);
this.opCode = builder.opCode;
}
public int getOpCode() {
return this.opCode;
}
@Override
protected byte[] getPacketPayloadBytes(XiaomiAuthService authService) {
// from packet dump of official app
return new byte[]{
// opcode
(byte) this.opCode,
// VERSION (type 1) = 01.00.00
KEY_VERSION,
0x03, 0x00,
0x01, 0x00, 0x00,
// MAX_FRAME_SIZE (type 2) = 0xfc00 -> 64512 bytes
KEY_MAX_PACKET_SIZE,
0x02, 0x00,
0x00, (byte) 0xfc,
// TX_WIN (type 3) = 0x0020 -> 32 frames
KEY_TX_WIN,
0x02, 0x00,
0x20, 0x00,
// SEND_TIMEOUT (type 4) = 0x2710 -> 10000ms
KEY_SEND_TIMEOUT,
0x02,
0x10, 0x27,
};
}
public static XiaomiSppPacketV2 decodePayloadBytes(final int sequenceNumber, final byte[] payloadBytes) {
final ByteBuffer buffer = ByteBuffer.wrap(payloadBytes).order(ByteOrder.LITTLE_ENDIAN);
if (buffer.remaining() < 1) {
LOG.warn("SessionConfig.decodePayloadBytes(): at least 1 byte required to decode");
return null;
}
final int opCode = buffer.get() & 0xff;
switch (opCode) {
case OPCODE_START_SESSION_REQUEST:
case OPCODE_START_SESSION_RESPONSE: {
while (buffer.remaining() >= 3) {
final int key = buffer.get() & 0xff;
final int valueSize = buffer.getShort() & 0xffff;
if (buffer.remaining() < valueSize) {
LOG.warn("not enough bytes remaining to extract value");
break;
}
// TODO store and handle values
switch (key) {
case KEY_VERSION: {
if (valueSize != VALUE_SIZE_VERSION) {
LOG.warn("expected {} bytes for version value, got {}", VALUE_SIZE_VERSION, valueSize);
buffer.get(new byte[valueSize]);
break;
}
final byte[] version = new byte[valueSize];
buffer.get(version);
LOG.debug("received SPPv2 version: {}", GB.hexdump(version));
break;
}
case KEY_MAX_PACKET_SIZE: {
if (valueSize != VALUE_SIZE_MAX_PACKET_SIZE) {
LOG.warn("expected 2 bytes for maximum packet size, got {}", valueSize);
buffer.get(new byte[valueSize]);
break;
}
LOG.debug("received max packet size: {}", buffer.getShort() & 0xffff);
break;
}
case KEY_TX_WIN: {
if (valueSize != VALUE_SIZE_TX_WIN) {
LOG.warn("expected {} bytes for transmission window, got {}", VALUE_SIZE_TX_WIN, valueSize);
buffer.get(new byte[valueSize]);
break;
}
LOG.debug("received tx win: {}", buffer.getShort() & 0xffff);
break;
}
case KEY_SEND_TIMEOUT: {
if (valueSize != VALUE_SIZE_SEND_TIMEOUT) {
LOG.warn("expected {} bytes for send timeout value, got {}", VALUE_SIZE_SEND_TIMEOUT, valueSize);
buffer.get(new byte[valueSize]);
break;
}
LOG.debug("received send timeout: {}ms", buffer.getShort() & 0xffff);
break;
}
default: {
final byte[] value = new byte[valueSize];
LOG.debug("received unknown config type {} with byte value {}",
key,
GB.hexdump(value));
break;
}
}
}
break;
}
case OPCODE_STOP_SESSION_REQUEST:
case OPCODE_STOP_SESSION_RESPONSE: {
break;
}
default: {
LOG.error("SessionConfigPacket#decode(): unknown opcode {}", opCode);
break;
}
}
return new Builder()
.setSequenceNumber(sequenceNumber)
.setOpCode(opCode)
.build();
}
}
public static class DataPacket extends XiaomiSppPacketV2 {
private static final int CHANNEL_UNKNOWN = -1;
private static final int CHANNEL_PROTOBUF = 1; // encrypted after authentication
private static final int CHANNEL_DATA = 2; // not encrypted
private static final int CHANNEL_ACTIVITY = 5; // encrypted
public static final int OPCODE_UNKNOWN = -1;
public static final int OPCODE_SEND_PLAINTEXT = 1;
public static final int OPCODE_SEND_ENCRYPTED = 2;
public static class Builder extends XiaomiSppPacketV2.Builder<Builder> {
private Channel channel = Channel.Unknown;
private int opCode = OPCODE_UNKNOWN;
private byte[] payload = new byte[0];
public Builder() {
setPacketType(PACKET_TYPE_DATA);
}
public Builder setOpCode(final int opCode) {
this.opCode = opCode;
return this;
}
public Builder setChannel(final Channel channel) {
this.channel = channel;
return this;
}
public Builder setPayload(final byte[] payload) {
this.payload = payload;
return this;
}
public XiaomiSppPacketV2 build() {
return new DataPacket(this);
}
}
private final Channel channel;
private final int opCode;
private final byte[] payload;
protected DataPacket(final Builder builder) {
super(builder.packetType, builder.packetNumber);
this.channel = builder.channel;
this.opCode = builder.opCode;
this.payload = builder.payload;
}
private static byte getRawChannel(final Channel channel) {
switch (channel) {
case Authentication: // fall through
case ProtobufCommand:
return CHANNEL_PROTOBUF;
case Data:
return CHANNEL_DATA;
case Activity:
return CHANNEL_ACTIVITY;
default:
LOG.warn("getRawChannel(): unable to get raw channel value for channel '{}'", channel);
return CHANNEL_UNKNOWN;
}
}
private static Channel getChannelFromRaw(final int rawChannel) {
switch (rawChannel) {
case CHANNEL_PROTOBUF:
return Channel.ProtobufCommand;
case CHANNEL_ACTIVITY:
return Channel.Activity;
case CHANNEL_DATA:
return Channel.Data;
default:
LOG.warn("getChannelFromRaw(): unknown raw channel {}", rawChannel);
return Channel.Unknown;
}
}
public static int getOpCodeForChannel(final Channel channel) {
switch (channel) {
case Authentication:
case Data:
return OPCODE_SEND_PLAINTEXT;
case ProtobufCommand:
case Activity:
return OPCODE_SEND_ENCRYPTED;
default:
LOG.warn("getOpCodeForChannel(): conversion for channel {} unknown", channel);
return OPCODE_UNKNOWN;
}
}
public static XiaomiSppPacketV2 decodePacketPayload(int sequenceNumber, byte[] payloadBytes) {
if (payloadBytes == null || payloadBytes.length < 2) {
LOG.error("DataPacket.decodePacketPayload(): not enough bytes to decode data packet payload");
return null;
}
final ByteBuffer buffer = ByteBuffer.wrap(payloadBytes).order(ByteOrder.LITTLE_ENDIAN);
final int rawChannel = buffer.get() & 0xf;
final int opCode = buffer.get() & 0xff;
final byte[] payload = new byte[buffer.remaining()];
buffer.get(payload);
return new Builder()
.setSequenceNumber(sequenceNumber)
.setChannel(getChannelFromRaw(rawChannel))
.setOpCode(opCode)
.setPayload(payload)
.build();
}
@Override
protected byte[] getPacketPayloadBytes(XiaomiAuthService authService) {
final ByteBuffer buffer = ByteBuffer.allocate(2 + payload.length);
buffer.put((byte) (getRawChannel(this.channel) & 0xf));
buffer.put((byte) (opCode & 0xff));
buffer.put(opCode == OPCODE_SEND_ENCRYPTED ? authService.encryptV2(payload) : payload);
return buffer.array();
}
public Channel getChannel() {
return channel;
}
public byte[] getPayloadBytes(final XiaomiAuthService authService) {
if (this.opCode == OPCODE_SEND_ENCRYPTED) {
return authService.decryptV2(this.payload);
}
return this.payload;
}
}
protected XiaomiSppPacketV2(final int packetType, final int sequenceNumber) {
this.packetType = packetType;
this.sequenceNumber = sequenceNumber;
}
public static SessionConfigPacket.Builder newSessionConfigPacketBuilder() {
return new SessionConfigPacket.Builder();
}
public static DataPacket.Builder newDataPacketBuilder() {
return new DataPacket.Builder();
}
public int getPacketType() {
return this.packetType;
}
public int getSequenceNumber() {
return this.sequenceNumber;
}
private static int calculatePayloadChecksum(final byte[] payload) {
// consider moving to nodomain.freeyourgadget.gadgetbridge.util.CheckSums
// configuration: CRC-16/ARC (poly=0x8005, init=0, xorout=0. refin, refout)
int crc = 0;
for (final byte b : payload) {
for (int j = 0; j < 8; j++) {
crc <<= 1;
if ((((crc >> 16) & 1) ^ ((b >> j) & 1)) == 1)
crc ^= 0x8005;
}
}
return (Integer.reverse(crc) >>> 16);
}
public byte[] encode(final XiaomiAuthService authService) {
final byte[] payloadBytes = getPacketPayloadBytes(authService);
final ByteBuffer buffer = ByteBuffer.allocate(8 + payloadBytes.length).order(ByteOrder.LITTLE_ENDIAN);
buffer.put(PACKET_PREAMBLE);
buffer.put((byte) (packetType & 0xf));
buffer.put((byte) (sequenceNumber & 0xff));
buffer.putShort((short) payloadBytes.length);
buffer.putShort((short) calculatePayloadChecksum(payloadBytes));
buffer.put(payloadBytes);
return buffer.array();
}
public static XiaomiSppPacketV2 decode(final byte[] packetBytes) {
if (packetBytes.length < 8) {
// caller should have checked if a full packet is in the given buffer
LOG.warn("decode(): at least 8 bytes required, got {}", packetBytes.length);
return null;
}
final ByteBuffer packetBuffer = ByteBuffer.wrap(packetBytes).order(ByteOrder.LITTLE_ENDIAN);
// verify packet preamble
{
final byte[] preamble = new byte[PACKET_PREAMBLE.length];
packetBuffer.get(preamble);
if (!Arrays.equals(PACKET_PREAMBLE, preamble)) {
LOG.error("decode(): packet header mismatch: expected {}, got {}", GB.hexdump(PACKET_PREAMBLE), GB.hexdump(preamble));
return null;
}
}
final int packetType, sequenceNumber, payloadLength, givenChecksum;
final byte[] payloadBytes;
// extract header fields and verify all bytes present
{
final byte b = packetBuffer.get(); // flags and packet type
// TODO process flags
packetType = b & 0xf;
sequenceNumber = packetBuffer.get() & 0xff;
payloadLength = packetBuffer.getShort() & 0xffff;
givenChecksum = packetBuffer.getShort() & 0xffff;
if (packetBuffer.remaining() < payloadLength) {
LOG.error("decode(): expected at least {} bytes in buffer, got {} (missing {} bytes to complete packet)",
payloadLength + 8,
packetBytes.length,
payloadLength - packetBuffer.remaining());
return null;
}
}
// get payload and verify checksum
{
payloadBytes = new byte[payloadLength];
packetBuffer.get(payloadBytes);
final int calculatedChecksum = calculatePayloadChecksum(payloadBytes);
if (calculatedChecksum != givenChecksum) {
LOG.error("decode(): payload checksum mismatch (given {} != calculated {})",
givenChecksum,
calculatedChecksum);
return null;
}
}
final XiaomiSppPacketV2 decodedPacket;
switch (packetType) {
case PACKET_TYPE_SESSION_CONFIG:
decodedPacket = SessionConfigPacket.decodePayloadBytes(sequenceNumber, payloadBytes);
break;
case PACKET_TYPE_DATA:
decodedPacket = DataPacket.decodePacketPayload(sequenceNumber, payloadBytes);
break;
case PACKET_TYPE_ACK:
decodedPacket = new AckPacket.Builder()
.setSequenceNumber(sequenceNumber)
.build();
break;
default:
LOG.warn("decode(): unhandled packet type {}", packetType);
decodedPacket = null;
break;
}
return decodedPacket;
}
}

View File

@ -0,0 +1,120 @@
/* Copyright (C) 2024 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.concurrent.atomic.AtomicInteger;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV1.OPCODE_SEND;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV1.PACKET_PREAMBLE;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV1.getDataTypeForChannel;
public class XiaomiSppProtocolV1 extends AbstractXiaomiSppProtocol {
private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppProtocolV1.class);
private final XiaomiSppSupport support;
private final AtomicInteger frameCounter = new AtomicInteger(0);
private final AtomicInteger encryptionCounter = new AtomicInteger(0);
public XiaomiSppProtocolV1(XiaomiSppSupport support) {
this.support = support;
}
@Override
public int findNextPacketOffset(byte[] buffer) {
for (int i = 1; i < buffer.length; i++) {
// just check for the first byte, the processPacket method checks the full magic
if (buffer[i] == PACKET_PREAMBLE[0]) {
return i;
}
}
return -1;
}
@Override
public ParseResult processPacket(byte[] buffer) {
if (buffer.length < 11) {
LOG.debug("processPacket(): not enough bytes in rx buffer to decode packet header");
return new ParseResult(ParseResult.Status.Incomplete);
}
final ByteBuffer headerBuffer = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN);
final int packetSize;
// verify preamble
{
byte[] preamble = new byte[PACKET_PREAMBLE.length];
headerBuffer.get(preamble);
if (!Arrays.equals(PACKET_PREAMBLE, preamble)) {
LOG.debug("processPacket(): header mismatch, expected {}, got {}",
GB.hexdump(PACKET_PREAMBLE),
GB.hexdump(preamble));
return new ParseResult(ParseResult.Status.Invalid);
}
}
// verify packet size
{
headerBuffer.getShort(); // skip flags and channel ID
int payloadSize = headerBuffer.getShort() & 0xffff;
packetSize = payloadSize + 8; // payload size includes payload header
if (buffer.length < packetSize) {
LOG.debug("processPacket(): received {}, missing {}/{} packet bytes",
buffer.length,
packetSize - buffer.length,
packetSize);
return new ParseResult(ParseResult.Status.Incomplete);
}
LOG.debug("processPacket(): all bytes for packet of {} bytes in buffer", packetSize);
}
XiaomiSppPacketV1 receivedPacket = XiaomiSppPacketV1.decode(buffer);
if (receivedPacket == null) {
LOG.debug("processPacket(): decoded packet is null");
return new ParseResult(ParseResult.Status.Invalid);
}
LOG.debug("processPacket(): Packet received: {}", receivedPacket);
support.onPacketReceived(receivedPacket.getChannel(), receivedPacket.getDecryptedPayload(support.getAuthService()));
// TODO send response if requested by device
return new ParseResult(ParseResult.Status.Complete, packetSize);
}
@Override
public byte[] encodePacket(XiaomiChannelHandler.Channel channel, byte[] data) {
return XiaomiSppPacketV1.newBuilder()
.channel(channel)
.opCode(OPCODE_SEND)
.frameSerial(frameCounter.getAndIncrement())
.dataType(getDataTypeForChannel(channel))
.payload(data)
.build()
.encode(support.getAuthService(), encryptionCounter);
}
}

View File

@ -0,0 +1,146 @@
/* Copyright (C) 2024 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.service.btbr.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV2.PACKET_PREAMBLE;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV2.PACKET_TYPE_ACK;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV2.PACKET_TYPE_DATA;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV2.PACKET_TYPE_SESSION_CONFIG;
public class XiaomiSppProtocolV2 extends AbstractXiaomiSppProtocol {
private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppProtocolV2.class);
private final AtomicInteger packetSequenceCounter = new AtomicInteger(0);
private final XiaomiSppSupport support;
public XiaomiSppProtocolV2(final XiaomiSppSupport support) {
this.support = support;
}
private void sendAck(final int sequenceNumber) {
final TransactionBuilder b = support.commsSupport.createTransactionBuilder(String.format(Locale.ROOT, "send ack for %d", sequenceNumber));
b.write(new XiaomiSppPacketV2.AckPacket.Builder()
.setSequenceNumber(sequenceNumber)
.build()
.encode(null));
b.queue(support.commsSupport.getQueue());
}
@Override
public int findNextPacketOffset(byte[] buffer) {
for (int i = 1; i < buffer.length; i++) {
if (buffer[i] == PACKET_PREAMBLE[0])
return i;
}
return -1;
}
@Override
public ParseResult processPacket(byte[] rxBuf) {
if (rxBuf.length < 8) {
LOG.debug("processPacket(): not enough bytes in buffer to process packet (got {} of required {} bytes)",
rxBuf.length,
8);
return new ParseResult(ParseResult.Status.Incomplete);
}
final ByteBuffer buffer = ByteBuffer.wrap(rxBuf).order(ByteOrder.LITTLE_ENDIAN);
final byte[] headerMagic = new byte[PACKET_PREAMBLE.length];
buffer.get(headerMagic);
if (!Arrays.equals(PACKET_PREAMBLE, headerMagic)) {
LOG.warn("processPacket(): invalid header magic (expected {}, got {})",
GB.hexdump(PACKET_PREAMBLE),
GB.hexdump(headerMagic));
return new ParseResult(ParseResult.Status.Invalid);
}
buffer.get(); // flags and packet type
buffer.get(); // packet sequence number
final int packetSize = 8 + (buffer.getShort() & 0xffff);
buffer.getShort(); // checksum
if (rxBuf.length < packetSize) {
LOG.debug("processPacket(): missing {} bytes (got {}/{} bytes)",
packetSize - rxBuf.length,
rxBuf.length,
packetSize);
return new ParseResult(ParseResult.Status.Incomplete);
}
final XiaomiSppPacketV2 decodedPacket = XiaomiSppPacketV2.decode(rxBuf);
if (decodedPacket != null) {
switch (decodedPacket.getPacketType()) {
case PACKET_TYPE_SESSION_CONFIG:
// TODO handle device's session config
LOG.info("Received session config, opcode={}", ((XiaomiSppPacketV2.SessionConfigPacket)decodedPacket).getOpCode());
support.getAuthService().startEncryptedHandshake();
break;
case PACKET_TYPE_DATA:
XiaomiSppPacketV2.DataPacket dataPacket = (XiaomiSppPacketV2.DataPacket) decodedPacket;
support.onPacketReceived(dataPacket.getChannel(), dataPacket.getPayloadBytes(support.getAuthService()));
// TODO: only directly ack protobuf packets, bulk ack others
sendAck(decodedPacket.getSequenceNumber());
break;
case PACKET_TYPE_ACK:
LOG.debug("receive ack for packet {}", decodedPacket.getSequenceNumber());
break;
default:
LOG.warn("Unhandled packet with type {} (decoded type {})", decodedPacket.getPacketType(), decodedPacket.getClass().getSimpleName());
break;
}
}
return new ParseResult(ParseResult.Status.Complete, packetSize);
}
@Override
public boolean initializeSession() {
final TransactionBuilder builder = support.commsSupport.createTransactionBuilder("send session config");
builder.write(XiaomiSppPacketV2.newSessionConfigPacketBuilder()
.setOpCode(XiaomiSppPacketV2.SessionConfigPacket.OPCODE_START_SESSION_REQUEST)
.setSequenceNumber(0)
.build()
.encode(null));
builder.queue(support.commsSupport.getQueue());
return false;
}
@Override
public byte[] encodePacket(XiaomiChannelHandler.Channel channel, byte[] payloadBytes) {
return XiaomiSppPacketV2.newDataPacketBuilder()
.setChannel(channel)
.setSequenceNumber(packetSequenceCounter.getAndIncrement())
.setOpCode(XiaomiSppPacketV2.DataPacket.getOpCodeForChannel(channel))
.setPayload(payloadBytes)
.build()
.encode(support.getAuthService());
}
}

View File

@ -1,4 +1,4 @@
/* Copyright (C) 2023 José Rebelo, Yoran Vulker
/* Copyright (C) 2023-2024 José Rebelo, Yoran Vulker
This file is part of Gadgetbridge.
@ -16,15 +16,14 @@
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_MASS;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.CHANNEL_PROTO_RX;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.DATA_TYPE_ENCRYPTED;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacket.PACKET_PREAMBLE;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV1.DATA_TYPE_PLAIN;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSppPacketV1.OPCODE_READ;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothSocket;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.Nullable;
@ -33,14 +32,11 @@ 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.deviceevents.GBDeviceEventUpdateDeviceInfo;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto;
import nodomain.freeyourgadget.gadgetbridge.service.btbr.AbstractBTBRDeviceSupport;
@ -48,6 +44,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.PlainAction;
import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.SetDeviceStateAction;
import nodomain.freeyourgadget.gadgetbridge.service.btbr.actions.SetProgressAction;
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiChannelHandler.Channel;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class XiaomiSppSupport extends XiaomiConnectionSupport {
@ -64,11 +61,6 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport {
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..
@ -80,10 +72,18 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport {
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.AUTHENTICATING, getContext()));
builder.write(XiaomiSppPacketV1.newBuilder()
.channel(Channel.Version)
.needsResponse(true)
.opCode(OPCODE_READ)
.dataType(DATA_TYPE_PLAIN)
.frameSerial(0)
.build()
.encode(null, null));
builder.add(new PlainAction() {
@Override
public boolean run(BluetoothSocket socket) {
mXiaomiSupport.getAuthService().startEncryptedHandshake();
mVersionResponseTimeoutHandler.postDelayed(new VersionTimeoutRunnable(), 5000L);
return true;
}
});
@ -96,12 +96,6 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport {
return XiaomiUuids.UUID_SERVICE_SERIAL_PORT_PROFILE;
}
@Override
public void disconnect() {
mXiaomiSupport.onDisconnect();
super.disconnect();
}
@Override
public void dispose() {
mXiaomiSupport.onDisconnect();
@ -110,16 +104,22 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport {
};
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<>();
private final Map<Channel, XiaomiChannelHandler> mChannelHandlers = new HashMap<>();
private final Handler mVersionResponseTimeoutHandler = new Handler(Looper.getMainLooper());
private AbstractXiaomiSppProtocol mProtocol = new XiaomiSppProtocolV1(this);
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);
mChannelHandlers.put(Channel.Version, this::handleVersionPacket);
mChannelHandlers.put(Channel.ProtobufCommand, this.mXiaomiSupport::handleCommandBytes);
mChannelHandlers.put(Channel.Activity, this.mXiaomiSupport.getHealthService().getActivityFetcher()::addChunk);
}
@Override
public void setContext(GBDevice device, BluetoothAdapter adapter, Context context) {
this.commsSupport.setContext(device, adapter, context);
}
@Override
@ -128,8 +128,12 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport {
}
@Override
public void onAuthSuccess() {
// Do nothing.
public void dispose() {
commsSupport.dispose();
}
protected XiaomiAuthService getAuthService() {
return mXiaomiSupport.getAuthService();
}
@Override
@ -153,7 +157,7 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport {
if (commsSupport == null) {
LOG.error("commsSupport is null, unable to queue task");
return;
}
}
final TransactionBuilder b = commsSupport.createTransactionBuilder("run task " + taskName + " on queue");
b.add(new PlainAction() {
@ -166,87 +170,54 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport {
b.queue(commsSupport.getQueue());
}
@Override
public void setContext(GBDevice device, BluetoothAdapter adapter, Context context) {
this.commsSupport.setContext(device, adapter, context);
}
private void skipBuffer(int newStart) {
final byte[] bufferState = buffer.toByteArray();
buffer.reset();
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;
}
if (newStart < 0) {
newStart = bufferState.length;
}
// did not find preamble
return -1;
if (newStart >= bufferState.length) {
return;
}
buffer.write(bufferState, newStart, bufferState.length - newStart);
}
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);
boolean shouldProcess = true;
while (shouldProcess) {
final byte[] bufferState = buffer.toByteArray();
final AbstractXiaomiSppProtocol.ParseResult parseResult = mProtocol.processPacket(bufferState);
LOG.debug("processBuffer(): protocol.processPacket() returned status {}", parseResult.status);
int skipBytes;
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);
switch (parseResult.status) {
case Incomplete:
skipBytes = 0;
shouldProcess = false;
break;
case Complete:
skipBytes = parseResult.packetSize;
break;
case Invalid:
skipBytes = mProtocol.findNextPacketOffset(bufferState);
if (skipBytes < 0) {
skipBytes = bufferState.length;
}
}
// continue processing at beginning of new buffer
continue;
break;
default:
throw new IllegalStateException(String.format("Unhandled parse state %s", parseResult.status));
}
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);
if (skipBytes > 0) {
LOG.debug("processBuffer(): skipping {} bytes for state {}", skipBytes, parseResult.status);
skipBuffer(skipBytes);
}
}
}
@Override
public void dispose() {
commsSupport.dispose();
}
public void onSocketRead(byte[] data) {
try {
buffer.write(data);
@ -257,26 +228,12 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport {
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);
}
final XiaomiChannelHandler handler = mChannelHandlers.get(packet.getChannel());
protected void onPacketReceived(final Channel channel, final byte[] payload) {
final XiaomiChannelHandler handler = mChannelHandlers.get(channel);
if (handler != null) {
handler.handle(payload);
} else {
LOG.warn("Unhandled SppPacket on channel {}", packet.getChannel());
LOG.warn("Unhandled SppPacket on channel {}", channel);
}
}
@ -292,27 +249,20 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport {
}
public void sendCommand(final TransactionBuilder builder, final XiaomiProto.Command command) {
final XiaomiSppPacket packet = XiaomiSppPacket.fromXiaomiCommand(command, frameCounter.getAndIncrement(), false);
LOG.debug("sending packet: {}, payload={}", packet, GB.hexdump(packet.getPayload()));
builder.write(packet.encode(mXiaomiSupport.getAuthService(), encryptionCounter));
LOG.debug("sendCommand(): encoded command for task '{}': {}", builder.getTransaction().getTaskName(), GB.hexdump(command.toByteArray()));
if (command.getType() == XiaomiAuthService.COMMAND_TYPE) {
builder.write(mProtocol.encodePacket(Channel.Authentication, command.toByteArray()));
} else {
builder.write(mProtocol.encodePacket(Channel.ProtobufCommand, command.toByteArray()));
}
// do not queue here, that's the job of the caller
}
public void sendDataChunk(final String taskName, final byte[] chunk, @Nullable final XiaomiCharacteristic.SendCallback callback) {
XiaomiSppPacket packet = XiaomiSppPacket.newBuilder()
.channel(CHANNEL_MASS)
.needsResponse(false)
.flag(true)
.opCode(2)
.frameSerial(frameCounter.getAndIncrement())
.dataType(DATA_TYPE_ENCRYPTED)
.payload(chunk)
.build();
LOG.debug("sending data packet: {}", packet);
TransactionBuilder b = this.commsSupport.createTransactionBuilder("send " + taskName);
b.write(packet.encode(mXiaomiSupport.getAuthService(), encryptionCounter));
b.queue(commsSupport.getQueue());
LOG.debug("sendDataChunk(): encoded data chunk for task '{}': {}", taskName, GB.hexdump(chunk));
this.commsSupport.createTransactionBuilder("send " + taskName)
.write(mProtocol.encodePacket(Channel.Data, chunk))
.queue(commsSupport.getQueue());
if (callback != null) {
// callback puts a SetProgressAction onto the queue
@ -320,9 +270,34 @@ public class XiaomiSppSupport extends XiaomiConnectionSupport {
}
}
@Override
public void setAutoReconnect(boolean enabled) {
// for sanity, but this is not supposed to be set on BT Classic devices
this.commsSupport.setAutoReconnect(enabled);
private void handleVersionPacket(final byte[] payloadBytes) {
// remove timeout actions from handler
mVersionResponseTimeoutHandler.removeCallbacksAndMessages(null);
if (payloadBytes != null && payloadBytes.length > 0) {
LOG.debug("Received SPP protocol version: {}", GB.hexdump(payloadBytes));
// show in details
final GBDeviceEventUpdateDeviceInfo event = new GBDeviceEventUpdateDeviceInfo("SPP_PROTOCOL: ", GB.hexdump(payloadBytes));
mXiaomiSupport.evaluateGBDeviceEvent(event);
// TODO handle different protocol versions
if (payloadBytes[0] >= 2) {
LOG.info("handleVersionPacket(): detected protocol version higher than 2, switching protocol");
mProtocol = new XiaomiSppProtocolV2(this);
}
}
if (mProtocol.initializeSession()) {
mXiaomiSupport.getAuthService().startEncryptedHandshake();
}
}
class VersionTimeoutRunnable implements Runnable {
@Override
public void run() {
LOG.warn("SPP protocol version request timed out");
XiaomiSppSupport.this.handleVersionPacket(new byte[0]);
}
}
}