/* Copyright (C) 2023-2024 José Rebelo, Yoran Vulker This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi; 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; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.UUID; 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; 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 { private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppSupport.class); AbstractBTBRDeviceSupport commsSupport = new AbstractBTBRDeviceSupport(LOG) { @Override public boolean useAutoConnect() { return mXiaomiSupport.useAutoConnect(); } @Override public void onSocketRead(byte[] data) { XiaomiSppSupport.this.onSocketRead(data); } @Override protected TransactionBuilder initializeDevice(TransactionBuilder builder) { // FIXME unsetDynamicState unsets the fw version, which causes problems.. if (getDevice().getFirmwareVersion() == null) { getDevice().setFirmwareVersion(mXiaomiSupport.getCachedFirmwareVersion() != null ? mXiaomiSupport.getCachedFirmwareVersion() : "N/A"); } 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) { mVersionResponseTimeoutHandler.postDelayed(new VersionTimeoutRunnable(), 5000L); return true; } }); return builder; } @Override protected UUID getSupportedService() { return XiaomiUuids.UUID_SERVICE_SERIAL_PORT_PROFILE; } @Override public void dispose() { mXiaomiSupport.onDisconnect(); super.dispose(); } }; ByteArrayOutputStream buffer = new ByteArrayOutputStream(); private final XiaomiSupport mXiaomiSupport; private final Map 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.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 public boolean connect() { return commsSupport.connect(); } @Override public void dispose() { commsSupport.dispose(); } protected XiaomiAuthService getAuthService() { return mXiaomiSupport.getAuthService(); } @Override public void onUploadProgress(final int textRsrc, final int progressPercent, final boolean ongoing) { try { final TransactionBuilder builder = commsSupport.createTransactionBuilder("send data upload progress"); builder.add(new SetProgressAction( commsSupport.getContext().getString(textRsrc), ongoing, progressPercent, commsSupport.getContext() )); builder.queue(commsSupport.getQueue()); } catch (final Exception e) { LOG.error("Failed to update progress notification", e); } } @Override public void runOnQueue(String taskName, Runnable runnable) { 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() { @Override public boolean run(BluetoothSocket socket) { runnable.run(); return true; } }); b.queue(commsSupport.getQueue()); } private void skipBuffer(int newStart) { final byte[] bufferState = buffer.toByteArray(); buffer.reset(); if (newStart < 0) { newStart = bufferState.length; } if (newStart >= bufferState.length) { return; } buffer.write(bufferState, newStart, bufferState.length - newStart); } private void processBuffer() { 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; 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; } break; default: throw new IllegalStateException(String.format("Unhandled parse state %s", parseResult.status)); } if (skipBytes > 0) { LOG.debug("processBuffer(): skipping {} bytes for state {}", skipBytes, parseResult.status); skipBuffer(skipBytes); } } } public void onSocketRead(byte[] data) { try { buffer.write(data); } catch (IOException ex) { LOG.error("Exception while writing buffer: ", ex); } processBuffer(); } 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 {}", channel); } } @Override public void sendCommand(final String taskName, final XiaomiProto.Command command) { try { final TransactionBuilder builder = this.commsSupport.createTransactionBuilder("send " + taskName); sendCommand(builder, command); builder.queue(this.commsSupport.getQueue()); } catch (final Exception ex) { LOG.error("Caught unexpected exception while sending command, device may not have been informed!", ex); } } public void sendCommand(final TransactionBuilder builder, final XiaomiProto.Command command) { 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) { 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 callback.onSend(); } } 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]); } } }