diff --git a/README.md b/README.md index fe4c52d..42a0c4b 100644 --- a/README.md +++ b/README.md @@ -29,29 +29,29 @@ where `$version` is the latest published version in Jitpack [![](https://jitpack The library consists of 5 core classes and corresponding callback abstract classes: 1. `BluetoothCentralManager`, for scanning and connecting peripherals 2. `BluetoothPeripheral`, for all peripheral related methods -3. `BluetoothPeripheralManager`, and it's companion abstract class `BluetoothPeripheralManagerCallback` +3. `BluetoothPeripheralManager`, and its companion abstract class `BluetoothPeripheralManagerCallback` 4. `BluetoothCentral` 5. `BluetoothBytesParser` The `BluetoothCentralManager` class is used to scan for devices and manage connections. The `BluetoothPeripheral` class is a replacement for the standard Android `BluetoothDevice` and `BluetoothGatt` classes. It wraps all GATT related peripheral functionality. -The `BluetoothPeripheralManager` class is used to create your own peripheral running on an Android phone. You can add service, control advertising and deal with requests from remote centrals, represented by the `BluetoothCentral` class. For more about creating your own peripherals see the separate guide: [creating your own peripheral](SERVER.md) +The `BluetoothPeripheralManager` class is used to create your own peripheral running on an Android phone. You can add service, control advertising, and deal with requests from remote centrals, represented by the `BluetoothCentral` class. For more about creating your own peripherals see the separate guide: [creating your own peripheral](SERVER.md) -The `BluetoothBytesParser` class is a utility class that makes parsing byte arrays easy. You can also use it construct your own byte arrays by adding integers, floats or strings. +The `BluetoothBytesParser` class is a utility class that makes parsing byte arrays easy. You can also use it to construct your own byte arrays by adding integers, floats, or strings. ## Scanning The `BluetoothCentralManager` class has several differrent scanning methods: ```kotlin -fun scanForPeripherals() -fun scanForPeripheralsWithServices(serviceUUIDs: Array) -fun scanForPeripheralsWithNames(peripheralNames: Array) -fun scanForPeripheralsWithAddresses(peripheralAddresses: Array) -fun scanForPeripheralsUsingFilters(filters: List) +fun scanForPeripherals(resultCallback: (BluetoothPeripheral, ScanResult) -> Unit, scanError: (ScanFailure) -> Unit ) +fun scanForPeripheralsWithServices(serviceUUIDs: Array, resultCallback: (BluetoothPeripheral, ScanResult) -> Unit, scanError: (ScanFailure) -> Unit) +fun scanForPeripheralsWithNames(peripheralNames: Array, resultCallback: (BluetoothPeripheral, ScanResult) -> Unit, scanError: (ScanFailure) -> Unit) +fun scanForPeripheralsWithAddresses(peripheralAddresses: Array, resultCallback: (BluetoothPeripheral, ScanResult) -> Unit, scanError: (ScanFailure) -> Unit) +fun scanForPeripheralsUsingFilters(filters: List,resultCallback: (BluetoothPeripheral, ScanResult) -> Unit, scanError: (ScanFailure) -> Unit) ``` -They all work in the same way and take an array of either service UUIDs, peripheral names or mac addresses. When a peripheral is found your callback lambda will be called with the `BluetoothPeripheral` object and a `ScanResult` object that contains the scan details. The method `scanForPeripheralsUsingFilters` is for scanning using your own list of filters. See [Android documentation](https://developer.android.com/reference/android/bluetooth/le/ScanFilter) for more info on the use of `ScanFilter`. +They all work in the same way and take an array of either service UUIDs, peripheral names, or mac addresses. When a peripheral is found your callback lambda will be called with the `BluetoothPeripheral` object and a `ScanResult` object that contains the scan details. The method `scanForPeripheralsUsingFilters` is for scanning using your own list of filters. See [Android documentation](https://developer.android.com/reference/android/bluetooth/le/ScanFilter) for more info on the use of `ScanFilter`. A second lambda is used to deliver any scan failures. So in order to setup a scan for a device with the Bloodpressure service or HeartRate service, you do: @@ -60,11 +60,13 @@ So in order to setup a scan for a device with the Bloodpressure service or Heart val BLP_SERVICE_UUID = UUID.fromString("00001810-0000-1000-8000-00805f9b34fb") val HRS_SERVICE_UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb") -central.scanForPeripheralsWithServices(arrayOf(BLP_SERVICE_UUID, HRS_SERVICE_UUID)) { peripheral, scanResult -> - Timber.i("Found peripheral '${peripheral.name}' with RSSI ${scanResult.rssi}") - central.stopScan() - connectPeripheral(peripheral) -} +central.scanForPeripheralsWithServices(arrayOf(BLP_SERVICE_UUID, HRS_SERVICE_UUID) + { peripheral, scanResult -> + Timber.i("Found peripheral '${peripheral.name}' with RSSI ${scanResult.rssi}") + central.stopScan() + connectPeripheral(peripheral) + }, + { scanFailure -> Timber.e("scan failed with reason $scanFailure") }) ``` The scanning functions are not suspending functions and simply use a lambda function to receive the results. @@ -82,7 +84,7 @@ fun autoConnectPeripheral(peripheral: BluetoothPeripheral) fun autoConnectPeripheralsBatch(batch: Set) ``` -The method `connectPeripheral` is a **suspending function** will try to immediately connect to a device that has already been found using a scan. This method will time out after 30 seconds or less, depending on the device manufacturer, and a `ConnectionFailedException` will be thrown. Note that there can be **only 1 outstanding** `connectPeripheral`. So if it is called multiple times only 1 will succeed. +The method `connectPeripheral` is a **suspending function** that will try to immediately connect to a device that has already been found using a scan. This method will time out after 30 seconds or less, depending on the device manufacturer, and a `ConnectionFailedException` will be thrown. Note that there can be **only 1 outstanding** `connectPeripheral`. So if it is called multiple times only 1 will succeed. ```kotlin scope.launch { @@ -96,7 +98,7 @@ scope.launch { The method `autoConnectPeripheral` will **not suspend** and is for re-connecting to known devices for which you already know the device's mac address. The BLE stack will automatically connect to the device when it sees it in its internal scan. Therefore, it may take longer to connect to a device but this call will never time out! So you can issue the autoConnect command and the device will be connected whenever it is found. This call will **also work** when the device is not cached by the Android stack, as BLESSED takes care of it! In contrary to `connectPeripheral`, there can be multiple outstanding `autoConnectPeripheral` requests. -The method `autoConnectPeripheralsBatch` is for re-connecting to multiple peripherals in one go. Since the normal `autoConnectPeripheral` may involve scanning, if peripherals are uncached, it is not suitable for calling very fast after each other, since it may trigger scanner limitations of Android. So use `autoConnectPeripheralsBatch` if the want to re-connect to many known peripherals. +The method `autoConnectPeripheralsBatch` is for re-connecting to multiple peripherals in one go. Since the normal `autoConnectPeripheral` may involve scanning, if peripherals are uncached, it is not suitable for calling very fast after each other, since it may trigger scanner limitations of Android. So use `autoConnectPeripheralsBatch` if you want to re-connect to many known peripherals. If you know the mac address of your peripheral you can obtain a `BluetoothPeripheral` object using: ```kotlin @@ -114,7 +116,7 @@ To disconnect or to cancel an outstanding `connectPeripheral()` or `autoConnectP ```kotlin suspend fun cancelConnection(peripheral: BluetoothPeripheral): Unit ``` -The function will suspend untill the peripheral is disconnected. +The function will suspend until the peripheral is disconnected. ## Service discovery @@ -138,7 +140,7 @@ suspend fun readDescriptor(descriptor: BluetoothGattDescriptor): ByteArray suspend fun writeDescriptor(descriptor: BluetoothGattDescriptor, value: ByteArray): ByteArray ``` -All methods are **suspending** and will return the result of the operation. The method `readCharacteristic` will return the ByteArray that has been read. It will throw `IllegalArgumentException` if the characteristic you provide is not readable, and it will throw `GattException` if the read was not succesful. +All methods are **suspending** and will return the result of the operation. The method `readCharacteristic` will return the ByteArray that has been read. It will throw `IllegalArgumentException` if the characteristic you provide is not readable, and it will throw `GattException` if the read was not successful. If you want to write to a characteristic, you need to provide a `value` and a `writeType`. The `writeType` is usually `WITH_RESPONSE` or `WITHOUT_RESPONSE`. If the write type you specify is not supported by the characteristic it will throw `IllegalArgumentException`. The method will return the bytes that were written or an empty byte array in case something went wrong. @@ -157,7 +159,7 @@ val model = peripheral.readCharacteristic(DIS_SERVICE_UUID, MODEL_NUMBER_CHARACT Timber.i("Received: $model") ``` -Note that there are also some extension like `asString()` and `asUInt8()` to quickly turn byte arrays in Strings or UInt8s. +Note that there are also some extension methods like `asString()` and `asUInt8()` to quickly turn byte arrays in Strings or UInt8s. ## Turning notifications on/off @@ -182,7 +184,7 @@ peripheral.observeBondState { Timber.i("Bond state is $it") } ``` -In most cases, the peripheral will initiate bonding either at the time of connection, or when trying to read/write protected characteristics. However, if you want you can also initiate bonding yourself by calling `createBond` on a peripheral. There are two ways to do this: +In most cases, the peripheral will initiate bonding either at the time of connection or when trying to read/write protected characteristics. However, if you want you can also initiate bonding yourself by calling `createBond` on a peripheral. There are two ways to do this: * Calling `createBond` when not yet connected to a peripheral. In this case, a connection is made and bonding is requested. * Calling `createBond` when already connected to a peripheral. In this case, only the bond is created. @@ -243,7 +245,7 @@ It will return the current Phy ## Example application -An example application is provided in the repo. It shows how to connect to Blood Pressure meters, Heart Rate monitors, Weight scales, Glucose Meters, Pulse Oximeters and Thermometers, read the data and show it on screen. It only works with peripherals that use the Bluetooth SIG services. Working peripherals include: +An example application is provided in the repo. It shows how to connect to Blood Pressure meters, Heart Rate monitors, Weight scales, Glucose Meters, Pulse Oximeters, and Thermometers, read the data, and show it on screen. It only works with peripherals that use the Bluetooth SIG services. Working peripherals include: * Beurer FT95 thermometer * GRX Thermometer (TD-1241) diff --git a/app/build.gradle b/app/build.gradle index 43c16c9..9cf010b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,12 +2,12 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' android { - compileSdkVersion 31 + compileSdkVersion 33 defaultConfig { applicationId "com.welie.blessedexample" minSdkVersion 26 - targetSdkVersion 31 + targetSdkVersion 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -31,15 +31,14 @@ android { dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation 'androidx.appcompat:appcompat:1.3.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.1' + implementation 'androidx.appcompat:appcompat:1.5.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.jakewharton.timber:timber:5.0.1' - implementation "androidx.core:core-ktx:1.7.0" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1-native-mt" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1" + implementation "androidx.core:core-ktx:1.8.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1" - testImplementation 'junit:junit:4.13.2' implementation project(':blessed') } \ No newline at end of file diff --git a/app/src/main/java/com/welie/blessedexample/BloodPressureMeasurementStatus.kt b/app/src/main/java/com/welie/blessedexample/BloodPressureMeasurementStatus.kt index b5659b8..c7b0682 100644 --- a/app/src/main/java/com/welie/blessedexample/BloodPressureMeasurementStatus.kt +++ b/app/src/main/java/com/welie/blessedexample/BloodPressureMeasurementStatus.kt @@ -1,7 +1,3 @@ -/* - * Copyright (c) Koninklijke Philips N.V. 2020. - * All rights reserved. - */ package com.welie.blessedexample class BloodPressureMeasurementStatus internal constructor(measurementStatus: Int) { diff --git a/app/src/main/java/com/welie/blessedexample/BluetoothHandler.kt b/app/src/main/java/com/welie/blessedexample/BluetoothHandler.kt index 79aeff9..eb6ae9d 100644 --- a/app/src/main/java/com/welie/blessedexample/BluetoothHandler.kt +++ b/app/src/main/java/com/welie/blessedexample/BluetoothHandler.kt @@ -1,5 +1,6 @@ package com.welie.blessedexample +import android.bluetooth.BluetoothAdapter import android.content.Context import com.welie.blessed.* import kotlinx.coroutines.* @@ -22,7 +23,7 @@ internal class BluetoothHandler private constructor(context: Context) { val weightChannel = Channel(UNLIMITED) private fun handlePeripheral(peripheral: BluetoothPeripheral) { - scope.launch(Dispatchers.IO) { + scope.launch { try { val mtu = peripheral.requestMtu(185) Timber.i("MTU is $mtu") @@ -43,12 +44,12 @@ internal class BluetoothHandler private constructor(context: Context) { val batteryLevel = peripheral.readCharacteristic(BTS_SERVICE_UUID, BATTERY_LEVEL_CHARACTERISTIC_UUID).asUInt8() Timber.i("Battery level: $batteryLevel") - // Turn on notifications for Current Time Service and write it if possible + // Write Current Time if possible peripheral.getCharacteristic(CTS_SERVICE_UUID, CURRENT_TIME_CHARACTERISTIC_UUID)?.let { // If it has the write property we write the current time if (it.supportsWritingWithResponse()) { // Write the current time unless it is an Omron device - if (!peripheral.name.contains("BLEsmart_")) { + if (!peripheral.name.contains("BLEsmart_", true)) { val parser = BluetoothBytesParser(ByteOrder.LITTLE_ENDIAN) parser.setCurrentTime(Calendar.getInstance()) peripheral.writeCharacteristic(it, parser.value, WriteType.WITH_RESPONSE) @@ -75,7 +76,6 @@ internal class BluetoothHandler private constructor(context: Context) { } } - private suspend fun writeContourClock(peripheral: BluetoothPeripheral) { val calendar = Calendar.getInstance() val offsetInMinutes = calendar.timeZone.rawOffset / 60000 @@ -101,7 +101,7 @@ internal class BluetoothHandler private constructor(context: Context) { // Deal with Omron devices where we can only write currentTime under specific conditions val name = peripheral.name - if (name.contains("BLEsmart_")) { + if (name.contains("BLEsmart_", true)) { peripheral.getCharacteristic(BLP_SERVICE_UUID, BLP_MEASUREMENT_CHARACTERISTIC_UUID)?.let { val isNotifying = peripheral.isNotifying(it) if (isNotifying) currentTimeCounter++ @@ -208,11 +208,13 @@ internal class BluetoothHandler private constructor(context: Context) { } private fun startScanning() { - central.scanForPeripheralsWithServices(supportedServices) { peripheral, scanResult -> - Timber.i("Found peripheral '${peripheral.name}' with RSSI ${scanResult.rssi}") - central.stopScan() - connectPeripheral(peripheral) - } + central.scanForPeripheralsWithServices(supportedServices, + { peripheral, scanResult -> + Timber.i("Found peripheral '${peripheral.name}' with RSSI ${scanResult.rssi}") + central.stopScan() + connectPeripheral(peripheral) + }, + { scanFailure -> Timber.e("scan failed with reason $scanFailure") }) } private fun connectPeripheral(peripheral: BluetoothPeripheral) { @@ -232,7 +234,7 @@ internal class BluetoothHandler private constructor(context: Context) { companion object { // UUIDs for the Blood Pressure service (BLP) private val BLP_SERVICE_UUID: UUID = UUID.fromString("00001810-0000-1000-8000-00805f9b34fb") - private val BLP_MEASUREMENT_CHARACTERISTIC_UUID : UUID = UUID.fromString("00002A35-0000-1000-8000-00805f9b34fb") + private val BLP_MEASUREMENT_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A35-0000-1000-8000-00805f9b34fb") // UUIDs for the Health Thermometer service (HTS) private val HTS_SERVICE_UUID = UUID.fromString("00001809-0000-1000-8000-00805f9b34fb") @@ -295,18 +297,28 @@ internal class BluetoothHandler private constructor(context: Context) { central = BluetoothCentralManager(context) central.observeConnectionState { peripheral, state -> - Timber.i("Peripheral ${peripheral.name} has $state") + Timber.i("Peripheral '${peripheral.name}' is $state") when (state) { ConnectionState.CONNECTED -> handlePeripheral(peripheral) ConnectionState.DISCONNECTED -> scope.launch { delay(15000) - central.autoConnectPeripheral(peripheral) + + // Check if this peripheral should still be auto connected + if (central.getPeripheral(peripheral.address).getState() == ConnectionState.DISCONNECTED) { + central.autoConnectPeripheral(peripheral) + } } else -> { } } } + central.observeAdapterState { state -> + when (state) { + BluetoothAdapter.STATE_ON -> startScanning() + } + } + startScanning() } } \ No newline at end of file diff --git a/app/src/main/java/com/welie/blessedexample/MainActivity.kt b/app/src/main/java/com/welie/blessedexample/MainActivity.kt index 666bda2..5fb5284 100644 --- a/app/src/main/java/com/welie/blessedexample/MainActivity.kt +++ b/app/src/main/java/com/welie/blessedexample/MainActivity.kt @@ -226,7 +226,7 @@ class MainActivity : AppCompatActivity() { if (missingPermissions.isNotEmpty()) { requestPermissions(missingPermissions, ACCESS_LOCATION_REQUEST) } else { - permissionsGranted() + checkIfLocationIsNeeded() } } @@ -250,10 +250,10 @@ class MainActivity : AppCompatActivity() { } else arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION) } - private fun permissionsGranted() { - // Check if Location services are on because they are required to make scanning work for SDK < 31 + private fun checkIfLocationIsNeeded() { val targetSdkVersion = applicationInfo.targetSdkVersion if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S && targetSdkVersion < Build.VERSION_CODES.S) { + // Check if Location services are on because they are required to make scanning work for SDK < 31 if (checkLocationServices()) { initBluetoothHandler() } @@ -313,7 +313,7 @@ class MainActivity : AppCompatActivity() { } } if (allGranted) { - permissionsGranted() + checkIfLocationIsNeeded() } else { AlertDialog.Builder(this@MainActivity) .setTitle("Location permission is required for scanning Bluetooth peripherals") diff --git a/app/src/main/java/com/welie/blessedexample/SensorContactFeature.kt b/app/src/main/java/com/welie/blessedexample/SensorContactFeature.kt index dca09df..4afbacf 100644 --- a/app/src/main/java/com/welie/blessedexample/SensorContactFeature.kt +++ b/app/src/main/java/com/welie/blessedexample/SensorContactFeature.kt @@ -1,7 +1,3 @@ -/* - * Copyright (c) Koninklijke Philips N.V., 2017. - * All rights reserved. - */ package com.welie.blessedexample /** diff --git a/app/src/test/java/com/welie/blessedexample/ExampleUnitTest.kt b/app/src/test/java/com/welie/blessedexample/ExampleUnitTest.kt deleted file mode 100644 index 6447c9b..0000000 --- a/app/src/test/java/com/welie/blessedexample/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.welie.blessedexample - -import org.junit.Assert -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see [Testing documentation](http://d.android.com/tools/testing) - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - Assert.assertEquals(4, (2 + 2).toLong()) - } -} \ No newline at end of file diff --git a/blessed/build.gradle b/blessed/build.gradle index a36b7c4..90b819f 100644 --- a/blessed/build.gradle +++ b/blessed/build.gradle @@ -3,13 +3,11 @@ apply plugin: 'kotlin-android' apply plugin: 'maven-publish' android { - compileSdkVersion 31 + compileSdkVersion 33 defaultConfig { minSdkVersion 26 - targetSdkVersion 31 - versionCode 1 - versionName "1.0" + targetSdkVersion 33 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -32,15 +30,16 @@ android { } dependencies { - implementation "androidx.core:core-ktx:1.7.0" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1-native-mt" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1" + implementation "androidx.core:core-ktx:1.9.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" implementation 'com.jakewharton.timber:timber:5.0.1' testImplementation 'junit:junit:4.13.2' testImplementation "org.robolectric:robolectric:4.5.1" testImplementation "org.mockito:mockito-core:3.8.0" - testImplementation 'androidx.test:core:1.4.0' + testImplementation 'androidx.test:core:1.5.0' + testImplementation "io.mockk:mockk:1.12.5" } afterEvaluate { diff --git a/blessed/src/main/java/com/welie/blessed/BluetoothBytesParser.kt b/blessed/src/main/java/com/welie/blessed/BluetoothBytesParser.kt index cc23258..ec14cd1 100644 --- a/blessed/src/main/java/com/welie/blessed/BluetoothBytesParser.kt +++ b/blessed/src/main/java/com/welie/blessed/BluetoothBytesParser.kt @@ -26,6 +26,7 @@ import java.nio.ByteOrder import java.nio.ByteOrder.LITTLE_ENDIAN import java.nio.charset.StandardCharsets import java.util.* +import kotlin.math.pow class BluetoothBytesParser ( var value: ByteArray, @@ -49,6 +50,71 @@ class BluetoothBytesParser ( */ constructor(value: ByteArray, byteOrder: ByteOrder) : this(value, 0, byteOrder) + fun getUInt8() : UInt { + val result = value[offset].toUInt() + offset += 1 + return result + } + + fun getInt8() : Int { + val result = value[offset].toInt() + offset += 1 + return result + } + + fun getUInt16() : UInt { + val result = value.getUInt16(offset.toUInt(), byteOrder) + offset += 2 + return result + } + + fun getInt16() : Int { + val result = value.getInt16(offset.toUInt(), byteOrder) + offset += 2 + return result + } + + fun getUInt24() : UInt { + val result = value.getUInt24(offset.toUInt(), byteOrder) + offset += 3 + return result + } + + fun getInt24() : Int { + val result = value.getInt24(offset.toUInt(), byteOrder) + offset += 3 + return result + } + + fun getUInt32() : UInt { + val result = value.getUInt32(offset.toUInt(), byteOrder) + offset += 4 + return result + } + + fun getInt32() : Int { + val result = value.getInt32(offset.toUInt(), byteOrder) + offset += 4 + return result + } + + fun getFloat() : Double { + val result = value.getFloat(offset.toUInt(), byteOrder) + offset += 4 + return result + } + + fun getSFloat() : Double { + val result = value.getSFloat(offset.toUInt(), byteOrder) + offset += 2 + return result + } + + fun setUInt16(uint16: UInt) { + val uint16array = byteArrayOf(uint16, 2u, LITTLE_ENDIAN) + value += uint16array + } + /** * Return an Integer value of the specified type. This operation will automatically advance the internal offset to the next position. * @@ -498,6 +564,7 @@ class BluetoothBytesParser ( } else -> return false } + this.offset += getTypeLen(formatType) return true } @@ -509,7 +576,7 @@ class BluetoothBytesParser ( * @return true if the locally stored value has been set */ fun setFloatValue(value: Float, precision: Int): Boolean { - val mantissa = (value * Math.pow(10.0, precision.toDouble())).toFloat() + val mantissa = (value * 10.toDouble().pow(precision.toDouble())).toFloat() return setFloatValue(mantissa.toInt(), -precision, FORMAT_FLOAT, offset) } @@ -552,7 +619,7 @@ class BluetoothBytesParser ( * @return flase if the calendar object was null, otherwise true */ fun setCurrentTime(calendar: Calendar): Boolean { - value = ByteArray(10) + prepareArray(10) setDateTime(calendar) value[7] = ((calendar[Calendar.DAY_OF_WEEK] + 5) % 7 + 1).toByte() value[8] = (calendar[Calendar.MILLISECOND] * 256 / 1000).toByte() @@ -567,7 +634,7 @@ class BluetoothBytesParser ( * @return flase if the calendar object was null, otherwise true */ fun setDateTime(calendar: Calendar): Boolean { - value = ByteArray(7) + prepareArray(7) value[0] = calendar[Calendar.YEAR].toByte() value[1] = (calendar[Calendar.YEAR] shr 8).toByte() value[2] = (calendar[Calendar.MONTH] + 1).toByte() @@ -611,16 +678,14 @@ class BluetoothBytesParser ( * Convert signed bytes to a 16-bit short float value. */ private fun bytesToFloat(b0: Byte, b1: Byte): Float { - val mantissa = unsignedToSigned( - unsignedByteToInt(b0) - + (unsignedByteToInt(b1) and 0x0F shl 8), 12 - ) + val mantissa = unsignedToSigned(unsignedByteToInt(b0) + + (unsignedByteToInt(b1) and 0x0F shl 8), 12) val exponent = unsignedToSigned(unsignedByteToInt(b1) shr 4, 4) - return (mantissa * Math.pow(10.0, exponent.toDouble())).toFloat() + return (mantissa * 10.toDouble().pow( exponent.toDouble())).toFloat() } /** - * Convert signed bytes to a 32-bit short float value. + * Convert signed bytes to a 32-bit float value. */ private fun bytesToFloat(b0: Byte, b1: Byte, b2: Byte, b3: Byte): Float { val mantissa = unsignedToSigned( @@ -628,7 +693,7 @@ class BluetoothBytesParser ( + (unsignedByteToInt(b1) shl 8) + (unsignedByteToInt(b2) shl 16), 24 ) - return (mantissa * Math.pow(10.0, b3.toDouble())).toFloat() + return (mantissa * 10.toDouble().pow(b3.toDouble())).toFloat() } /** diff --git a/blessed/src/main/java/com/welie/blessed/BluetoothCentral.kt b/blessed/src/main/java/com/welie/blessed/BluetoothCentral.kt index 304ceb3..c1d1993 100644 --- a/blessed/src/main/java/com/welie/blessed/BluetoothCentral.kt +++ b/blessed/src/main/java/com/welie/blessed/BluetoothCentral.kt @@ -31,6 +31,7 @@ class BluetoothCentral internal constructor(address: String, name: String?) { val address: String private val name: String? var currentMtu = 23 + fun getName(): String { return name ?: "" } diff --git a/blessed/src/main/java/com/welie/blessed/BluetoothCentralManager.kt b/blessed/src/main/java/com/welie/blessed/BluetoothCentralManager.kt index 53e83a8..9065d7e 100644 --- a/blessed/src/main/java/com/welie/blessed/BluetoothCentralManager.kt +++ b/blessed/src/main/java/com/welie/blessed/BluetoothCentralManager.kt @@ -23,6 +23,7 @@ package com.welie.blessed import android.Manifest +import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager @@ -50,6 +51,8 @@ import kotlin.coroutines.suspendCoroutine /** * Central Manager class to scan and connect with bluetooth peripherals. */ +@SuppressLint("MissingPermission") +@Suppress("unused") class BluetoothCentralManager(private val context: Context) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val bluetoothAdapter: BluetoothAdapter @@ -70,10 +73,11 @@ class BluetoothCentralManager(private val context: Context) { private var scanSettings: ScanSettings private val autoConnectScanSettings: ScanSettings private val connectionRetries: MutableMap = ConcurrentHashMap() - private var expectingBluetoothOffDisconnects = false private var disconnectRunnable: Runnable? = null private val pinCodes: MutableMap = ConcurrentHashMap() private var currentResultCallback : ((BluetoothPeripheral, ScanResult) -> Unit)? = null + private var currentScanErrorCallback : ((ScanFailure) -> Unit)? = null + private var adapterStateCallback: (state: Int) -> Unit = {} private val scanByNameCallback: ScanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { @@ -115,9 +119,10 @@ class BluetoothCentralManager(private val context: Context) { private fun sendScanFailed(scanFailure: ScanFailure) { currentCallback = null currentFilters = null + cancelTimeoutTimer() scope.launch { Logger.e(TAG, "scan failed with error code %d (%s)", scanFailure.value, scanFailure) - // bluetoothCentralManagerCallback.onScanFailed(scanFailure) + currentScanErrorCallback?.invoke(scanFailure) } } @@ -151,6 +156,10 @@ class BluetoothCentralManager(private val context: Context) { @JvmField val internalCallback: InternalCallback = object : InternalCallback { + override fun connecting(peripheral: BluetoothPeripheral) { + scope.launch { connectionStateCallback.invoke(peripheral, ConnectionState.CONNECTING)} + } + override fun connected(peripheral: BluetoothPeripheral) { connectionRetries.remove(peripheral.address) unconnectedPeripherals.remove(peripheral.address) @@ -190,11 +199,11 @@ class BluetoothCentralManager(private val context: Context) { } } + override fun disconnecting(peripheral: BluetoothPeripheral) { + scope.launch { connectionStateCallback.invoke(peripheral, ConnectionState.DISCONNECTING)} + } + override fun disconnected(peripheral: BluetoothPeripheral, status: HciStatus) { - if (expectingBluetoothOffDisconnects) { - cancelDisconnectionTimer() - expectingBluetoothOffDisconnects = false - } connectedPeripherals.remove(peripheral.address) unconnectedPeripherals.remove(peripheral.address) scannedPeripherals.remove(peripheral.address) @@ -243,10 +252,6 @@ class BluetoothCentralManager(private val context: Context) { private fun startScan(filters: List, scanSettings: ScanSettings, scanCallback: ScanCallback) { if (bleNotReady()) return - if (isScanning) { - Logger.e(TAG, "other scan still active, stopping scan") - stopScan() - } if (bluetoothScanner == null) { bluetoothScanner = bluetoothAdapter.bluetoothLeScanner } @@ -254,7 +259,7 @@ class BluetoothCentralManager(private val context: Context) { setScanTimer() currentCallback = scanCallback currentFilters = filters - bluetoothScanner!!.startScan(filters, scanSettings, scanCallback) + bluetoothScanner?.startScan(filters, scanSettings, scanCallback) Logger.i(TAG, "scan started") } else { Logger.e(TAG, "starting scan failed") @@ -267,9 +272,11 @@ class BluetoothCentralManager(private val context: Context) { * @param serviceUUIDs an array of service UUIDs * @throws IllegalArgumentException if the array of service UUIDs is empty */ - fun scanForPeripheralsWithServices(serviceUUIDs: Array, resultCallback: (BluetoothPeripheral, ScanResult) -> Unit ) { + fun scanForPeripheralsWithServices(serviceUUIDs: Array, resultCallback: (BluetoothPeripheral, ScanResult) -> Unit, scanError: (ScanFailure) -> Unit ) { require(serviceUUIDs.isNotEmpty()) { "at least one service UUID must be supplied" } + if (isScanning) stopScan() + val filters: MutableList = ArrayList() for (serviceUUID in serviceUUIDs) { val filter = ScanFilter.Builder() @@ -279,6 +286,7 @@ class BluetoothCentralManager(private val context: Context) { } currentResultCallback = resultCallback + currentScanErrorCallback = scanError startScan(filters, scanSettings, defaultScanCallback) } @@ -291,9 +299,13 @@ class BluetoothCentralManager(private val context: Context) { * @param peripheralNames array of partial peripheral names * @throws IllegalArgumentException if the array of peripheral names is empty */ - fun scanForPeripheralsWithNames(peripheralNames: Array, resultCallback: (BluetoothPeripheral, ScanResult) -> Unit ) { + fun scanForPeripheralsWithNames(peripheralNames: Array, resultCallback: (BluetoothPeripheral, ScanResult) -> Unit, scanError: (ScanFailure) -> Unit ) { require(peripheralNames.isNotEmpty()) { "at least one peripheral name must be supplied" } + + if (isScanning) stopScan() + currentResultCallback = resultCallback + currentScanErrorCallback = scanError // Start the scanner with no filter because we'll do the filtering ourselves scanPeripheralNames = peripheralNames @@ -306,9 +318,13 @@ class BluetoothCentralManager(private val context: Context) { * @param peripheralAddresses array of peripheral mac addresses to scan for * @throws IllegalArgumentException if the array of addresses is empty */ - fun scanForPeripheralsWithAddresses(peripheralAddresses: Array, resultCallback: (BluetoothPeripheral, ScanResult) -> Unit ) { + fun scanForPeripheralsWithAddresses(peripheralAddresses: Array, resultCallback: (BluetoothPeripheral, ScanResult) -> Unit, scanError: (ScanFailure) -> Unit ) { require(peripheralAddresses.isNotEmpty()) { "at least one peripheral address must be supplied" } + + if (isScanning) stopScan() + currentResultCallback = resultCallback + currentScanErrorCallback = scanError val filters: MutableList = ArrayList() for (address in peripheralAddresses) { @@ -330,17 +346,24 @@ class BluetoothCentralManager(private val context: Context) { * @param filters A list of ScanFilters * @throws IllegalArgumentException if the list of filters is empty */ - fun scanForPeripheralsUsingFilters(filters: List,resultCallback: (BluetoothPeripheral, ScanResult) -> Unit ) { + fun scanForPeripheralsUsingFilters(filters: List,resultCallback: (BluetoothPeripheral, ScanResult) -> Unit, scanError: (ScanFailure) -> Unit ) { require(filters.isNotEmpty()) { "at least one scan filter must be supplied" } + + if (isScanning) stopScan() + currentResultCallback = resultCallback + currentScanErrorCallback = scanError startScan(filters, scanSettings, defaultScanCallback) } /** * Scan for any peripheral that is advertising. */ - fun scanForPeripherals(resultCallback: (BluetoothPeripheral, ScanResult) -> Unit ) { + fun scanForPeripherals(resultCallback: (BluetoothPeripheral, ScanResult) -> Unit, scanError: (ScanFailure) -> Unit ) { + if (isScanning) stopScan() + currentResultCallback = resultCallback + currentScanErrorCallback = scanError startScan(emptyList(), scanSettings, defaultScanCallback) } @@ -393,7 +416,7 @@ class BluetoothCentralManager(private val context: Context) { Logger.i(TAG, "scan stopped") } } else { - Logger.i(TAG, "no scan to stop because no scan is running") + Logger.d(TAG, "no scan to stop because no scan is running") } currentCallback = null currentFilters = null @@ -520,7 +543,7 @@ class BluetoothCentralManager(private val context: Context) { }) } - fun cancelConnection(peripheral: BluetoothPeripheral, resultCentralManagerCallback: BluetoothCentralManagerCallback) { + private fun cancelConnection(peripheral: BluetoothPeripheral, resultCentralManagerCallback: BluetoothCentralManagerCallback) { // First check if we are doing a reconnection scan for this peripheral val peripheralAddress = peripheral.address if (reconnectPeripheralAddresses.contains(peripheralAddress)) { @@ -590,11 +613,11 @@ class BluetoothCentralManager(private val context: Context) { throw IllegalArgumentException(message) } return if (connectedPeripherals.containsKey(peripheralAddress)) { - Objects.requireNonNull(connectedPeripherals[peripheralAddress])!! + requireNotNull(connectedPeripherals[peripheralAddress]) } else if (unconnectedPeripherals.containsKey(peripheralAddress)) { - Objects.requireNonNull(unconnectedPeripherals[peripheralAddress])!! + requireNotNull(unconnectedPeripherals[peripheralAddress]) } else if (scannedPeripherals.containsKey(peripheralAddress)) { - Objects.requireNonNull(scannedPeripherals[peripheralAddress])!! + requireNotNull(scannedPeripherals[peripheralAddress]) } else { val peripheral = BluetoothPeripheral(context, bluetoothAdapter.getRemoteDevice(peripheralAddress), internalCallback) scannedPeripherals[peripheralAddress] = peripheral @@ -778,9 +801,8 @@ class BluetoothCentralManager(private val context: Context) { // Try to remove the bond return if (peripheralToUnBond != null) { try { - // TODO FIX THIS, automatic conversion gone wrong - val method = peripheralToUnBond.javaClass.getMethod("removeBond", null) - val result = method.invoke(peripheralToUnBond, null as Array?) as Boolean + val method = peripheralToUnBond.javaClass.getMethod("removeBond") + val result = method.invoke(peripheralToUnBond) as Boolean if (result) { Logger.i(TAG, "Succesfully removed bond for '%s'", peripheralToUnBond.name) } @@ -835,37 +857,17 @@ class BluetoothCentralManager(private val context: Context) { reconnectPeripheralAddresses.clear() } - /** - * Timer to determine if manual disconnection in case of bluetooth off is needed - */ - private fun startDisconnectionTimer() { - cancelDisconnectionTimer() - disconnectRunnable = Runnable { - Logger.e(TAG, "bluetooth turned off but no automatic disconnects happening, so doing it ourselves") - cancelAllConnectionsWhenBluetoothOff() - disconnectRunnable = null - } - mainHandler.postDelayed(disconnectRunnable!!, 1000) + fun observeAdapterState(callback: (state: Int) -> Unit) { + this.adapterStateCallback = callback } - /** - * Cancel timer for bluetooth off disconnects - */ - private fun cancelDisconnectionTimer() { - if (disconnectRunnable != null) { - mainHandler.removeCallbacks(disconnectRunnable!!) - disconnectRunnable = null - } - } - - @JvmField - val adapterStateReceiver: BroadcastReceiver = object : BroadcastReceiver() { + private val adapterStateReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val action = intent.action ?: return if (action == BluetoothAdapter.ACTION_STATE_CHANGED) { val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) - // scope.launch { bluetoothCentralManagerCallback.onBluetoothAdapterStateChanged(state) } handleAdapterState(state) + adapterStateCallback.invoke(state) } } } @@ -874,15 +876,32 @@ class BluetoothCentralManager(private val context: Context) { when (state) { BluetoothAdapter.STATE_OFF -> { // Check if there are any connected peripherals or connections in progress - if (connectedPeripherals.size > 0 || unconnectedPeripherals.size > 0) { - // See if they are automatically disconnect - expectingBluetoothOffDisconnects = true - startDisconnectionTimer() + if (connectedPeripherals.isNotEmpty() || unconnectedPeripherals.isNotEmpty()) { + cancelAllConnectionsWhenBluetoothOff() } Logger.d(TAG, "bluetooth turned off") } BluetoothAdapter.STATE_TURNING_OFF -> { - expectingBluetoothOffDisconnects = true + // Try to disconnect all peripherals because Android doesn't always do that + connectedPeripherals.forEach { entry -> entry.value.cancelConnection()} + + // Stop all scans so that we are back in a clean state + if (isScanning) { + // Note that we can't call stopScan if the adapter is off + // On some phones like the Nokia 8, the adapter will be already off at this point + // So add a try/catch to handle any exceptions + try { + stopScan() + } catch (ignored: java.lang.Exception) { + } + } + + if (isAutoScanning) { + try { + stopAutoconnectScan() + } catch (ignored: java.lang.Exception) { + } + } // Stop all scans so that we are back in a clean state // Note that we can't call stopScan if the adapter is off @@ -891,24 +910,28 @@ class BluetoothCentralManager(private val context: Context) { currentCallback = null currentFilters = null autoConnectScanner = null + bluetoothScanner = null Logger.d(TAG, "bluetooth turning off") } BluetoothAdapter.STATE_ON -> { - expectingBluetoothOffDisconnects = false + // On some phones like Nokia 8, this scanner may still have an older active scan from us + // This happens when bluetooth is toggled. So make sure it is gone. + bluetoothScanner = bluetoothAdapter.bluetoothLeScanner + bluetoothScanner?.stopScan(defaultScanCallback) + Logger.d(TAG, "bluetooth turned on") } BluetoothAdapter.STATE_TURNING_ON -> { - expectingBluetoothOffDisconnects = false Logger.d(TAG, "bluetooth turning on") } } } - fun disableLogging(): Unit { + fun disableLogging() { Logger.enabled = false } - fun enableLogging(): Unit { + fun enableLogging() { Logger.enabled = false } @@ -922,20 +945,12 @@ class BluetoothCentralManager(private val context: Context) { private const val NO_VALID_PERIPHERAL_CALLBACK_SPECIFIED = "no valid peripheral callback specified" } - /** - * Construct a new BluetoothCentralManager object - * - * @param context Android application environment. - * @param bluetoothCentralManagerCallback the callback to call for updates - * @param handler Handler to use for callbacks. - */ init { val manager = context.getSystemService(BLUETOOTH_SERVICE) as BluetoothManager bluetoothAdapter = manager.adapter autoConnectScanSettings = getScanSettings(ScanMode.LOW_POWER) scanSettings = getScanSettings(ScanMode.LOW_LATENCY) - // Register for broadcasts on BluetoothAdapter state change val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) context.registerReceiver(adapterStateReceiver, filter) } diff --git a/blessed/src/main/java/com/welie/blessed/BluetoothCentralManagerCallback.kt b/blessed/src/main/java/com/welie/blessed/BluetoothCentralManagerCallback.kt index c4d2f0c..b8b8f25 100644 --- a/blessed/src/main/java/com/welie/blessed/BluetoothCentralManagerCallback.kt +++ b/blessed/src/main/java/com/welie/blessed/BluetoothCentralManagerCallback.kt @@ -27,7 +27,7 @@ import android.bluetooth.le.ScanResult /** * Callbacks for the BluetoothCentralManager class */ -abstract class BluetoothCentralManagerCallback { +internal abstract class BluetoothCentralManagerCallback { /** * Successfully connected with a peripheral. * diff --git a/blessed/src/main/java/com/welie/blessed/BluetoothPeripheral.kt b/blessed/src/main/java/com/welie/blessed/BluetoothPeripheral.kt index f7f68d5..b0f6f41 100644 --- a/blessed/src/main/java/com/welie/blessed/BluetoothPeripheral.kt +++ b/blessed/src/main/java/com/welie/blessed/BluetoothPeripheral.kt @@ -22,6 +22,7 @@ */ package com.welie.blessed +import android.annotation.SuppressLint import android.bluetooth.* import android.bluetooth.BluetoothGattDescriptor.* import android.content.BroadcastReceiver @@ -31,7 +32,6 @@ import android.content.IntentFilter import android.os.Build import android.os.SystemClock import kotlinx.coroutines.* -import java.lang.Runnable import java.util.* import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executors @@ -46,6 +46,8 @@ import kotlin.coroutines.suspendCoroutine * This class is a wrapper around the [BluetoothDevice] and [BluetoothGatt] classes. * It takes care of operation queueing, some Android bugs, and provides several convenience functions. */ +@SuppressLint("MissingPermission") +@Suppress("unused") class BluetoothPeripheral internal constructor( private val context: Context, private var device: BluetoothDevice, @@ -91,7 +93,7 @@ class BluetoothPeripheral internal constructor( */ private val bluetoothGattCallback: BluetoothGattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { - cancelConnectionTimer() + if (newState != BluetoothProfile.STATE_CONNECTING) cancelConnectionTimer() val previousState = state state = newState @@ -100,8 +102,14 @@ class BluetoothPeripheral internal constructor( when (newState) { BluetoothProfile.STATE_CONNECTED -> successfullyConnected() BluetoothProfile.STATE_DISCONNECTED -> successfullyDisconnected(previousState) - BluetoothProfile.STATE_DISCONNECTING -> Logger.d(TAG, "peripheral is disconnecting") - BluetoothProfile.STATE_CONNECTING -> Logger.d(TAG, "peripheral is connecting") + BluetoothProfile.STATE_DISCONNECTING -> { + Logger.d(TAG, "peripheral is disconnecting") + listener.disconnecting(this@BluetoothPeripheral) + } + BluetoothProfile.STATE_CONNECTING -> { + Logger.d(TAG, "peripheral is connecting") + listener.connecting(this@BluetoothPeripheral) + } else -> Logger.e(TAG, "unknown state received") } } else { @@ -139,9 +147,11 @@ class BluetoothPeripheral internal constructor( ) } + val value = currentWriteBytes + currentWriteBytes = ByteArray(0) + if (descriptor.uuid == CCC_DESCRIPTOR_UUID) { if (gattStatus == GattStatus.SUCCESS) { - val value = nonnullOf(descriptor.value) if (value.contentEquals(ENABLE_NOTIFICATION_VALUE) || value.contentEquals(ENABLE_INDICATION_VALUE) ) { @@ -152,28 +162,41 @@ class BluetoothPeripheral internal constructor( } callbackScope.launch { resultCallback.onNotificationStateUpdate(this@BluetoothPeripheral, parentCharacteristic, gattStatus) } } else { - callbackScope.launch { resultCallback.onDescriptorWrite(this@BluetoothPeripheral, currentWriteBytes, descriptor, gattStatus) } + callbackScope.launch { resultCallback.onDescriptorWrite(this@BluetoothPeripheral, value, descriptor, gattStatus) } } completedCommand() } - override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int, value: ByteArray) { val gattStatus = GattStatus.fromValue(status) if (gattStatus != GattStatus.SUCCESS) { Logger.e(TAG, "reading descriptor <%s> failed for device '%s, status '%s'", descriptor.uuid, address, gattStatus) } - val value = nonnullOf(descriptor.value) val resultCallback = currentResultCallback callbackScope.launch { resultCallback.onDescriptorRead(this@BluetoothPeripheral, value, descriptor, gattStatus) } completedCommand() } - override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { - val value = nonnullOf(characteristic.value) + @Deprecated("Deprecated in Java") + override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + if (Build.VERSION.SDK_INT < 33) { + onDescriptorRead(gatt, descriptor, status, descriptor.value) + } + } + + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { callbackScope.launch { observeMap[characteristic]?.invoke(value) } } + @Deprecated("Deprecated in Java") + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + if (Build.VERSION.SDK_INT < 33) { + onCharacteristicChanged(gatt, characteristic, characteristic.value) + } + } + + @Deprecated("Deprecated in Java") override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { val gattStatus = GattStatus.fromValue(status) if (gattStatus != GattStatus.SUCCESS) { @@ -286,10 +309,11 @@ class BluetoothPeripheral internal constructor( } } + @SuppressLint("MissingPermission") private fun discoverServices() { discoverJob = scope.launch { Logger.d(TAG, "discovering services of '%s'", name) - if (bluetoothGatt != null && bluetoothGatt!!.discoverServices()) { + if (bluetoothGatt != null && (bluetoothGatt?.discoverServices() == true)) { discoveryStarted = true } else { Logger.e(TAG, "discoverServices failed to start") @@ -405,6 +429,7 @@ class BluetoothPeripheral internal constructor( } private val pairingRequestBroadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() { + @SuppressLint("MissingPermission") override fun onReceive(context: Context, intent: Intent) { val receivedDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) ?: return if (!receivedDevice.address.equals(address, ignoreCase = true)) return @@ -435,11 +460,18 @@ class BluetoothPeripheral internal constructor( delay(DIRECT_CONNECTION_DELAY_IN_MS) Logger.d(TAG, "connect to '%s' (%s) using TRANSPORT_LE", name, address) registerBondingBroadcastReceivers() - state = BluetoothProfile.STATE_CONNECTING discoveryStarted = false - bluetoothGatt = device.connectGatt(context, false, bluetoothGattCallback, BluetoothDevice.TRANSPORT_LE) - connectTimestamp = SystemClock.elapsedRealtime() - startConnectionTimer(this@BluetoothPeripheral) + bluetoothGatt = try { + device.connectGatt(context, false, bluetoothGattCallback, BluetoothDevice.TRANSPORT_LE) + } catch (e: SecurityException) { + Logger.d(TAG, "exception") + null + } + bluetoothGatt?.let { + bluetoothGattCallback.onConnectionStateChange(it, HciStatus.SUCCESS.value, BluetoothProfile.STATE_CONNECTING) + connectTimestamp = SystemClock.elapsedRealtime() + startConnectionTimer(this@BluetoothPeripheral) + } } } else { Logger.e(TAG, "peripheral '%s' not yet disconnected, will not connect", name) @@ -457,10 +489,17 @@ class BluetoothPeripheral internal constructor( scope.launch { Logger.d(TAG, "autoConnect to '%s' (%s) using TRANSPORT_LE", name, address) registerBondingBroadcastReceivers() - state = BluetoothProfile.STATE_CONNECTING discoveryStarted = false - bluetoothGatt = device.connectGatt(context, true, bluetoothGattCallback, BluetoothDevice.TRANSPORT_LE) - connectTimestamp = SystemClock.elapsedRealtime() + bluetoothGatt = try { + device.connectGatt(context, true, bluetoothGattCallback, BluetoothDevice.TRANSPORT_LE) + } catch (e: SecurityException) { + Logger.e(TAG, "connectGatt exception") + null + } + bluetoothGatt?.let { + bluetoothGattCallback.onConnectionStateChange(it, HciStatus.SUCCESS.value, BluetoothProfile.STATE_CONNECTING) + connectTimestamp = SystemClock.elapsedRealtime() + } } } else { Logger.e(TAG, "peripheral '%s' not yet disconnected, will not connect", name) @@ -482,15 +521,17 @@ class BluetoothPeripheral internal constructor( * * @return true if bonding was started/enqueued, false if not */ + fun createBond(): Boolean { // Check if we have a Gatt object if (bluetoothGatt == null) { // No gatt object so no connection issued, do create bond immediately + registerBondingBroadcastReceivers() return device.createBond() } // Enqueue the bond command because a connection has been issued or we are already connected - val result = commandQueue.add(Runnable { + return enqueue { manuallyBonding = true if (!device.createBond()) { Logger.e(TAG, "bonding failed for %s", address) @@ -499,13 +540,7 @@ class BluetoothPeripheral internal constructor( Logger.d(TAG, "manually bonding %s", address) nrTries++ } - }) - if (result) { - nextCommand() - } else { - Logger.e(TAG, "could not enqueue bonding command") } - return result } /** @@ -537,7 +572,9 @@ class BluetoothPeripheral internal constructor( // Since we will not get a callback on onConnectionStateChange for this, we issue the disconnect ourselves scope.launch { delay(50) - bluetoothGattCallback.onConnectionStateChange(bluetoothGatt, HciStatus.SUCCESS.value, BluetoothProfile.STATE_DISCONNECTED) + bluetoothGatt?.let { + bluetoothGattCallback.onConnectionStateChange(bluetoothGatt, HciStatus.SUCCESS.value, BluetoothProfile.STATE_DISCONNECTED) + } } } else { // Cancel active connection and onConnectionStateChange will be called by Android @@ -553,10 +590,14 @@ class BluetoothPeripheral internal constructor( */ private fun disconnect() { if (state == BluetoothProfile.STATE_CONNECTED || state == BluetoothProfile.STATE_CONNECTING) { - state = BluetoothProfile.STATE_DISCONNECTING + bluetoothGatt?.let { + bluetoothGattCallback.onConnectionStateChange(it, HciStatus.SUCCESS.value, BluetoothProfile.STATE_DISCONNECTING) + } + scope.launch { if (state == BluetoothProfile.STATE_DISCONNECTING && bluetoothGatt != null) { - bluetoothGatt!!.disconnect() + bluetoothGatt?.disconnect() + Logger.i(TAG, "force disconnect '%s' (%s)", name, address) } } } else { @@ -565,7 +606,6 @@ class BluetoothPeripheral internal constructor( } fun disconnectWhenBluetoothOff() { - bluetoothGatt = null completeDisconnect(true, HciStatus.SUCCESS) } @@ -580,6 +620,10 @@ class BluetoothPeripheral internal constructor( commandQueue.clear() commandQueueBusy = false notifyingCharacteristics.clear() + currentMtu = DEFAULT_MTU + currentCommand = IDLE + manuallyBonding = false + discoveryStarted = false try { context.unregisterReceiver(bondStateReceiver) context.unregisterReceiver(pairingRequestBroadcastReceiver) @@ -787,10 +831,10 @@ class BluetoothPeripheral internal constructor( require(characteristic.supportsReading()) { "characteristic does not have read property" } require(isConnected) { PERIPHERAL_NOT_CONNECTED } - val result = commandQueue.add(Runnable { + return enqueue { if (isConnected) { currentResultCallback = resultCallback - if (bluetoothGatt!!.readCharacteristic(characteristic)) { + if (bluetoothGatt?.readCharacteristic(characteristic) == true) { Logger.d(TAG, "reading characteristic <%s>", characteristic.uuid) nrTries++ } else { @@ -802,13 +846,7 @@ class BluetoothPeripheral internal constructor( resultCallback.onCharacteristicRead(this@BluetoothPeripheral, ByteArray(0), characteristic, GattStatus.CONNECTION_CANCELLED) completedCommand() } - }) - if (result) { - nextCommand() - } else { - Logger.e(TAG, "could not enqueue read characteristic command") } - return result } @@ -881,11 +919,9 @@ class BluetoothPeripheral internal constructor( // Copy the value to avoid race conditions val bytesToWrite = copyOf(value) - val result = commandQueue.add(Runnable { + return enqueue { if (isConnected) { currentResultCallback = resultCallback - currentWriteBytes = bytesToWrite - characteristic.writeType = writeType.writeType if (willCauseLongWrite(bytesToWrite, writeType)) { // Android will turn this into a Long Write because it is larger than the MTU - 3. // When doing a Long Write the byte array will be automatically split in chunks of size MTU - 3. @@ -895,8 +931,8 @@ class BluetoothPeripheral internal constructor( // See https://stackoverflow.com/questions/48216517/rxandroidble-write-only-sends-the-first-20b Logger.w(TAG, "value byte array is longer than allowed by MTU, write will fail if peripheral does not support long writes") } - characteristic.value = bytesToWrite - if (bluetoothGatt?.writeCharacteristic(characteristic) ?: false) { + + if (internalWriteCharacteristic(characteristic, bytesToWrite, writeType) == true) { Logger.d(TAG, "writing <%s> to characteristic <%s>", BluetoothBytesParser.bytes2String(bytesToWrite), characteristic.uuid) nrTries++ } else { @@ -908,13 +944,21 @@ class BluetoothPeripheral internal constructor( resultCallback.onCharacteristicWrite(this@BluetoothPeripheral, ByteArray(0), characteristic, GattStatus.CONNECTION_CANCELLED) completedCommand() } - }) - if (result) { - nextCommand() + } + } + + private fun internalWriteCharacteristic(characteristic: BluetoothGattCharacteristic, value: ByteArray, writeType: WriteType): Boolean { + if (bluetoothGatt == null) return false + + currentWriteBytes = value + return if (Build.VERSION.SDK_INT >= 33) { + val result = bluetoothGatt?.writeCharacteristic(characteristic, currentWriteBytes, writeType.writeType) + result == BluetoothStatusCodes.SUCCESS } else { - Logger.e(TAG, "could not enqueue write characteristic command") + characteristic.writeType = writeType.writeType + characteristic.value = value + bluetoothGatt!!.writeCharacteristic(characteristic) } - return result } private fun willCauseLongWrite(value: ByteArray, writeType: WriteType): Boolean { @@ -952,10 +996,10 @@ class BluetoothPeripheral internal constructor( private fun readDescriptor(descriptor: BluetoothGattDescriptor, resultCallback: BluetoothPeripheralCallback): Boolean { require(isConnected) { PERIPHERAL_NOT_CONNECTED } - val result = commandQueue.add(Runnable { + return enqueue { if (isConnected) { currentResultCallback = resultCallback - if (bluetoothGatt!!.readDescriptor(descriptor)) { + if (bluetoothGatt?.readDescriptor(descriptor) == true) { Logger.d(TAG, "reading descriptor <%s>", descriptor.uuid) nrTries++ } else { @@ -967,13 +1011,7 @@ class BluetoothPeripheral internal constructor( resultCallback.onDescriptorRead(this@BluetoothPeripheral, ByteArray(0), descriptor, GattStatus.CONNECTION_CANCELLED) completedCommand() } - }) - if (result) { - nextCommand() - } else { - Logger.e(TAG, "could not enqueue read descriptor command") } - return result } suspend fun writeDescriptor(descriptor: BluetoothGattDescriptor, value: ByteArray): ByteArray = @@ -1015,12 +1053,11 @@ class BluetoothPeripheral internal constructor( // Copy the value to avoid race conditions val bytesToWrite = copyOf(value) - val result = commandQueue.add(Runnable { + return enqueue { if (isConnected) { currentResultCallback = resultCallback - currentWriteBytes = bytesToWrite - descriptor.value = bytesToWrite - if (bluetoothGatt!!.writeDescriptor(descriptor)) { + + if (internalWriteDescriptor(descriptor, bytesToWrite)) { Logger.d(TAG, "writing <%s> to descriptor <%s>", BluetoothBytesParser.bytes2String(bytesToWrite), descriptor.uuid) nrTries++ } else { @@ -1032,17 +1069,23 @@ class BluetoothPeripheral internal constructor( resultCallback.onDescriptorWrite(this@BluetoothPeripheral, ByteArray(0), descriptor, GattStatus.CONNECTION_CANCELLED) completedCommand() } - }) + } + } - if (result) { - nextCommand() + private fun internalWriteDescriptor(descriptor: BluetoothGattDescriptor, value: ByteArray): Boolean { + if (bluetoothGatt == null) return false + + currentWriteBytes = value + + return if (Build.VERSION.SDK_INT >= 33) { + val result = bluetoothGatt?.writeDescriptor(descriptor, value) + result == BluetoothStatusCodes.SUCCESS } else { - Logger.e(TAG, "could not enqueue write descriptor command") + descriptor.value = value + bluetoothGatt!!.writeDescriptor(descriptor) } - return result } - suspend fun observe(characteristic: BluetoothGattCharacteristic, callback: (value: ByteArray) -> Unit): Boolean = suspendCoroutine { try { @@ -1137,37 +1180,26 @@ class BluetoothPeripheral internal constructor( } val finalValue = if (enable) value else DISABLE_NOTIFICATION_VALUE - val result = commandQueue.add(Runnable { + return enqueue { if (notConnected()) { resultCallback.onDescriptorWrite(this@BluetoothPeripheral, ByteArray(0), descriptor, GattStatus.CONNECTION_CANCELLED) completedCommand() - return@Runnable - } - - // First try to set notification for Gatt object - currentResultCallback = resultCallback - if (!bluetoothGatt!!.setCharacteristicNotification(characteristic, enable)) { - Logger.e(TAG, "setCharacteristicNotification failed for characteristic: %s", characteristic.uuid) - completedCommand() - return@Runnable - } - - currentWriteBytes = finalValue - descriptor.value = finalValue - if (bluetoothGatt!!.writeDescriptor(descriptor)) { - nrTries++ } else { - Logger.e(TAG, "writeDescriptor failed for descriptor: %s", descriptor.uuid) - resultCallback.onDescriptorWrite(this@BluetoothPeripheral, ByteArray(0), descriptor, GattStatus.WRITE_NOT_PERMITTED) - completedCommand() + currentResultCallback = resultCallback + if (bluetoothGatt?.setCharacteristicNotification(characteristic, enable) == false) { + Logger.e(TAG, "setCharacteristicNotification failed for characteristic: %s", characteristic.uuid) + completedCommand() + } else { + if (internalWriteDescriptor(descriptor, finalValue)) { + nrTries++ + } else { + Logger.e(TAG, "writeDescriptor failed for descriptor: %s", descriptor.uuid) + resultCallback.onDescriptorWrite(this@BluetoothPeripheral, ByteArray(0), descriptor, GattStatus.WRITE_NOT_PERMITTED) + completedCommand() + } + } } - }) - if (result) { - nextCommand() - } else { - Logger.e(TAG, "could not enqueue setNotify command") } - return result } suspend fun readRemoteRssi(): Int = @@ -1198,10 +1230,10 @@ class BluetoothPeripheral internal constructor( private fun readRemoteRssi(resultCallback: BluetoothPeripheralCallback): Boolean { require(isConnected) { PERIPHERAL_NOT_CONNECTED } - val result = commandQueue.add(Runnable { + return enqueue { if (isConnected) { currentResultCallback = resultCallback - if (!bluetoothGatt!!.readRemoteRssi()) { + if (bluetoothGatt?.readRemoteRssi() == false) { Logger.e(TAG, "readRemoteRssi failed") resultCallback.onReadRemoteRssi(this@BluetoothPeripheral, 0, GattStatus.ERROR) completedCommand() @@ -1210,13 +1242,7 @@ class BluetoothPeripheral internal constructor( resultCallback.onReadRemoteRssi(this@BluetoothPeripheral, 0, GattStatus.CONNECTION_CANCELLED) completedCommand() } - }) - if (result) { - nextCommand() - } else { - Logger.e(TAG, "could not enqueue readRemoteRssi command") } - return result } suspend fun requestMtu(mtu: Int): Int = @@ -1260,10 +1286,10 @@ class BluetoothPeripheral internal constructor( require(mtu in DEFAULT_MTU..MAX_MTU) { "mtu must be between 23 and 517" } require(isConnected) { PERIPHERAL_NOT_CONNECTED } - val result = commandQueue.add(Runnable { + return enqueue { if (isConnected) { currentResultCallback = resultCallback - if (bluetoothGatt!!.requestMtu(mtu)) { + if (bluetoothGatt?.requestMtu(mtu) == true) { currentCommand = REQUEST_MTU_COMMAND Logger.d(TAG, "requesting MTU of %d", mtu) } else { @@ -1275,13 +1301,7 @@ class BluetoothPeripheral internal constructor( resultCallback.onMtuChanged(this@BluetoothPeripheral, 0, GattStatus.CONNECTION_CANCELLED) completedCommand() } - }) - if (result) { - nextCommand() - } else { - Logger.e(TAG, "could not enqueue requestMtu command") } - return result } @@ -1311,10 +1331,10 @@ class BluetoothPeripheral internal constructor( private fun requestConnectionPriority(priority: ConnectionPriority, resultCallback: BluetoothPeripheralCallback): Boolean { require(isConnected) { PERIPHERAL_NOT_CONNECTED } - val result = commandQueue.add(Runnable { + return enqueue { if (isConnected) { currentResultCallback = resultCallback - if (bluetoothGatt!!.requestConnectionPriority(priority.value)) { + if (bluetoothGatt?.requestConnectionPriority(priority.value) == true) { Logger.d(TAG, "requesting connection priority %s", priority) } else { Logger.e(TAG, "could not request connection priority") @@ -1326,14 +1346,7 @@ class BluetoothPeripheral internal constructor( currentResultCallback.onRequestedConnectionPriority(this@BluetoothPeripheral) completedCommand() } - }) - - if (result) { - nextCommand() - } else { - Logger.e(TAG, "could not enqueue request connection priority command") } - return result } /** @@ -1366,7 +1379,7 @@ class BluetoothPeripheral internal constructor( private fun setPreferredPhy(txPhy: PhyType, rxPhy: PhyType, phyOptions: PhyOptions, resultCallback: BluetoothPeripheralCallback): Boolean { require(isConnected) { PERIPHERAL_NOT_CONNECTED } - val result = commandQueue.add(Runnable { + return enqueue { if (isConnected) { Logger.d(TAG, "setting preferred Phy: tx = %s, rx = %s, options = %s", txPhy, rxPhy, phyOptions) currentResultCallback = resultCallback @@ -1376,14 +1389,7 @@ class BluetoothPeripheral internal constructor( resultCallback.onPhyUpdate(this@BluetoothPeripheral, txPhy, rxPhy, GattStatus.CONNECTION_CANCELLED) completedCommand() } - }) - - if (result) { - nextCommand() - } else { - Logger.e(TAG, "could not enqueue setPreferredPhy command") } - return result } /** @@ -1411,7 +1417,7 @@ class BluetoothPeripheral internal constructor( private fun readPhy(resultCallback: BluetoothPeripheralCallback): Boolean { require(isConnected) { PERIPHERAL_NOT_CONNECTED } - val result = commandQueue.add(Runnable { + return enqueue { if (isConnected) { currentResultCallback = resultCallback bluetoothGatt?.readPhy() @@ -1420,14 +1426,7 @@ class BluetoothPeripheral internal constructor( resultCallback.onPhyUpdate(this@BluetoothPeripheral, PhyType.UNKNOWN_PHY_TYPE, PhyType.UNKNOWN_PHY_TYPE, GattStatus.CONNECTION_CANCELLED) completedCommand() } - }) - - if (result) { - nextCommand() - } else { - Logger.e(TAG, "could not enqueue readyPhy command") } - return result } /** @@ -1439,7 +1438,7 @@ class BluetoothPeripheral internal constructor( if (bluetoothGatt == null) return false var result = false try { - val refreshMethod = bluetoothGatt!!.javaClass.getMethod("refresh") + val refreshMethod = bluetoothGatt?.javaClass?.getMethod("refresh") if (refreshMethod != null) { result = refreshMethod.invoke(bluetoothGatt) as Boolean } @@ -1450,6 +1449,21 @@ class BluetoothPeripheral internal constructor( return result } + /** + * Enqueue a command + * + * Return true if the command was enqueued, otherwise false + */ + private fun enqueue(command: Runnable) : Boolean { + val result = commandQueue.add(command) + if (result) { + nextCommand() + } else { + Logger.e(TAG, "could not enqueue command") + } + return result + } + /** * The current command has been completed, move to the next command in the queue (if any) */ @@ -1513,6 +1527,13 @@ class BluetoothPeripheral internal constructor( } interface InternalCallback { + /** + * Trying to connect to [BluetoothPeripheral] + * + * @param peripheral [BluetoothPeripheral] the peripheral. + */ + fun connecting(peripheral: BluetoothPeripheral) + /** * [BluetoothPeripheral] has successfully connected. * @@ -1527,6 +1548,13 @@ class BluetoothPeripheral internal constructor( */ fun connectFailed(peripheral: BluetoothPeripheral, status: HciStatus) + /** + * Trying to disconnect to [BluetoothPeripheral] + * + * @param peripheral [BluetoothPeripheral] the peripheral. + */ + fun disconnecting(peripheral: BluetoothPeripheral) + /** * [BluetoothPeripheral] has disconnected. * @@ -1546,7 +1574,9 @@ class BluetoothPeripheral internal constructor( disconnect() scope.launch { delay(50) - bluetoothGattCallback.onConnectionStateChange(bluetoothGatt, HciStatus.CONNECTION_FAILED_ESTABLISHMENT.value, BluetoothProfile.STATE_DISCONNECTED) + bluetoothGatt?.let { + bluetoothGattCallback.onConnectionStateChange(bluetoothGatt, HciStatus.CONNECTION_FAILED_ESTABLISHMENT.value, BluetoothProfile.STATE_DISCONNECTED) + } } } } diff --git a/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralCallback.kt b/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralCallback.kt index 0268337..914570a 100644 --- a/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralCallback.kt +++ b/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralCallback.kt @@ -105,28 +105,28 @@ internal abstract class BluetoothPeripheralCallback { * * @param peripheral the peripheral */ - fun onBondingStarted(peripheral: BluetoothPeripheral) {} + open fun onBondingStarted(peripheral: BluetoothPeripheral) {} /** * Callback invoked when the bonding process has succeeded * * @param peripheral the peripheral */ - fun onBondingSucceeded(peripheral: BluetoothPeripheral) {} + open fun onBondingSucceeded(peripheral: BluetoothPeripheral) {} /** * Callback invoked when the bonding process has failed * * @param peripheral the peripheral */ - fun onBondingFailed(peripheral: BluetoothPeripheral) {} + open fun onBondingFailed(peripheral: BluetoothPeripheral) {} /** * Callback invoked when a bond has been lost and the peripheral is not bonded anymore. * * @param peripheral the peripheral */ - fun onBondLost(peripheral: BluetoothPeripheral) {} + open fun onBondLost(peripheral: BluetoothPeripheral) {} /** * Callback invoked as the result of a read RSSI operation @@ -178,7 +178,7 @@ internal abstract class BluetoothPeripheralCallback { * Valid range is from 10 (0.1s) to 3200 (32s). * @param status GATT status code */ - fun onConnectionUpdated(peripheral: BluetoothPeripheral, interval: Int, latency: Int, timeout: Int, status: GattStatus) {} + open fun onConnectionUpdated(peripheral: BluetoothPeripheral, interval: Int, latency: Int, timeout: Int, status: GattStatus) {} /** * NULL class to deal with nullability diff --git a/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralManager.kt b/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralManager.kt index 5ace389..bafc97c 100644 --- a/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralManager.kt +++ b/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralManager.kt @@ -22,6 +22,7 @@ */ package com.welie.blessed +import android.annotation.SuppressLint import android.bluetooth.* import android.bluetooth.le.AdvertiseCallback import android.bluetooth.le.AdvertiseData @@ -42,6 +43,7 @@ import java.util.concurrent.ConcurrentLinkedQueue /** * This class represent a peripheral running on the local phone */ +@SuppressLint("MissingPermission") class BluetoothPeripheralManager(private val context: Context, private val bluetoothManager: BluetoothManager, private val callback: BluetoothPeripheralManagerCallback) { // private val context: Context private val mainHandler = Handler(Looper.getMainLooper()) @@ -80,13 +82,13 @@ class BluetoothPeripheralManager(private val context: Context, private val bluet handleDeviceDisconnected(device) } } else { - Logger.i(TAG, "Device '%s' disconnected with status %d", device.name, status) + Logger.i(TAG, "Device '%s' disconnected with status %d", device.name ?: "null", status) handleDeviceDisconnected(device) } } private fun handleDeviceConnected(device: BluetoothDevice) { - Logger.i(TAG, "Central '%s' (%s) connected", device.name, device.address) + Logger.i(TAG, "Central '%s' (%s) connected", device.name ?: "null", device.address) val bluetoothCentral = BluetoothCentral(device.address, device.name) connectedCentralsMap[bluetoothCentral.address] = bluetoothCentral mainHandler.post { callback.onCentralConnected(bluetoothCentral) } @@ -474,7 +476,7 @@ class BluetoothPeripheralManager(private val context: Context, private val bluet private fun cancelConnection(bluetoothDevice: BluetoothDevice) { Objects.requireNonNull(bluetoothDevice, DEVICE_IS_NULL) - Logger.i(TAG, "cancelConnection with '%s' (%s)", bluetoothDevice.name, bluetoothDevice.address) + Logger.i(TAG, "cancelConnection with '%s' (%s)", bluetoothDevice.name ?: "null", bluetoothDevice.address) bluetoothGattServer.cancelConnection(bluetoothDevice) } diff --git a/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralManagerCallback.kt b/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralManagerCallback.kt index 5bba66b..b83f630 100644 --- a/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralManagerCallback.kt +++ b/blessed/src/main/java/com/welie/blessed/BluetoothPeripheralManagerCallback.kt @@ -40,7 +40,7 @@ abstract class BluetoothPeripheralManagerCallback { * successfully. * @param service The service that has been added */ - fun onServiceAdded(status: GattStatus, service: BluetoothGattService) {} + open fun onServiceAdded(status: GattStatus, service: BluetoothGattService) {} /** * A remote central has requested to read a local characteristic. @@ -53,7 +53,7 @@ abstract class BluetoothPeripheralManagerCallback { * @param bluetoothCentral the central that is doing the request * @param characteristic the characteristic to be read */ - fun onCharacteristicRead(bluetoothCentral: BluetoothCentral, characteristic: BluetoothGattCharacteristic) {} + open fun onCharacteristicRead(bluetoothCentral: BluetoothCentral, characteristic: BluetoothGattCharacteristic) {} /** * A remote central has requested to write a local characteristic. @@ -71,7 +71,7 @@ abstract class BluetoothPeripheralManagerCallback { * @param value the value the central wants to write * @return GattStatus.SUCCESS if the value is acceptable, otherwise an appropriate status */ - fun onCharacteristicWrite(bluetoothCentral: BluetoothCentral, characteristic: BluetoothGattCharacteristic, value: ByteArray): GattStatus { + open fun onCharacteristicWrite(bluetoothCentral: BluetoothCentral, characteristic: BluetoothGattCharacteristic, value: ByteArray): GattStatus { return GattStatus.SUCCESS } @@ -86,7 +86,7 @@ abstract class BluetoothPeripheralManagerCallback { * @param bluetoothCentral the central that is doing the request * @param descriptor the descriptor to be read */ - fun onDescriptorRead(bluetoothCentral: BluetoothCentral, descriptor: BluetoothGattDescriptor) {} + open fun onDescriptorRead(bluetoothCentral: BluetoothCentral, descriptor: BluetoothGattDescriptor) {} /** * A remote central has requested to write a local descriptor. @@ -104,7 +104,7 @@ abstract class BluetoothPeripheralManagerCallback { * @param value the value the central wants to write * @return GattStatus.SUCCESS if the value is acceptable, otherwise an appropriate status */ - fun onDescriptorWrite(bluetoothCentral: BluetoothCentral, descriptor: BluetoothGattDescriptor, value: ByteArray): GattStatus { + open fun onDescriptorWrite(bluetoothCentral: BluetoothCentral, descriptor: BluetoothGattDescriptor, value: ByteArray): GattStatus { return GattStatus.SUCCESS } @@ -114,7 +114,7 @@ abstract class BluetoothPeripheralManagerCallback { * @param bluetoothCentral the central * @param characteristic the characteristic */ - fun onNotifyingEnabled(bluetoothCentral: BluetoothCentral, characteristic: BluetoothGattCharacteristic) {} + open fun onNotifyingEnabled(bluetoothCentral: BluetoothCentral, characteristic: BluetoothGattCharacteristic) {} /** * A remote central has disabled notifications or indications for a characteristic @@ -122,7 +122,7 @@ abstract class BluetoothPeripheralManagerCallback { * @param bluetoothCentral the central * @param characteristic the characteristic */ - fun onNotifyingDisabled(bluetoothCentral: BluetoothCentral, characteristic: BluetoothGattCharacteristic) {} + open fun onNotifyingDisabled(bluetoothCentral: BluetoothCentral, characteristic: BluetoothGattCharacteristic) {} /** * A notification has been sent to a central @@ -132,39 +132,39 @@ abstract class BluetoothPeripheralManagerCallback { * @param characteristic the characteristic for which the notification was sent * @param status the status of the operation */ - fun onNotificationSent(bluetoothCentral: BluetoothCentral, value: ByteArray, characteristic: BluetoothGattCharacteristic, status: GattStatus) {} + open fun onNotificationSent(bluetoothCentral: BluetoothCentral, value: ByteArray, characteristic: BluetoothGattCharacteristic, status: GattStatus) {} /** * A remote central has connected * * @param bluetoothCentral the central */ - fun onCentralConnected(bluetoothCentral: BluetoothCentral) {} + open fun onCentralConnected(bluetoothCentral: BluetoothCentral) {} /** * A remote central has disconnected * * @param bluetoothCentral the central */ - fun onCentralDisconnected(bluetoothCentral: BluetoothCentral) {} + open fun onCentralDisconnected(bluetoothCentral: BluetoothCentral) {} /** * Advertising has successfully started * * @param settingsInEffect the AdvertiseSettings that are currently active */ - fun onAdvertisingStarted(settingsInEffect: AdvertiseSettings) {} + open fun onAdvertisingStarted(settingsInEffect: AdvertiseSettings) {} /** * Advertising has failed * * @param advertiseError the error explaining why the advertising failed */ - fun onAdvertiseFailure(advertiseError: AdvertiseError) {} + open fun onAdvertiseFailure(advertiseError: AdvertiseError) {} /** * Advertising has stopped * */ - fun onAdvertisingStopped() {} + open fun onAdvertisingStopped() {} } \ No newline at end of file diff --git a/blessed/src/main/java/com/welie/blessed/ByteArrayExtensions.kt b/blessed/src/main/java/com/welie/blessed/ByteArrayExtensions.kt new file mode 100644 index 0000000..bf0315a --- /dev/null +++ b/blessed/src/main/java/com/welie/blessed/ByteArrayExtensions.kt @@ -0,0 +1,218 @@ +package com.welie.blessed + + +import java.nio.ByteOrder +import java.nio.ByteOrder.LITTLE_ENDIAN +import kotlin.math.pow + +fun Byte.asHexString(): String { + var hexString = this.toUByte().toString(16).uppercase() + if (this.toUInt() < 16u) hexString = "0$hexString" + return hexString +} + +fun ByteArray.formatHexBytes(separator: String?): String { + var resultString = "" + for ((index, value) in this.iterator().withIndex()) { + resultString += value.asHexString() + if (separator != null && index < (this.size - 1)) resultString += separator + } + return resultString +} + +fun ByteArray.asHexString() : String { + return this.formatHexBytes(null) +} + +/** + * Convert an unsigned integer value to a two's-complement encoded + * signed value. + */ +private fun unsignedToSigned(unsigned: UInt, size: UInt): Int { + if (size > 24u) throw IllegalArgumentException("size too large") + + val signBit : UInt = (1u shl ((size - 1u).toInt())) + if (unsigned and signBit != 0u) { + // Convert to a negative value + val nonsignedPart = (unsigned and (signBit - 1u)) + return -1 * (signBit - nonsignedPart).toInt() + } + return unsigned.toInt() +} + +/** + * Convert an unsigned long value to a two's-complement encoded + * signed value. + */ +private fun unsignedToSigned(unsigned: ULong, size: ULong): Long { + if (size > 56u) throw IllegalArgumentException("size too large") + + val signBit : ULong = (1uL shl ((size - 1uL).toInt())) + if (unsigned and signBit != 0uL) { + // Convert to a negative value + val nonsignedPart = (unsigned and (signBit - 1u)) + return -1 * (signBit - nonsignedPart).toLong() + } + return unsigned.toLong() +} + +/** + * Convert an integer into the signed bits of a given length. + */ +private fun intToSignedBits(value: Int, size: Int): Int { + var i = value + if (i < 0) { + i = (1 shl size - 1) + (i and (1 shl size - 1) - 1) + } + return i +} + +fun ByteArray.getULong(offset: UInt = 0u, length: UInt, order: ByteOrder) : ULong { + if (length == 0u) throw IllegalArgumentException("length must not be zero") + + val start = offset.toInt() + val end = start + length.toInt() - 1 + val range : IntProgression = if (order == LITTLE_ENDIAN) IntProgression.fromClosedRange (end, start, -1) else start..end + var result : ULong = 0u + for (i in range) { + if (i != range.first) { + result = result shl 8 + } + result += this[i].toUByte().toUInt() + } + return result +} + +fun ByteArray.getUInt16(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : UInt { + return getULong(offset = offset, length = 2u, order = order).toUInt() +} + +fun ByteArray.getInt16(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : Int { + return unsignedToSigned(getULong(offset = offset, length = 2u, order = order).toUInt(), 16u) +} + +fun ByteArray.getUInt24(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : UInt { + return getULong(offset = offset, length = 3u, order = order).toUInt() +} + +fun ByteArray.getInt24(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : Int { + return unsignedToSigned(getULong(offset = offset, length = 3u, order = order).toUInt(), 24u) +} + +fun ByteArray.getUInt32(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : UInt { + return getULong(offset = offset, length = 4u, order = order).toUInt() +} + +fun ByteArray.getInt32(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : Int { + return getULong(offset = offset, length = 4u, order = order).toInt() +} + +fun ByteArray.getUInt48(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : ULong { + return getULong(offset = offset, length = 6u, order = order) +} + +fun ByteArray.getInt48(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : Long { + return unsignedToSigned(getULong(offset = offset, length = 6u, order = order), 48uL) +} + +fun ByteArray.geUInt64(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : ULong { + return getULong(offset = offset, length = 8u, order = order) +} + +fun ByteArray.geInt64(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : Long { + return getULong(offset = offset, length = 8u, order = order).toLong() +} + +fun ByteArray.getSFloat(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : Double { + val uint16 = getUInt16(offset = offset, order = order) + val mantissa = unsignedToSigned( uint16 and 0x0FFFu, 12u) + val exponent = unsignedToSigned(uint16 shr 12, 4u) + + return mantissa.toDouble() * 10.0.pow(exponent) +} + +fun ByteArray.getFloat(offset : UInt = 0u, order: ByteOrder = LITTLE_ENDIAN) : Double { + val uint32 = getUInt32(offset = offset, order = order) + val mantissa = unsignedToSigned( uint32 and 0x00FFFFFFu, 24u) + val exponent = unsignedToSigned(uint32 shr 24, 8u) + + return mantissa.toDouble() * 10.0.pow(exponent) +} + +fun byteArrayOf(value: UInt, length: UInt, order : ByteOrder = LITTLE_ENDIAN) : ByteArray { + val result = ByteArray(size = length.toInt()) + val end = length.toInt() - 1 + val range : IntProgression = if (order == LITTLE_ENDIAN) 0..end else IntProgression.fromClosedRange (end, 0, -1) + for (i in range) { + if (i == range.first) { + result[i] = value.toByte() + } else { + result[i] = (value shr (i * 8)).toByte() + } + } + return result +} + +fun byteArrayOf(value: Int, length: UInt, order : ByteOrder) : ByteArray { + return byteArrayOf(value.toUInt(), length, order) +} + +fun byteArrayOf(value: Double, length: UInt, precision: Int, order : ByteOrder = LITTLE_ENDIAN) : ByteArray { + val result = ByteArray(size = length.toInt()) + val mantissa = (value * 10.0.pow(precision)).toInt() + val exponent = -precision + + if (length == 2u) { + val localMantissa = intToSignedBits(mantissa, 12) + val localExponent = intToSignedBits(exponent, 4) + var index = 0 + if (order == LITTLE_ENDIAN) { + result[index++] = (localMantissa and 0xFF).toByte() + result[index] = (localMantissa shr 8 and 0x0F).toByte() + result[index] = (result[index] + (localExponent and 0x0F shl 4)).toByte() + } else { + result[index] = (localMantissa shr 8 and 0x0F).toByte() + result[index++] = (result[index] + (localExponent and 0x0F shl 4)).toByte() + result[index] = (localMantissa and 0xFF).toByte() + } + } else if(length == 4u) { + val localMantissa = intToSignedBits(mantissa, 24) + val localExponent = intToSignedBits(exponent, 8) + var index = 0 + if (order == LITTLE_ENDIAN) { + result[index++] = (localMantissa and 0xFF).toByte() + result[index++] = (localMantissa shr 8 and 0xFF).toByte() + result[index++] = (localMantissa shr 16 and 0xFF).toByte() + result[index] = (result[index] + (localExponent and 0xFF)).toByte() + } else { + result[index++] = (result[index] + (localExponent and 0xFF)).toByte() + result[index++] = (localMantissa shr 16 and 0xFF).toByte() + result[index++] = (localMantissa shr 8 and 0xFF).toByte() + result[index] = (localMantissa and 0xFF).toByte() + } + } + return result +} + +fun byteArrayOf(hexString: String): ByteArray { + val result = ByteArray(hexString.length / 2) + for (i in result.indices) { + val index = i * 2 + result[i] = hexString.substring(index, index + 2).toInt(16).toByte() + } + return result +} + +fun mergeArrays(vararg arrays: ByteArray): ByteArray { + var size = 0 + for (array in arrays) { + size += array.size + } + val merged = ByteArray(size) + var index = 0 + for (array in arrays) { + array.copyInto(merged, index, 0, array.size) + index += array.size + } + return merged +} \ No newline at end of file diff --git a/blessed/src/main/java/com/welie/blessed/Extensions.kt b/blessed/src/main/java/com/welie/blessed/Extensions.kt index 5b7cbda..fd532d6 100644 --- a/blessed/src/main/java/com/welie/blessed/Extensions.kt +++ b/blessed/src/main/java/com/welie/blessed/Extensions.kt @@ -9,9 +9,9 @@ fun ByteArray.asString() : String { return parser.stringValue } -fun ByteArray.asHexString() : String { - return BluetoothBytesParser.bytes2String(this) -} +//fun ByteArray.asHexString() : String { +// return BluetoothBytesParser.bytes2String(this) +//} fun ByteArray.asUInt8() : UInt? { if (this.isEmpty()) return null diff --git a/blessed/src/test/java/com/welie/blessed/BluetoothBytesParserTest.kt b/blessed/src/test/java/com/welie/blessed/BluetoothBytesParserTest.kt new file mode 100644 index 0000000..aed28d3 --- /dev/null +++ b/blessed/src/test/java/com/welie/blessed/BluetoothBytesParserTest.kt @@ -0,0 +1,38 @@ +package com.welie.blessed + +import android.content.Context +import android.util.Log +import com.welie.blessed.BluetoothBytesParser.Companion.FORMAT_FLOAT +import io.mockk.* +import org.junit.* +import org.junit.Assert.* +import java.nio.ByteOrder + + +class BluetoothBytesParserTest { + private val mockContext = mockk() + + @Test + fun first_test() { + val byteParser = BluetoothBytesParser(byteArrayOf(0xFF.toByte(), 0x00, 0x01, 0x6c), ByteOrder.BIG_ENDIAN) + assertEquals(byteParser.getFloatValue(FORMAT_FLOAT), 36.4f) + } + + @Test + fun second_test() { + var parser = BluetoothBytesParser(ByteOrder.LITTLE_ENDIAN) + parser.setFloatValue(364, -1, FORMAT_FLOAT, 0) + parser.offset = 0 +// assertEquals(36.4f, parser.getFloatValue(FORMAT_FLOAT)) + val asstring = parser.value.asHexString() + assertEquals(36.4, parser.getFloat(), 0.01) + + parser = BluetoothBytesParser(ByteOrder.LITTLE_ENDIAN) + parser.setFloatValue(5.3f, 1) + parser.setFloatValue(36.86f, 2) + + parser.offset = 0 + assertEquals(5.3f, parser.getFloatValue(FORMAT_FLOAT)) + assertEquals(36.86f, parser.getFloatValue(FORMAT_FLOAT)) + } +} \ No newline at end of file diff --git a/blessed/src/test/java/com/welie/blessed/ByteArrayTests.kt b/blessed/src/test/java/com/welie/blessed/ByteArrayTests.kt new file mode 100644 index 0000000..584e9f8 --- /dev/null +++ b/blessed/src/test/java/com/welie/blessed/ByteArrayTests.kt @@ -0,0 +1,153 @@ +package com.welie.blessed + +import org.junit.Assert.* +import org.junit.Test +import java.nio.ByteOrder.BIG_ENDIAN +import java.nio.ByteOrder.LITTLE_ENDIAN + + +class ByteArrayTests { + + @Test + fun getUInt16_pos_LE_test() { + val value = byteArrayOf(0x01u,0x02u) + assertEquals(513u, value.getUInt16(order = LITTLE_ENDIAN)) + } + + @Test + fun getUInt16_pos_BE_test() { + val value = byteArrayOf(0x01u,0x02u) + assertEquals(258u, value.getUInt16(order = BIG_ENDIAN)) + } + + @Test + fun getUInt16_max_LE_test() { + val value = byteArrayOf(0xFF.toByte(),0xFF.toByte()) + assertEquals(65535u, value.getUInt16(order = LITTLE_ENDIAN)) + } + + @Test + fun getUInt16_max_BE_test() { + val value = byteArrayOf(0xFF.toByte(),0xFF.toByte()) + assertEquals(65535u, value.getUInt16(order = BIG_ENDIAN)) + } + + @Test + fun getInt16_pos_LE_test() { + val value = byteArrayOf(0x01u,0x02u) + assertEquals(513, value.getInt16(order = LITTLE_ENDIAN)) + } + + @Test + fun getInt16_pos_BE_test() { + val value = byteArrayOf(0x01u,0x02u) + assertEquals(258, value.getInt16(order = BIG_ENDIAN)) + } + + @Test + fun getInt16_neg_LE_test() { + val value = byteArrayOf(3,-4) + assertEquals(-1021, value.getInt16(order = LITTLE_ENDIAN)) + } + + @Test + fun getInt16_neg_BE_test() { + val value = byteArrayOf(-4, 3) + assertEquals(-1021, value.getInt16(order = BIG_ENDIAN)) + } + + @Test + fun getUInt32_max_LE_test() { + val value = byteArrayOf(0xFF.toByte(),0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) + assertEquals(UInt.MAX_VALUE, value.getUInt32(order = LITTLE_ENDIAN)) + } + + @Test + fun getInt32_minus1_LE_test() { + val value = byteArrayOf(0xFF.toByte(),0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) + assertEquals(-1, value.getInt32(order = LITTLE_ENDIAN)) + } + + @Test + fun getInt32_minus_random_LE_test() { + val value = byteArrayOf(1,-2,3,-4) + assertEquals(-66847231, value.getInt32(order = LITTLE_ENDIAN)) + } + + @Test + fun getFloat32_random_LE_test() { + val value = byteArrayOf("6C0100FF") + assertEquals(36.4, value.getFloat(order = LITTLE_ENDIAN), 0.1) + } + + @Test + fun getFloat32_random_BE_test() { + // 6C0100FF + val value = byteArrayOf("FF000170") + assertEquals(36.8, value.getFloat(order = BIG_ENDIAN), 0.1) + } + + @Test + fun getFloat16_pos_random_BE_test() { + val value = byteArrayOf("F070") + assertEquals(11.2, value.getSFloat(order = BIG_ENDIAN), 0.1) + } + + @Test + fun getFloat16_neg_random_LE_test() { + val value = byteArrayOf("70F8") + assertEquals(-193.6, value.getSFloat(order = LITTLE_ENDIAN), 0.1) + } + + @Test + fun getFloat16_neg_random_BE_test() { + val value = byteArrayOf("F870") + assertEquals(-193.6, value.getSFloat(order = BIG_ENDIAN), 0.1) + } + + @Test + fun byteArrayOf_UInt16_LE_test() { + val value = byteArrayOf(256u, 2u, LITTLE_ENDIAN) + assertEquals("0001", value.asHexString()) + } + + @Test + fun hexString_test() { + val value = byteArrayOf("F870") + assertEquals("F870", value.asHexString()) + } + + @Test + fun byteArrayOf_SFloat_LE_test() { + val value = byteArrayOf(11.2, 2u, 1, LITTLE_ENDIAN) + assertEquals("70F0", value.asHexString()) + } + + @Test + fun byteArrayOf_SFloat_BE_test() { + val value = byteArrayOf(11.2, 2u, 1, BIG_ENDIAN) + assertEquals("F070", value.asHexString()) + } + + @Test + fun byteArrayOf_Float_LE_test() { + val value = byteArrayOf(36.4, 4u, 1, LITTLE_ENDIAN) + assertEquals("6C0100FF", value.asHexString()) + } + + @Test + fun byteArrayOf_Float_BE_test() { + val value = byteArrayOf(36.4, 4u, 1, BIG_ENDIAN) + assertEquals("FF00016C", value.asHexString()) + } + + @Test + fun merge_test() { + val value = byteArrayOf("F870") + val value2 = byteArrayOf("A387") + val value3 = byteArrayOf("5638") + val merged = mergeArrays(value,value2,value3) + + assertEquals("F870A3875638", merged.asHexString()) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index a1674a4..58f1c0b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,14 +1,14 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.5.31' + ext.kotlin_version = '1.7.20' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.3' + classpath 'com.android.tools.build:gradle:7.2.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ffed3a2..2e6e589 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists