From f3d56ba59c1afb31892298be5090a874cfa1c70b Mon Sep 17 00:00:00 2001 From: Fabian Christoffel Date: Sun, 25 Jun 2023 11:16:12 +0200 Subject: [PATCH] feat: display list of measurements --- app/build.gradle | 2 +- .../sensortestingapp/ConnectionManager.kt | 153 +++++++-- .../sensortestingapp/DeviceListAdapter.kt | 147 ++++++++ .../example/sensortestingapp/KirbyDevice.kt | 262 +++++++++++++++ .../example/sensortestingapp/MainActivity.kt | 318 ++++++++---------- .../sensortestingapp/ScanResultAdapter.kt | 64 ---- .../res/drawable/baseline_more_vert_24.xml | 5 + app/src/main/res/drawable/layout_bg.xml | 6 + app/src/main/res/layout/activity_main.xml | 26 +- app/src/main/res/layout/row_device_list.xml | 103 ++++++ ...n_result.xml => row_measurements_list.xml} | 29 +- app/src/main/res/menu/device_menu.xml | 3 + app/src/main/res/values/colors.xml | 1 + 13 files changed, 807 insertions(+), 312 deletions(-) create mode 100644 app/src/main/java/com/example/sensortestingapp/DeviceListAdapter.kt create mode 100644 app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt delete mode 100644 app/src/main/java/com/example/sensortestingapp/ScanResultAdapter.kt create mode 100644 app/src/main/res/drawable/baseline_more_vert_24.xml create mode 100644 app/src/main/res/drawable/layout_bg.xml create mode 100644 app/src/main/res/layout/row_device_list.xml rename app/src/main/res/layout/{row_scan_result.xml => row_measurements_list.xml} (52%) create mode 100644 app/src/main/res/menu/device_menu.xml diff --git a/app/build.gradle b/app/build.gradle index 7ed66fc..08c6596 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -46,5 +46,5 @@ dependencies { testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - implementation 'androidx.recyclerview:recyclerview:1.1.0' + implementation 'androidx.recyclerview:recyclerview:1.2.0' } \ No newline at end of file diff --git a/app/src/main/java/com/example/sensortestingapp/ConnectionManager.kt b/app/src/main/java/com/example/sensortestingapp/ConnectionManager.kt index 34d858b..50e9de2 100644 --- a/app/src/main/java/com/example/sensortestingapp/ConnectionManager.kt +++ b/app/src/main/java/com/example/sensortestingapp/ConnectionManager.kt @@ -1,27 +1,16 @@ -/* - * Copyright 2019 Punch Through Design LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ package com.example.sensortestingapp import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings import android.content.Context import android.util.Log import java.util.UUID @@ -74,7 +63,19 @@ data class DiscoverServicesRequest( ) : BleOperationType() -open class BleListener { +open class BleListener(private val deviceAddress: String?) { + + open fun isRelevantMessage(address: String?): Boolean { + if (deviceAddress != null && deviceAddress == address) { + return true; + } + return deviceAddress == null + } + + open fun onConnect(gatt: BluetoothGatt) {} + + open fun onDisconnect(gatt: BluetoothGatt) {} + open fun onSuccessfulCharRead( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic @@ -86,6 +87,27 @@ open class BleListener { characteristic: BluetoothGattCharacteristic ) { } + + open fun onSubscribe( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + ) { + } + + open fun onUnsubscribe( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + ) { + } + + open fun onScanningStateChange(isScanning: Boolean) { + + } + + open fun onScanResult(callbackType: Int, result: ScanResult) { + + } + } @@ -138,28 +160,67 @@ fun ByteArray.toHexString(): String = joinToString(separator = "", prefix = "0x") { String.format("%02X", it) } @SuppressLint("MissingPermission") -object ConnectionManager { +class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) { private val deviceGattMap = ConcurrentHashMap() private val operationQueue = ConcurrentLinkedQueue() private var pendingOperation: BleOperationType? = null private var listeners = ArrayList() + private val scanSettings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + private val bleScanner by lazy { + bleAdapter.bluetoothLeScanner + } + + var isScanning = false + set(value) { + field = value + notifyListeners(null) { + it.onScanningStateChange(value) + } + } + + @SuppressLint("MissingPermission") + val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + Log.d( + "ScanCallback", + "Found BLE device with address ${result.device.address} (name: ${result.device.name}, rssi: ${result.rssi})" + ) + notifyListeners(result.device.address) { it.onScanResult(callbackType, result) } + } + + override fun onScanFailed(errorCode: Int) { + Log.e("ScanCallback", "onScanFailed: code $errorCode") + } + } fun register(listener: BleListener) { listeners.add(listener) } - private fun notifyListeners(notifier: (listener: BleListener) -> Unit) { - listeners.forEach(notifier) + + fun startScan() { + if (!isScanning) { + isScanning = true + bleScanner.startScan(null, scanSettings, scanCallback) + } + } + + fun stopScan() { + bleScanner.stopScan(scanCallback) + isScanning = false } - fun connect(device: BluetoothDevice, context: Context) { + fun connect(device: BluetoothDevice) { if (device.isConnected()) { Log.e("ConnectionManager", "Already connected to ${device.address}!") } else { - enqueueOperation(Connect(device, context.applicationContext)) + enqueueOperation(Connect(device, context)) } } @@ -174,15 +235,13 @@ object ConnectionManager { } } - - fun requestMtu(device: BluetoothDevice, mtu: Int) { + fun requestMtu(device: BluetoothDevice, mtu: Int = Int.MAX_VALUE) { enqueueOperation(MtuRequest(device, mtu.coerceIn(GATT_MIN_MTU_SIZE, GATT_MAX_MTU_SIZE))) } fun discoverServices(device: BluetoothDevice) { enqueueOperation(DiscoverServicesRequest(device)) - } fun readChar(device: BluetoothDevice, service: UUID, char: UUID) { @@ -200,8 +259,16 @@ object ConnectionManager { // - Beginning of PRIVATE functions + private fun notifyListeners(address: String?, notifier: (listener: BleListener) -> Unit) { + listeners.filter { n -> n.isRelevantMessage(address) }.forEach(notifier) + } + + @Synchronized private fun enqueueOperation(operation: BleOperationType) { + if (isScanning) { + stopScan() + } operationQueue.add(operation) if (pendingOperation == null) { doNextOperation() @@ -217,11 +284,6 @@ object ConnectionManager { } } - /** - * Perform a given [BleOperationType]. All permission checks are performed before an operation - * can be enqueued by [enqueueOperation]. - */ - @Synchronized private fun doNextOperation() { if (pendingOperation != null) { @@ -266,6 +328,9 @@ object ConnectionManager { Log.w("ConnectionManager", "Disconnecting from ${device.address}") gatt.close() deviceGattMap.remove(device) + notifyListeners(gatt.device.address) { + it.onDisconnect(gatt) + } signalEndOfOperation() } @@ -355,11 +420,14 @@ object ConnectionManager { if (status == BluetoothGatt.GATT_SUCCESS) { if (newState == BluetoothProfile.STATE_CONNECTED) { - Log.w( + Log.i( "ConnectionManager", "onConnectionStateChange: connected to $deviceAddress" ) deviceGattMap[gatt.device] = gatt + notifyListeners(gatt.device.address) { + it.onConnect(gatt) + } if (pendingOperation is Connect) { signalEndOfOperation() } @@ -368,6 +436,9 @@ object ConnectionManager { "ConnectionManager", "onConnectionStateChange: disconnected from $deviceAddress" ) + notifyListeners(gatt.device.address) { + it.onDisconnect(gatt) + } teardownConnection(gatt.device) } } else { @@ -388,6 +459,9 @@ object ConnectionManager { "Discovered ${services.size} services for ${device.address}." ) printGattTable() + notifyListeners(gatt.device.address) { + it.onConnect(gatt) + } } else { Log.e("ConnectionManager", "Service discovery failed due to status $status") teardownConnection(gatt.device) @@ -422,7 +496,7 @@ object ConnectionManager { "ConnectionManager", "Read characteristic $uuid (service: ${service.uuid}): ${value.toHexString()}" ) - notifyListeners { listener -> + notifyListeners(gatt.device.address) { listener -> listener.onSuccessfulCharRead( gatt, characteristic @@ -462,6 +536,16 @@ object ConnectionManager { descriptor.uuid == CCC_DESCRIPTOR_UUID && op.charId == descriptor.characteristic.uuid && op.serviceId == descriptor.characteristic.service.uuid ) { + + if (!descriptor.value.contentEquals(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)) { + notifyListeners(gatt.device.address) { + it.onSubscribe(gatt, descriptor) + } + } else { + notifyListeners(gatt.device.address) { + it.onUnsubscribe(gatt, descriptor) + } + } signalEndOfOperation() } } @@ -470,7 +554,12 @@ object ConnectionManager { gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { - notifyListeners { listener -> listener.onCharChange(gatt, characteristic) } + notifyListeners(gatt.device.address) { listener -> + listener.onCharChange( + gatt, + characteristic + ) + } } diff --git a/app/src/main/java/com/example/sensortestingapp/DeviceListAdapter.kt b/app/src/main/java/com/example/sensortestingapp/DeviceListAdapter.kt new file mode 100644 index 0000000..c9d52fd --- /dev/null +++ b/app/src/main/java/com/example/sensortestingapp/DeviceListAdapter.kt @@ -0,0 +1,147 @@ +package com.example.sensortestingapp + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.ListView +import android.widget.PopupMenu +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +interface Action { + fun getLabel(): String + fun getIcon(): String + fun execute(): Unit +} + +interface Measurement { + fun getLabel(): String + fun getFormattedValue(): String +} + +interface DeviceListEntry { + val address: String + + var rssi: Int + + val name: String? + + val status: String? + + fun getActions(): List + + fun getMeasurements(): List + +} + +@SuppressLint("MissingPermission") +class DeviceListAdapter( + private val items: List, +) : RecyclerView.Adapter() { + + /** + * Provide a reference to the type of views that you are using + * (custom ViewHolder) + */ + class ViewHolder( + private val view: View, + private val context: Context + ) : RecyclerView.ViewHolder(view) { + + val deviceNameView: TextView + val macAddressView: TextView + val signalStrengthView: TextView + val statusView: TextView + val measurementsListView: ListView + val deviceActions: Button + + + init { + deviceNameView = view.findViewById(R.id.device_name) + macAddressView = view.findViewById(R.id.mac_address) + signalStrengthView = view.findViewById(R.id.signal_strength) + statusView = view.findViewById(R.id.device_status) + measurementsListView = view.findViewById(R.id.measurement_fields) + deviceActions = view.findViewById(R.id.device_actions) + } + + fun bind(result: DeviceListEntry) { + deviceNameView.text = result.name ?: "" + macAddressView.text = result.address + signalStrengthView.text = "${result.rssi ?: "-"} dBm" + statusView.text = result.status + deviceActions.setOnClickListener { + val popup = PopupMenu(context, deviceActions) + result.getActions().forEach { action -> + popup.menu.add(action.getLabel()) + .setOnMenuItemClickListener { menuItem -> + action.execute() + true + } + } + val inflater = popup.menuInflater + inflater.inflate(R.menu.device_menu, popup.menu) + popup.show() + } + + val measurements = result.getMeasurements() + + val measurementsRowHeight = 100 + val measurementsAdapter = object : + ArrayAdapter( + context, + R.layout.row_measurements_list, + measurements + ) { + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup + ): View { + + val measurementView = convertView ?: LayoutInflater.from(parent.context) + .inflate(R.layout.row_measurements_list, parent, false) + measurementView.findViewById(R.id.measurement_label).text = + measurements[position].getLabel() + measurementView.findViewById(R.id.measurement_value).text = + measurements[position].getFormattedValue() + measurementView.layoutParams.height = measurementsRowHeight + return measurementView + } + + override fun isEnabled(position: Int): Boolean { + // make measurement rows unclickable + return false + } + } + + measurementsListView.adapter = measurementsAdapter + measurementsListView.divider = null + + measurementsListView.layoutParams.height = measurementsRowHeight * measurements.size + } + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { + // Create a new view, which defines the UI of the list item + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.row_device_list, viewGroup, false) + + return ViewHolder(view, viewGroup.context) + } + + override fun getItemCount() = items.size + + // Replace the contents of a view (invoked by the layout manager) + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + + // Get element from your dataset at this position and replace the + // contents of the view with that element + val item = items[position] + holder.bind(item) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt b/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt new file mode 100644 index 0000000..4ee0797 --- /dev/null +++ b/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt @@ -0,0 +1,262 @@ +package com.example.sensortestingapp + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.le.ScanResult +import android.util.Log +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.EnumSet +import java.util.UUID +import java.util.stream.Collectors + +private val DEMO_SERVICE_UUID = UUID.fromString("00000000-0001-11E1-9AB4-0002A5D5C51B") +private val DEMO_CHAR_UUID = UUID.fromString("00140000-0001-11E1-AC36-0002A5D5C51B") + +data class DemoPayload( + val ts: Int, + val pressure: Float, + val temperature: Float, + val sysTs: String = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("HH:mm:ss")) +) + + +fun bytesToUInt16(arr: ByteArray, start: Int): Int { + return ByteBuffer.wrap(arr, start, 2) + .order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF +} + +fun bytesToInt16(arr: ByteArray, start: Int): Short { + return ByteBuffer.wrap(arr, start, 2) + .order(ByteOrder.LITTLE_ENDIAN).short +} + +fun bytesToInt32(arr: ByteArray, start: Int): Int { + return ByteBuffer.wrap(arr, start, 4) + .order(ByteOrder.LITTLE_ENDIAN).int +} + + +fun decodeDemoPayload(bytes: ByteArray): DemoPayload { + val ts = bytesToUInt16(bytes, 0) + val pressure = bytesToInt32(bytes, 2) / 100.0f + val temp = bytesToInt16(bytes, 6) / 10.0f; + return DemoPayload(ts, pressure, temp) +} + +enum class DeviceStatus { + DISCOVERED, CONNECTED, BONDED, SUBSCRIBED +} + +@SuppressLint("MissingPermission") +class KirbyDevice( + private val connectionManager: ConnectionManager, + private val bleDevice: BluetoothDevice, + initialRssi: Int, + private val onStateChange: (device: KirbyDevice) -> Unit +) : BleListener(bleDevice.address), DeviceListEntry { + + override fun onScanResult(callbackType: Int, result: ScanResult) { + statuses.add(DeviceStatus.DISCOVERED) + onStateChange(this) + } + + override fun onConnect(gatt: BluetoothGatt) { + statuses.add(DeviceStatus.CONNECTED) + onStateChange(this) + } + + override fun onDisconnect(gatt: BluetoothGatt) { + statuses.remove(DeviceStatus.CONNECTED) + statuses.remove(DeviceStatus.SUBSCRIBED) + onStateChange(this) + } + + override fun onSuccessfulCharRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic + ) { + addMeasurement(characteristic) + onStateChange(this) + } + + override fun onCharChange(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + addMeasurement(characteristic) + //statuses.add(DeviceStatus.SUBSCRIBED) + onStateChange(this) + } + + override fun onSubscribe( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + ) { + statuses.add(DeviceStatus.SUBSCRIBED) + onStateChange(this) + } + + override fun onUnsubscribe(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor) { + statuses.remove(DeviceStatus.SUBSCRIBED) + onStateChange(this) + } + + override var rssi = initialRssi + set(value) { + field = value + onStateChange(this) + } + + private fun addMeasurement(characteristic: BluetoothGattCharacteristic) { + if (characteristic.service.uuid == DEMO_SERVICE_UUID && characteristic.uuid == DEMO_CHAR_UUID) { + val payload = decodeDemoPayload(characteristic.value) + Log.i("BleListener", "Demo char received: $payload") + measurements.add(payload) + } + } + + private val measurements = ArrayList() + + private val statuses = EnumSet.noneOf(DeviceStatus::class.java) + + override val address: String + get() = bleDevice.address + + + override val name: String? + get() = bleDevice.name + + override val status: String? + get() = statuses.stream().map { it.name }.collect(Collectors.joining(", ")) + + // override fun getRssi(): Int? { +// return rssi +// } + + override fun getMeasurements(): List { + if (measurements.isEmpty()) { + return emptyList() + } + val latest = measurements.last() + return listOf(object : Measurement { + override fun getLabel(): String { + return "TS" + } + + override fun getFormattedValue(): String { + return "${latest.sysTs} (${latest.ts})" + } + }, object : Measurement { + override fun getLabel(): String { + return "Temperature" + } + + override fun getFormattedValue(): String { + return "${latest.temperature} °C" + } + }, object : Measurement { + override fun getLabel(): String { + return "Pressure" + } + + override fun getFormattedValue(): String { + return "${latest.pressure} hPa" + } + }) + } + + override fun getActions(): List { + val actions = mutableListOf() + if (statuses.contains(DeviceStatus.DISCOVERED) && !statuses.contains(DeviceStatus.CONNECTED) + ) { + actions.add(object : Action { + override fun getLabel(): String { + return "Connect" + } + + override fun getIcon(): String { + return "" + } + + override fun execute() { + connectionManager.connect(bleDevice) + connectionManager.discoverServices(bleDevice) + } + }) + } + + if (statuses.contains(DeviceStatus.CONNECTED)) { + + actions.add(object : Action { + override fun getLabel(): String { + return "Disconnect" + } + + override fun getIcon(): String { + return "" + } + + override fun execute() { + connectionManager.teardownConnection(bleDevice) + } + }) + + actions.add(object : Action { + override fun getLabel(): String { + return "Fetch Measurement" + } + + override fun getIcon(): String { + TODO("Not yet implemented") + } + + override fun execute() { + connectionManager.readChar(bleDevice, DEMO_SERVICE_UUID, DEMO_CHAR_UUID) + } + }) + + if (!statuses.contains(DeviceStatus.SUBSCRIBED)) { + actions.add(object : Action { + override fun getLabel(): String { + return "Subscribe" + } + + override fun getIcon(): String { + return "" + } + + override fun execute() { + connectionManager.enableNotification( + bleDevice, DEMO_SERVICE_UUID, DEMO_CHAR_UUID + ) + } + }) + } + } + + if (statuses.contains(DeviceStatus.SUBSCRIBED)) { + actions.add(object : Action { + override fun getLabel(): String { + return "Unsubscribe" + } + + override fun getIcon(): String { + return "" + } + + override fun execute() { + connectionManager.disableNotification( + bleDevice, DEMO_SERVICE_UUID, DEMO_CHAR_UUID + ) + } + }) + } + + + return actions; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/sensortestingapp/MainActivity.kt b/app/src/main/java/com/example/sensortestingapp/MainActivity.kt index 819c2e6..3f6e800 100644 --- a/app/src/main/java/com/example/sensortestingapp/MainActivity.kt +++ b/app/src/main/java/com/example/sensortestingapp/MainActivity.kt @@ -5,12 +5,8 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.AlertDialog import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothGatt -import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothManager -import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanResult -import android.bluetooth.le.ScanSettings import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -28,26 +24,10 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import com.example.sensortestingapp.databinding.ActivityMainBinding -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.UUID - private const val ENABLE_BLUETOOTH_REQUEST_CODE = 1 - private const val RUNTIME_PERMISSION_REQUEST_CODE = 2 -// Top level declaration -private const val GATT_MAX_MTU_SIZE = 517 - -// Top level declaration -private const val GATT_REQUESTED_MTU_SIZE = GATT_MAX_MTU_SIZE - - -private val DEMO_SERVICE_UUID = UUID.fromString("00000000-0001-11E1-9AB4-0002A5D5C51B") -private val DEMO_CHAR_UUID = UUID.fromString("00140000-0001-11E1-AC36-0002A5D5C51B") - - fun Context.hasPermission(permissionType: String): Boolean { return ContextCompat.checkSelfPermission(this, permissionType) == PackageManager.PERMISSION_GRANTED @@ -62,114 +42,27 @@ fun Context.hasRequiredRuntimePermissions(): Boolean { } } -data class DemoPayload(val ts: Int, val pressure: Float, val temperature: Float) - - -fun bytesToUInt16(arr: ByteArray, start: Int): Int { - return ByteBuffer.wrap(arr, start, 2) - .order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF -} - -fun bytesToInt16(arr: ByteArray, start: Int): Short { - return ByteBuffer.wrap(arr, start, 2) - .order(ByteOrder.LITTLE_ENDIAN).short -} - -fun bytesToInt32(arr: ByteArray, start: Int): Int { - return ByteBuffer.wrap(arr, start, 4) - .order(ByteOrder.LITTLE_ENDIAN).int -} - - -fun decodeDemoPayload(bytes: ByteArray): DemoPayload { - val ts = bytesToUInt16(bytes, 0) - val pressure = bytesToInt32(bytes, 2) / 100.0f - val temp = bytesToInt16(bytes, 6) / 10.0f; - return DemoPayload(ts, pressure, temp) -} - - @SuppressLint("MissingPermission") class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding - private val scanSettings = ScanSettings.Builder() - .setScanMode(ScanSettings.SCAN_MODE_BALANCED) - .build() - private val bluetoothAdapter: BluetoothAdapter by lazy { val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager bluetoothManager.adapter } - private val bleScanner by lazy { - bluetoothAdapter.bluetoothLeScanner + + private val connectionManager: ConnectionManager by lazy { + newConnectionManager() } - private var isScanning = false - set(value) { - field = value - runOnUiThread { - binding.fab.setText(if (value) "Stop Scan" else "Start Scan") - } - } + private val kirbyDevices = mutableListOf(); - private val kirbyScanResults = ArrayList(); - - - private var mtuSizeInBytes = 23 - - - private val scanResultAdapter: ScanResultAdapter by lazy { - ScanResultAdapter(kirbyScanResults) { scanResult -> - // User tapped on a scan result - if (isScanning) { - stopBleScan() - } - - Log.i( - "ConnectCallback", - "Start connecting to ${scanResult.device} (${scanResult.device.name})" - ) - - ConnectionManager.connect(scanResult.device, applicationContext) - ConnectionManager.requestMtu(scanResult.device, Int.MAX_VALUE) - ConnectionManager.discoverServices(scanResult.device) - //ConnectionManager.readChar(scanResult.device, DEMO_SERVICE_UUID, DEMO_CHAR_UUID) - ConnectionManager.enableNotification( - scanResult.device, - DEMO_SERVICE_UUID, - DEMO_CHAR_UUID - ) - } + private val deviceListAdapter: DeviceListAdapter by lazy { + DeviceListAdapter(kirbyDevices) } - private val bleListener = object : BleListener() { - - fun logDemoPayload(characteristic: BluetoothGattCharacteristic) { - if (characteristic.service.uuid == DEMO_SERVICE_UUID && characteristic.uuid == DEMO_CHAR_UUID) { - val payload = decodeDemoPayload(characteristic.value) - Log.i("BleListener", "Demo char received: $payload") - } - } - - override fun onSuccessfulCharRead( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic - ) { - logDemoPayload(characteristic) - } - - override fun onCharChange( - gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic - ) { - logDemoPayload(characteristic) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { WindowCompat.setDecorFitsSystemWindows(window, false) super.onCreate(savedInstanceState) @@ -182,37 +75,97 @@ class MainActivity : AppCompatActivity() { binding.fab.setOnClickListener { view -> - if (!isScanning) { - startBleScan() - } else { - stopBleScan() + if (!hasRequiredRuntimePermissions()) { + requestRelevantRuntimePermissions() + } + if (connectionManager.isScanning) { + connectionManager.stopScan() + } else { + startBleScan() } -// Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG) -// .setAnchorView(R.id.fab) -// .setAction("Action", null).show() } - setupRecyclerView() - - ConnectionManager.register(bleListener) + setupDevicesList() } - private fun setupRecyclerView() { - binding.scanResultsRecyclerView.apply { - adapter = scanResultAdapter + private fun setupDevicesList() { + binding.devicesList.apply { + adapter = deviceListAdapter layoutManager = LinearLayoutManager( this@MainActivity, RecyclerView.VERTICAL, false ) - isNestedScrollingEnabled = false + //isNestedScrollingEnabled = false } - val animator = binding.scanResultsRecyclerView.itemAnimator + val animator = binding.devicesList.itemAnimator if (animator is SimpleItemAnimator) { animator.supportsChangeAnimations = false } + //addDummyDevices() + } + + @SuppressLint("NotifyDataSetChanged") + private fun addDummyDevices() { + for (i in 0..14) { + kirbyDevices.add(DummyListEntry("$i")) + } + deviceListAdapter.notifyDataSetChanged() + } + + private fun newConnectionManager(): ConnectionManager { + val mngr = ConnectionManager(applicationContext, bluetoothAdapter) + mngr.register(object : BleListener(null) { + override fun onScanningStateChange(isScanning: Boolean) { + runOnUiThread { + binding.fab.setText(if (isScanning) "Stop Scan" else "Start Scan") + } + } + + override fun onScanResult(callbackType: Int, result: ScanResult) { + if ((result.device.name ?: "").lowercase().contains("kirby")) { + + Log.i( + "ScanCallback", + "Found Kirby device with name ${result.device.name} (address: ${result.device.address}, rssi: ${result.rssi})" + ) + val kirbyDevice = kirbyDevices.find { it.address == result.device.address } + if (kirbyDevice == null) { + newKirbyDevice(callbackType, result) + } else { + kirbyDevice.rssi = result.rssi + } + + } + } + }) + return mngr + } + + private fun newKirbyDevice(callbackType: Int, scanResult: ScanResult): KirbyDevice { + val device = KirbyDevice(connectionManager, scanResult.device, scanResult.rssi) { + val i = kirbyDevices.indexOfFirst { d -> d === it } + runOnUiThread { + deviceListAdapter.notifyItemChanged(i) + } + } + connectionManager.register(device) + kirbyDevices.add(device) + device.onScanResult(callbackType, scanResult) + deviceListAdapter.notifyItemInserted(kirbyDevices.size - 1) + return device + } + + private fun startBleScan() { + if (!hasRequiredRuntimePermissions()) { + requestRelevantRuntimePermissions() + } else { + connectionManager.startScan() + // kirbyDevices.clear() + //deviceListAdapter.notifyDataSetChanged() + } } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -231,7 +184,6 @@ class MainActivity : AppCompatActivity() { } } - override fun onResume() { super.onResume() if (!bluetoothAdapter.isEnabled) { @@ -296,51 +248,6 @@ class MainActivity : AppCompatActivity() { } } - @SuppressLint("MissingPermission") - val scanCallback = object : ScanCallback() { - override fun onScanResult(callbackType: Int, result: ScanResult) { - Log.d( - "ScanCallback", - "Found BLE device with address ${result.device.address} (name: ${result.device.name}, rssi: ${result.rssi})" - ) - if ((result.device.name ?: "").lowercase().contains("kirby")) { - Log.i( - "ScanCallback", - "Found Kirby device with name ${result.device.name} (address: ${result.device.address}, rssi: ${result.rssi})" - ) - val index = - kirbyScanResults.indexOfFirst { r -> r.device.address == result.device.address } - if (index == -1) { - kirbyScanResults.add(result); - scanResultAdapter.notifyItemInserted(index) - } else { - kirbyScanResults[index] = result - scanResultAdapter.notifyItemChanged(index) - } - } - } - - override fun onScanFailed(errorCode: Int) { - Log.e("ScanCallback", "onScanFailed: code $errorCode") - } - } - - private fun startBleScan() { - if (!hasRequiredRuntimePermissions()) { - requestRelevantRuntimePermissions() - } else { - kirbyScanResults.clear() - scanResultAdapter.notifyDataSetChanged() - bleScanner.startScan(null, scanSettings, scanCallback) - isScanning = true - } - } - - private fun stopBleScan() { - bleScanner.stopScan(scanCallback) - isScanning = false - } - private fun Activity.requestRelevantRuntimePermissions() { if (hasRequiredRuntimePermissions()) { return @@ -357,8 +264,6 @@ class MainActivity : AppCompatActivity() { } private fun requestLocationPermission() { - - val onClick = { dialog: DialogInterface, which: Int -> ActivityCompat.requestPermissions( this, @@ -366,8 +271,6 @@ class MainActivity : AppCompatActivity() { RUNTIME_PERMISSION_REQUEST_CODE ) } - - runOnUiThread { val builder = AlertDialog.Builder(this) @@ -389,7 +292,6 @@ class MainActivity : AppCompatActivity() { } private fun requestBluetoothPermissions() { - val onClick = { dialog: DialogInterface, which: Int -> ActivityCompat.requestPermissions( this, @@ -400,8 +302,6 @@ class MainActivity : AppCompatActivity() { RUNTIME_PERMISSION_REQUEST_CODE ) } - - runOnUiThread { val builder = AlertDialog.Builder(this) @@ -420,7 +320,63 @@ class MainActivity : AppCompatActivity() { show() } } + } +} +@SuppressLint("MissingPermission") +class DummyListEntry(override val address: String) : DeviceListEntry { + + override var rssi: Int = 99 + + override val name: String = "Device 123" + override val status: String + get() = "statusA, statusB" + + override fun getActions(): List { + return listOf(object : Action { + override fun getLabel(): String { + return "Test action 1" + } + + override fun getIcon(): String { + return "" + } + + override fun execute() { + + } + }, object : Action { + override fun getLabel(): String { + return "Test action 2" + } + + override fun getIcon(): String { + return "" + } + + override fun execute() { + + } + }) } + override fun getMeasurements(): List { + return listOf(object : Measurement { + override fun getLabel(): String { + return "Temperature" + } + + override fun getFormattedValue(): String { + return "21.2 °C" + } + }, object : Measurement { + override fun getLabel(): String { + return "Pressure" + } + + override fun getFormattedValue(): String { + return "232 bar" + } + }) + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/sensortestingapp/ScanResultAdapter.kt b/app/src/main/java/com/example/sensortestingapp/ScanResultAdapter.kt deleted file mode 100644 index 65bce61..0000000 --- a/app/src/main/java/com/example/sensortestingapp/ScanResultAdapter.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.example.sensortestingapp - -import android.annotation.SuppressLint -import android.bluetooth.le.ScanResult -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView - -@SuppressLint("MissingPermission") -class ScanResultAdapter( - private val items: List, - private val onClickListener: ((device: ScanResult) -> Unit) -) : RecyclerView.Adapter() { - - /** - * Provide a reference to the type of views that you are using - * (custom ViewHolder) - */ - class ViewHolder( - private val view: View, - private val onClickListener: ((device: ScanResult) -> Unit) - ) : RecyclerView.ViewHolder(view) { - - val deviceNameView: TextView - val macAddressView: TextView - val signalStrengthView: TextView - - init { - deviceNameView = view.findViewById(R.id.device_name) - macAddressView = view.findViewById(R.id.mac_address) - signalStrengthView = view.findViewById(R.id.signal_strength) - } - - fun bind(result: ScanResult) { - deviceNameView.text = result.device.name ?: "Unnamed" - macAddressView.text = result.device.address - signalStrengthView.text = "${result.rssi} dBm" - view.setOnClickListener { onClickListener.invoke(result) } - } - } - - override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { - // Create a new view, which defines the UI of the list item - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.row_scan_result, viewGroup, false) - - return ViewHolder(view, onClickListener) - } - - override fun getItemCount() = items.size - - // Replace the contents of a view (invoked by the layout manager) - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - - // Get element from your dataset at this position and replace the - // contents of the view with that element - val item = items[position] - holder.bind(item) - } - - -} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_more_vert_24.xml b/app/src/main/res/drawable/baseline_more_vert_24.xml new file mode 100644 index 0000000..39fbab5 --- /dev/null +++ b/app/src/main/res/drawable/baseline_more_vert_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/layout_bg.xml b/app/src/main/res/drawable/layout_bg.xml new file mode 100644 index 0000000..952ccfb --- /dev/null +++ b/app/src/main/res/drawable/layout_bg.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 5d660b1..30bf963 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -5,6 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" + android:background="@color/grey" tools:context=".MainActivity"> - - - + android:layout_height="?attr/actionBarSize" + style="@style/Widget.MaterialComponents.Toolbar.Primary" /> + - \ No newline at end of file diff --git a/app/src/main/res/layout/row_device_list.xml b/app/src/main/res/layout/row_device_list.xml new file mode 100644 index 0000000..bb70ec8 --- /dev/null +++ b/app/src/main/res/layout/row_device_list.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/row_scan_result.xml b/app/src/main/res/layout/row_measurements_list.xml similarity index 52% rename from app/src/main/res/layout/row_scan_result.xml rename to app/src/main/res/layout/row_measurements_list.xml index c978ac4..90101e0 100644 --- a/app/src/main/res/layout/row_scan_result.xml +++ b/app/src/main/res/layout/row_measurements_list.xml @@ -3,37 +3,26 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="wrap_content" - android:padding="8dp" - android:background="?android:attr/selectableItemBackground"> + android:layout_height="wrap_content"> + tools:text="Temperature" /> - - + app:layout_constraintTop_toTopOf="parent" + tools:text="20 °C" /> + + - \ No newline at end of file diff --git a/app/src/main/res/menu/device_menu.xml b/app/src/main/res/menu/device_menu.xml new file mode 100644 index 0000000..f9a106c --- /dev/null +++ b/app/src/main/res/menu/device_menu.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c8524cd..7d4e879 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -2,4 +2,5 @@ #FF000000 #FFFFFFFF + #ECECEC \ No newline at end of file