/* * Copyright (c) 2018, Nordic Semiconductor * * SPDX-License-Identifier: Apache-2.0 */ package io.runtime.mcumgr.ble; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.bluetooth.BluetoothGattService; import android.content.Context; import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.LinkedList; import java.util.List; import java.util.UUID; import io.runtime.mcumgr.McuMgrCallback; import io.runtime.mcumgr.McuMgrHeader; import io.runtime.mcumgr.McuMgrScheme; import io.runtime.mcumgr.McuMgrTransport; import io.runtime.mcumgr.ble.callback.SmpDataCallback; import io.runtime.mcumgr.ble.callback.SmpMerger; import io.runtime.mcumgr.ble.callback.SmpResponse; import io.runtime.mcumgr.exception.InsufficientMtuException; import io.runtime.mcumgr.exception.McuMgrErrorException; import io.runtime.mcumgr.exception.McuMgrException; import io.runtime.mcumgr.exception.McuMgrTimeoutException; import io.runtime.mcumgr.response.McuMgrResponse; import io.runtime.mcumgr.util.CBOR; import no.nordicsemi.android.ble.BleManager; import no.nordicsemi.android.ble.Request; import no.nordicsemi.android.ble.annotation.ConnectionPriority; import no.nordicsemi.android.ble.callback.FailCallback; import no.nordicsemi.android.ble.callback.MtuCallback; import no.nordicsemi.android.ble.callback.SuccessCallback; import no.nordicsemi.android.ble.data.Data; import no.nordicsemi.android.ble.data.DataMerger; import no.nordicsemi.android.ble.error.GattError; import no.nordicsemi.android.ble.exception.BluetoothDisabledException; import no.nordicsemi.android.ble.exception.DeviceDisconnectedException; import no.nordicsemi.android.ble.exception.InvalidRequestException; import no.nordicsemi.android.ble.exception.RequestFailedException; /** * The McuMgrBleTransport is an implementation for the {@link McuMgrScheme#BLE} transport scheme. * This class extends {@link BleManager}, which handles the BLE state machine and owns the * {@link BluetoothGatt} object that executes BLE actions. If you wish to integrate McuManager an * existing BLE implementation, you may simply implement {@link McuMgrTransport} or use this class * to perform your BLE actions by calling {@link BleManager#enqueue(Request)}. */ @SuppressWarnings("unused") public class McuMgrBleTransport extends BleManager implements McuMgrTransport { private static final Logger LOG = LoggerFactory.getLogger(McuMgrBleTransport.class); public final static UUID SMP_SERVICE_UUID = UUID.fromString("8D53DC1D-1DB7-4CD3-868B-8A527460AA84"); private final static UUID SMP_CHAR_UUID = UUID.fromString("DA2E7828-FBCE-4E01-AE9E-261174997C48"); /** * Simple Management Protocol service. */ private BluetoothGattService mSmpService; /** * Simple Management Protocol characteristic. */ private BluetoothGattCharacteristic mSmpCharacteristic; /** * The Bluetooth device for this transporter. */ private final BluetoothDevice mDevice; /** * An instance of a merger used to merge SMP packets that are split into multiple BLE packets. */ private final DataMerger mSMPMerger = new SmpMerger(); /** * The maximum packet length supported by the target device. * This may be greater than MTU size. * For packets longer than this value an {@link InsufficientMtuException} will be thrown. * Packets longer than MTU, but shorter than this value will be split. * Splitting packets must be supported by SMP Server on the target device. */ private int mMaxPacketLength; /** * Flag indicating should low-level logging be enabled. Default to false. * Call {@link #setLoggingEnabled(boolean)} to change. */ private boolean mLoggingEnabled; /** * Construct a McuMgrBleTransport object. * * @param context the context used to connect to the device. * @param device the device to connect to and communicate with. */ public McuMgrBleTransport(@NonNull Context context, @NonNull BluetoothDevice device) { super(context); mDevice = device; } /** * Returns the device set in the constructor. * * @return The device to connect to and communicate with. */ @NonNull @Override public BluetoothDevice getBluetoothDevice() { return mDevice; } @NonNull @Override protected BleManagerGattCallback getGattCallback() { return new McuMgrGattCallback(); } /** * In order to send packets longer than MTU size, this library supports automatic splitting * of packets into at-most-MTU size chunks. This feature must be also supported by the target * device, as it must merge received chunks into a single packet, based on the length field * from the {@link io.runtime.mcumgr.McuMgrHeader}, included in the first chunk. * <p> * {@link io.runtime.mcumgr.managers.ImageManager} and * {@link io.runtime.mcumgr.managers.FsManager} will automatically split the file into multiple * SMP packets. This feature is about splitting SMP packet into chunks, not splitting data into * SMP packets. * <p> * This method sets the maximum packet length supported by the target device. * By default, this is be set to MTU - 3, which means that no splitting will be done. * <p> * Keep in mind, that before Android 5 requesting higher MTU was not supported. Setting the * maximum length to a greater value is required on those devices in order to upgrade * the firmware, send file or send any other SMP packet that is longer than 20 bytes. * * @param maxLength the maximum packet length. */ public void setDeviceSidePacketMergingSupported(int maxLength) { mMaxPacketLength = maxLength; } //******************************************************************************************* // Logging //******************************************************************************************* /** * Allows to enable low-level logging. If enabled, all BLE events will be logged. * * @param enabled true to enable logging, false to disable (default). */ public void setLoggingEnabled(boolean enabled) { mLoggingEnabled = enabled; } @Override public void log(int priority, @NonNull String message) { if (mLoggingEnabled) { switch (priority) { case Log.DEBUG: LOG.debug(message); break; case Log.INFO: LOG.info(message); break; case Log.WARN: LOG.warn(message); break; case Log.ERROR: case Log.ASSERT: LOG.error(message); break; case Log.VERBOSE: default: LOG.trace(message); break; } } } //******************************************************************************************* // Mcu Manager Transport //******************************************************************************************* @NonNull @Override public McuMgrScheme getScheme() { return McuMgrScheme.BLE; } @NonNull @Override public <T extends McuMgrResponse> T send(@NonNull final byte[] payload, @NonNull final Class<T> responseType) throws McuMgrException { // If device is not connected, connect final boolean wasConnected = isConnected(); try { // Await will wait until the device is ready (that is initialization is complete) connect(mDevice) .retry(3, 100) .timeout(25 * 1000) .await(); if (!wasConnected) { notifyConnected(); } } catch (RequestFailedException e) { switch (e.getStatus()) { case FailCallback.REASON_DEVICE_NOT_SUPPORTED: throw new McuMgrException("Device does not support SMP Service"); case FailCallback.REASON_REQUEST_FAILED: // This could be thrown only if the manager was requested to connect for // a second time and to a different device than the one that's already // connected. This may not happen here. throw new McuMgrException("Other device already connected"); case FailCallback.REASON_TIMEOUT: // Called after receiving error 133 after 30 seconds. throw new McuMgrTimeoutException(); default: // Other errors are currently never thrown for the connect request. throw new McuMgrException("Unknown error"); } } catch (InterruptedException e) { // On timeout, fail the request throw new McuMgrException("Connection routine timed out."); } catch (DeviceDisconnectedException e) { // When connection failed, fail the request throw new McuMgrException("Device has disconnected"); } catch (BluetoothDisabledException e) { // When Bluetooth was disabled, fail the request throw new McuMgrException("Bluetooth adapter disabled"); } catch (InvalidRequestException e) { // Ignore. This exception won't be thrown throw new RuntimeException("Invalid request"); } // Ensure the MTU is sufficient. if (mMaxPacketLength < payload.length) { throw new InsufficientMtuException(payload.length, mMaxPacketLength); } // Send the request and wait for a notification in a synchronous way try { if (mLoggingEnabled) { try { log(Log.VERBOSE, "Sending " + McuMgrHeader.fromBytes(payload).toString() + " CBOR " + CBOR.toString(payload, McuMgrHeader.HEADER_LENGTH)); } catch (Exception e) { // Ignore } } final SmpResponse<T> smpResponse = waitForNotification(mSmpCharacteristic) .merge(mSMPMerger) .trigger(writeCharacteristic(mSmpCharacteristic, payload).split()) .timeout(30000) .await(new SmpResponse<>(responseType)); if (smpResponse.isValid()) { if (mLoggingEnabled) { try { byte[] response = smpResponse.getRawData().getValue(); //noinspection ConstantConditions log(Log.INFO, "Received " + McuMgrHeader.fromBytes(response).toString() + " CBOR " + CBOR.toString(response, McuMgrHeader.HEADER_LENGTH)); } catch (Exception e) { // Ignore } } //noinspection ConstantConditions return smpResponse.getResponse(); } else { throw new McuMgrException("Error building " + "McuMgrResponse from response data: " + smpResponse.getRawData()); } } catch (RequestFailedException e) { throw new McuMgrException(GattError.parse(e.getStatus())); } catch (InterruptedException e) { // Device must have disconnected moment before the request was made throw new McuMgrException("Request timed out"); } catch (DeviceDisconnectedException e) { // When connection failed, fail the request throw new McuMgrException("Device has disconnected"); } catch (BluetoothDisabledException e) { // When Bluetooth was disabled, fail the request throw new McuMgrException("Bluetooth adapter disabled"); } catch (InvalidRequestException e) { // Ignore. This exception won't be thrown throw new RuntimeException("Invalid request"); } } @Override public <T extends McuMgrResponse> void send(@NonNull final byte[] payload, @NonNull final Class<T> responseType, @NonNull final McuMgrCallback<T> callback) { // If device is not connected, connect. // If the device was already connected, the completion callback will be called immediately. final boolean wasConnected = isConnected(); connect(mDevice).done(new SuccessCallback() { @Override public void onRequestCompleted(@NonNull final BluetoothDevice device) { if (!wasConnected) { notifyConnected(); } // Ensure the MTU is sufficient. Packets longer than MTU, but shorter // then few MTU lengths can be split automatically. if (mMaxPacketLength < payload.length) { callback.onError(new InsufficientMtuException(payload.length, mMaxPacketLength)); return; } if (mLoggingEnabled) { try { log(Log.VERBOSE, "Sending " + McuMgrHeader.fromBytes(payload).toString() + " CBOR " + CBOR.toString(payload, McuMgrHeader.HEADER_LENGTH)); } catch (Exception e) { // Ignore } } waitForNotification(mSmpCharacteristic) .merge(mSMPMerger) .with(new SmpDataCallback<T>(responseType) { @Override public void onDataReceived(@NonNull BluetoothDevice device, @NonNull Data data) { if (mLoggingEnabled) { try { byte[] response = data.getValue(); //noinspection ConstantConditions log(Log.INFO, "Received " + McuMgrHeader.fromBytes(response).toString() + " CBOR " + CBOR.toString(response, McuMgrHeader.HEADER_LENGTH)); } catch (Exception e) { // Ignore } } super.onDataReceived(device, data); } @Override public void onResponseReceived(@NonNull BluetoothDevice device, @NonNull T response) { if (response.isSuccess()) { callback.onResponse(response); } else { callback.onError(new McuMgrErrorException(response)); } } @Override public void onInvalidDataReceived(@NonNull BluetoothDevice device, @NonNull Data data) { callback.onError(new McuMgrException("Error building " + "McuMgrResponse from response data: " + data)); } }) .trigger(writeCharacteristic(mSmpCharacteristic, payload).split()) .fail(new FailCallback() { @Override public void onRequestFailed(@NonNull BluetoothDevice device, int status) { switch (status) { case REASON_TIMEOUT: callback.onError(new McuMgrException("Request timed out")); break; case REASON_DEVICE_DISCONNECTED: callback.onError(new McuMgrException("Device has disconnected")); break; case REASON_BLUETOOTH_DISABLED: callback.onError(new McuMgrException("Bluetooth adapter disabled")); break; default: callback.onError(new McuMgrException(GattError.parse(status))); break; } } }) .timeout(30000) .enqueue(); } }).fail(new FailCallback() { @Override public void onRequestFailed(@NonNull final BluetoothDevice device, final int status) { switch (status) { case REASON_DEVICE_DISCONNECTED: callback.onError(new McuMgrException("Device has disconnected")); break; case REASON_DEVICE_NOT_SUPPORTED: callback.onError(new McuMgrException("Device does not support SMP Service")); break; case REASON_REQUEST_FAILED: // This could be thrown only if the manager was requested to connect for // a second time and to a different device than the one that's already // connected. This may not happen here. callback.onError(new McuMgrException("Other device already connected")); break; case REASON_TIMEOUT: // Called after receiving error 133 after 30 seconds. callback.onError(new McuMgrTimeoutException()); break; case REASON_BLUETOOTH_DISABLED: callback.onError(new McuMgrException("Bluetooth adapter disabled")); break; default: callback.onError(new McuMgrException(GattError.parseConnectionError(status))); break; } } }) .retry(3, 100) .enqueue(); } @Override public void connect(@Nullable final ConnectionCallback callback) { if (isConnected()) { if (callback != null) { callback.onConnected(); } return; } connect(mDevice) .retry(3, 100) .done(new SuccessCallback() { @Override public void onRequestCompleted(@NonNull BluetoothDevice device) { notifyConnected(); if (callback == null) { return; } callback.onConnected(); } }) .fail(new FailCallback() { @Override public void onRequestFailed(@NonNull BluetoothDevice device, int status) { if (callback == null) { return; } switch (status) { case REASON_DEVICE_DISCONNECTED: callback.onError(new McuMgrException("Device has disconnected")); break; case REASON_DEVICE_NOT_SUPPORTED: callback.onError(new McuMgrException("Device does not support SMP Service")); break; case REASON_REQUEST_FAILED: // This could be thrown only if the manager was requested to connect for // a second time and to a different device than the one that's already // connected. This may not happen here. callback.onError(new McuMgrException("Other device already connected")); break; case REASON_BLUETOOTH_DISABLED: callback.onError(new McuMgrException("Bluetooth adapter disabled")); break; default: callback.onError(new McuMgrException(GattError.parseConnectionError(status))); break; } } }) .enqueue(); } @Override public void release() { cancelQueue(); disconnect().enqueue(); } /** * Requests the given connection priority. On Android, the connection priority is the * equivalent of connection parameters. Acceptable values are: * <ol> * <li>{@link BluetoothGatt#CONNECTION_PRIORITY_HIGH} * - Interval: 11.25 -15 ms, latency: 0, supervision timeout: 20 sec,</li> * <li>{@link BluetoothGatt#CONNECTION_PRIORITY_BALANCED} * - Interval: 30 - 50 ms, latency: 0, supervision timeout: 20 sec,</li> * <li>{@link BluetoothGatt#CONNECTION_PRIORITY_LOW_POWER} * - Interval: 100 - 125 ms, latency: 2, supervision timeout: 20 sec.</li> * </ol> * Calling this method with priority {@link BluetoothGatt#CONNECTION_PRIORITY_HIGH} may * improve file transfer speed. * <p> * Similarly to {@link #send(byte[], Class)}, this method will connect automatically * to the device if not connected. * * @param priority one of: {@link BluetoothGatt#CONNECTION_PRIORITY_HIGH}, * {@link BluetoothGatt#CONNECTION_PRIORITY_BALANCED}, * {@link BluetoothGatt#CONNECTION_PRIORITY_LOW_POWER}. */ public void requestConnPriority(@ConnectionPriority final int priority) { connect(mDevice).done(new SuccessCallback() { @Override public void onRequestCompleted(@NonNull BluetoothDevice device) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { McuMgrBleTransport.super.requestConnectionPriority(priority).enqueue(); } // else ignore... :( } }) .retry(3, 100) .enqueue(); } //******************************************************************************************* // Ble Manager Callbacks //******************************************************************************************* private class McuMgrGattCallback extends BleManagerGattCallback { // Determines whether the device supports the SMP Service @Override protected boolean isRequiredServiceSupported(@NonNull BluetoothGatt gatt) { mSmpService = gatt.getService(SMP_SERVICE_UUID); if (mSmpService == null) { LOG.error("Device does not support SMP service"); return false; } mSmpCharacteristic = mSmpService.getCharacteristic(SMP_CHAR_UUID); if (mSmpCharacteristic == null) { LOG.error("Device does not support SMP characteristic"); return false; } else { final int rxProperties = mSmpCharacteristic.getProperties(); boolean write = (rxProperties & BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) > 0; boolean notify = (rxProperties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) > 0; if (!write || !notify) { LOG.error("SMP characteristic does not support either write ({}) or notify ({})", write, notify); return false; } } return true; } // Called once the connection has been established and services discovered. This method // adds a queue of requests necessary to set up the SMP service to begin writing // commands and receiving responses. Once these actions have completed onDeviceReady is // called. @Override protected void initialize() { requestMtu(515) .with(new MtuCallback() { @Override public void onMtuChanged(@NonNull final BluetoothDevice device, final int mtu) { mMaxPacketLength = Math.max(mtu - 3, mMaxPacketLength); } }) .fail(new FailCallback() { @Override public void onRequestFailed(@NonNull final BluetoothDevice device, final int status) { mMaxPacketLength = Math.max(getMtu() - 3, mMaxPacketLength); } }).enqueue(); enableNotifications(mSmpCharacteristic).enqueue(); } // Called when the device has disconnected. This method nulls the services and // characteristic variables. @Override protected void onDeviceDisconnected() { mSmpService = null; mSmpCharacteristic = null; runOnCallbackThread(new Runnable() { @Override public void run() { notifyDisconnected(); } }); } } //******************************************************************************************* // Manager Connection Observers //******************************************************************************************* private final List<ConnectionObserver> mConnectionObservers = new LinkedList<>(); @Override public synchronized void addObserver(@NonNull final ConnectionObserver observer) { mConnectionObservers.add(observer); } @Override public synchronized void removeObserver(@NonNull final ConnectionObserver observer) { mConnectionObservers.remove(observer); } private synchronized void notifyConnected() { for (ConnectionObserver o : mConnectionObservers) { o.onConnected(); } } private synchronized void notifyDisconnected() { for (ConnectionObserver o : mConnectionObservers) { o.onDisconnected(); } } }