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:
parent
9f566fb7d9
commit
cc8b54131d
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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) {}
|
||||
}
|
||||
|
@ -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));
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user