/* 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 . */ 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> { 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 { 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 { 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 { 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; } }