feat: display list of measurements
This commit is contained in:
@@ -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<BluetoothDevice, BluetoothGatt>()
|
||||
private val operationQueue = ConcurrentLinkedQueue<BleOperationType>()
|
||||
private var pendingOperation: BleOperationType? = null
|
||||
private var listeners = ArrayList<BleListener>()
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<Action>
|
||||
|
||||
fun getMeasurements(): List<Measurement>
|
||||
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class DeviceListAdapter(
|
||||
private val items: List<DeviceListEntry>,
|
||||
) : RecyclerView.Adapter<DeviceListAdapter.ViewHolder>() {
|
||||
|
||||
/**
|
||||
* 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 ?: "<N/A>"
|
||||
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<Measurement>(
|
||||
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<TextView>(R.id.measurement_label).text =
|
||||
measurements[position].getLabel()
|
||||
measurementView.findViewById<TextView>(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)
|
||||
}
|
||||
}
|
||||
262
app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt
Normal file
262
app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt
Normal file
@@ -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<DemoPayload>()
|
||||
|
||||
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<Measurement> {
|
||||
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<Action> {
|
||||
val actions = mutableListOf<Action>()
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<DeviceListEntry>();
|
||||
|
||||
private val kirbyScanResults = ArrayList<ScanResult>();
|
||||
|
||||
|
||||
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<Action> {
|
||||
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<Measurement> {
|
||||
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"
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<ScanResult>,
|
||||
private val onClickListener: ((device: ScanResult) -> Unit)
|
||||
) : RecyclerView.Adapter<ScanResultAdapter.ViewHolder>() {
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user