chore: rename project
This commit is contained in:
69
app/src/main/java/com/logitech/vc/kirbytest/BLEService.kt
Normal file
69
app/src/main/java/com/logitech/vc/kirbytest/BLEService.kt
Normal file
@@ -0,0 +1,69 @@
|
||||
package com.logitech.vc.kirbytest
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
class BLEService : Service() {
|
||||
private val CHANNEL_ID = "BLEService Kotlin"
|
||||
|
||||
companion object {
|
||||
|
||||
fun startService(context: Context, message: String) {
|
||||
val startIntent = Intent(context,
|
||||
BLEService::class.java)
|
||||
startIntent.putExtra("inputExtra", message)
|
||||
ContextCompat.startForegroundService(context, startIntent)
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
val stopIntent = Intent(context, BLEService::class.java)
|
||||
context.stopService(stopIntent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
||||
//do heavy work on a background thread
|
||||
val input = intent?.getStringExtra("inputExtra")
|
||||
createNotificationChannel()
|
||||
val notificationIntent = Intent(this, MainActivity::class.java)
|
||||
|
||||
val pendingIntent: PendingIntent =
|
||||
Intent(this, MainActivity::class.java).let { notificationIntent ->
|
||||
PendingIntent.getActivity(this, 0, notificationIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("BLEService Service Kotlin Example")
|
||||
.setContentText(input)
|
||||
//.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
startForeground(1, notification)
|
||||
//stopSelf();
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val serviceChannel = NotificationChannel(CHANNEL_ID, "BLEService Service Channel",
|
||||
NotificationManager.IMPORTANCE_DEFAULT)
|
||||
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager!!.createNotificationChannel(serviceChannel)
|
||||
}
|
||||
}
|
||||
}
|
||||
779
app/src/main/java/com/logitech/vc/kirbytest/ConnectionManager.kt
Normal file
779
app/src/main/java/com/logitech/vc/kirbytest/ConnectionManager.kt
Normal file
@@ -0,0 +1,779 @@
|
||||
package com.logitech.vc.kirbytest
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Service
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothDevice.BOND_BONDED
|
||||
import android.bluetooth.BluetoothDevice.BOND_BONDING
|
||||
import android.bluetooth.BluetoothDevice.BOND_NONE
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGatt.GATT_SUCCESS
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
val CCC_DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
|
||||
|
||||
private const val GATT_MIN_MTU_SIZE = 23
|
||||
|
||||
/** Maximum BLE MTU size as defined in gatt_api.h. */
|
||||
private const val GATT_MAX_MTU_SIZE = 517
|
||||
|
||||
|
||||
/** Abstract sealed class representing a type of BLE operation */
|
||||
sealed class BleOperationType {
|
||||
abstract val device: BluetoothDevice
|
||||
}
|
||||
|
||||
/** Connect to [device] and perform service discovery */
|
||||
data class Connect(override val device: BluetoothDevice, val context: Context) : BleOperationType()
|
||||
|
||||
/** Disconnect from [device] and release all connection resources */
|
||||
data class Disconnect(override val device: BluetoothDevice) : BleOperationType()
|
||||
|
||||
|
||||
/** Request for an MTU of [mtu] */
|
||||
data class MtuRequest(
|
||||
override val device: BluetoothDevice,
|
||||
val mtu: Int
|
||||
) : BleOperationType()
|
||||
|
||||
|
||||
data class ReadChar(
|
||||
override val device: BluetoothDevice,
|
||||
val serviceId: UUID,
|
||||
val charId: UUID
|
||||
) : BleOperationType()
|
||||
|
||||
|
||||
data class SetNotification(
|
||||
override val device: BluetoothDevice,
|
||||
val serviceId: UUID,
|
||||
val charId: UUID,
|
||||
val enable: Boolean
|
||||
) : BleOperationType()
|
||||
|
||||
data class DiscoverServicesRequest(
|
||||
override val device: BluetoothDevice,
|
||||
) : BleOperationType()
|
||||
|
||||
data class BondRequest(
|
||||
override val device: BluetoothDevice,
|
||||
) : BleOperationType()
|
||||
|
||||
data class UnbondRequest(
|
||||
override val device: BluetoothDevice,
|
||||
) : BleOperationType()
|
||||
|
||||
data class ReadRemoteRssi(
|
||||
override val device: BluetoothDevice,
|
||||
) : BleOperationType()
|
||||
|
||||
|
||||
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 onConnectToBondedFailed(gatt: BluetoothGatt) {}
|
||||
|
||||
open fun onDisconnect(gatt: BluetoothGatt) {}
|
||||
|
||||
open fun onSuccessfulCharRead(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic
|
||||
) {
|
||||
}
|
||||
|
||||
open fun onCharChange(
|
||||
gatt: BluetoothGatt,
|
||||
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) {
|
||||
|
||||
}
|
||||
|
||||
open fun onQueueSizeChange(groupedOps: Map<String, List<BleOperationType>>) {
|
||||
|
||||
}
|
||||
|
||||
open fun onBonded(device: BluetoothDevice) {
|
||||
|
||||
}
|
||||
|
||||
open fun onUnbonded(device: BluetoothDevice) {
|
||||
|
||||
}
|
||||
|
||||
open fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun BluetoothGatt.printGattTable() {
|
||||
if (services.isEmpty()) {
|
||||
Log.i(
|
||||
"printGattTable",
|
||||
"No service and characteristic available, call discoverServices() first?"
|
||||
)
|
||||
return
|
||||
}
|
||||
services.forEach { service ->
|
||||
val characteristicsTable = service.characteristics.joinToString(
|
||||
separator = "\n|--",
|
||||
prefix = "|--"
|
||||
) {
|
||||
"${it.uuid.toString()} | " +
|
||||
"readable: ${it.isReadable()}, " +
|
||||
"writable: ${it.isWritable()}, " +
|
||||
"writableWithoutResponse: ${it.isWritableWithoutResponse()}, " +
|
||||
"notifiable: ${it.isNotifiable()}, " +
|
||||
"indicatable: ${it.isIndicatable()}, "
|
||||
}
|
||||
Log.i(
|
||||
"printGattTable", "\nService ${service.uuid}\nCharacteristics:\n$characteristicsTable"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun BluetoothGattCharacteristic.isReadable(): Boolean =
|
||||
containsProperty(BluetoothGattCharacteristic.PROPERTY_READ)
|
||||
|
||||
fun BluetoothGattCharacteristic.isWritable(): Boolean =
|
||||
containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE)
|
||||
|
||||
fun BluetoothGattCharacteristic.isWritableWithoutResponse(): Boolean =
|
||||
containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE)
|
||||
|
||||
fun BluetoothGattCharacteristic.isIndicatable(): Boolean =
|
||||
containsProperty(BluetoothGattCharacteristic.PROPERTY_INDICATE)
|
||||
|
||||
fun BluetoothGattCharacteristic.isNotifiable(): Boolean =
|
||||
containsProperty(BluetoothGattCharacteristic.PROPERTY_NOTIFY)
|
||||
|
||||
fun BluetoothGattCharacteristic.containsProperty(property: Int): Boolean =
|
||||
properties and property != 0
|
||||
|
||||
fun ByteArray.toHexString(): String =
|
||||
joinToString(separator = "", prefix = "0x") { String.format("%02X", it) }
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
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 scanFilters = listOf( ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString(BuildConfig.SERVICE_UUID)).build())
|
||||
|
||||
private val bleScanner by lazy {
|
||||
bleAdapter.bluetoothLeScanner
|
||||
}
|
||||
|
||||
init {
|
||||
val broadcastReceiver = object : BroadcastReceiver() {
|
||||
|
||||
private fun successfulBondingAttempt(prev: Int, curr: Int): Boolean {
|
||||
return prev == BOND_BONDING && curr == BOND_BONDED || prev == BOND_NONE && curr == BOND_BONDED
|
||||
}
|
||||
|
||||
private fun unsuccessfulBondingAttempt(prev: Int, curr: Int): Boolean {
|
||||
return prev == BOND_BONDING && curr == BOND_NONE
|
||||
}
|
||||
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
|
||||
val device =
|
||||
intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)!!
|
||||
val previousBondState =
|
||||
intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1)
|
||||
val bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
|
||||
val bondTransition = "${previousBondState.toBondStateDescription()} to " +
|
||||
bondState.toBondStateDescription()
|
||||
Log.i(
|
||||
"ConnectionManager",
|
||||
"${device.address} bond state changed | $bondTransition"
|
||||
)
|
||||
|
||||
if (bondState == BOND_BONDED) {
|
||||
notifyListeners(device.address) { it.onBonded(device) }
|
||||
}
|
||||
if (bondState == BOND_NONE) {
|
||||
notifyListeners(device.address) { it.onUnbonded(device) }
|
||||
}
|
||||
|
||||
val operation = pendingOperation
|
||||
if (
|
||||
(operation is BondRequest && (successfulBondingAttempt(
|
||||
previousBondState, bondState
|
||||
) || unsuccessfulBondingAttempt(
|
||||
previousBondState, bondState
|
||||
)) || operation is UnbondRequest && bondState == BOND_NONE)
|
||||
&& operation.device.address == device.address
|
||||
) {
|
||||
signalEndOfOperation(operation)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun Int.toBondStateDescription() = when (this) {
|
||||
BOND_BONDED -> "BONDED"
|
||||
BOND_BONDING -> "BONDING"
|
||||
BOND_NONE -> "NOT BONDED"
|
||||
else -> "ERROR: $this"
|
||||
}
|
||||
}
|
||||
|
||||
context.registerReceiver(
|
||||
broadcastReceiver,
|
||||
IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
fun startScan() {
|
||||
if (!isScanning) {
|
||||
isScanning = true
|
||||
bleScanner.startScan( null, scanSettings, scanCallback)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopScan() {
|
||||
bleScanner.stopScan(scanCallback)
|
||||
isScanning = false
|
||||
}
|
||||
|
||||
|
||||
fun connect(device: BluetoothDevice) {
|
||||
if (device.isConnected()) {
|
||||
Log.e("ConnectionManager", "Already connected to ${device.address}!")
|
||||
} else {
|
||||
enqueueOperation(Connect(device, context))
|
||||
}
|
||||
}
|
||||
|
||||
fun teardownConnection(device: BluetoothDevice) {
|
||||
if (device.isConnected()) {
|
||||
enqueueOperation(Disconnect(device))
|
||||
} else {
|
||||
Log.e(
|
||||
"ConnectionManager",
|
||||
"Not connected to ${device.address}, cannot teardown connection!"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
enqueueOperation(ReadChar(device, service, char))
|
||||
}
|
||||
|
||||
fun enableNotification(device: BluetoothDevice, service: UUID, char: UUID) {
|
||||
enqueueOperation(SetNotification(device, service, char, true))
|
||||
}
|
||||
|
||||
fun disableNotification(device: BluetoothDevice, service: UUID, char: UUID) {
|
||||
enqueueOperation(SetNotification(device, service, char, false))
|
||||
}
|
||||
|
||||
fun bond(device: BluetoothDevice) {
|
||||
enqueueOperation(BondRequest(device))
|
||||
}
|
||||
|
||||
fun unbond(device: BluetoothDevice) {
|
||||
enqueueOperation(UnbondRequest(device))
|
||||
}
|
||||
|
||||
fun readRemoteRssi(device: BluetoothDevice) {
|
||||
enqueueOperation(ReadRemoteRssi(device))
|
||||
}
|
||||
|
||||
|
||||
// - Beginning of PRIVATE functions
|
||||
|
||||
private fun notifyListeners(address: String?, notifier: (listener: BleListener) -> Unit) {
|
||||
listeners.filter { n -> n.isRelevantMessage(address) }.forEach(notifier)
|
||||
}
|
||||
|
||||
private fun notifyListenersOfQueueChange(address: String) {
|
||||
notifyListeners(address) { listener ->
|
||||
listener.onQueueSizeChange(
|
||||
operationQueue.groupBy { o -> o.device.address }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Synchronized
|
||||
private fun enqueueOperation(operation: BleOperationType) {
|
||||
if (isScanning) {
|
||||
stopScan()
|
||||
}
|
||||
operationQueue.add(operation)
|
||||
notifyListenersOfQueueChange(operation.device.address)
|
||||
if (pendingOperation == null) {
|
||||
doNextOperation()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun signalEndOfOperation(op: BleOperationType) {
|
||||
Log.d("ConnectionManager", "End of $op")
|
||||
pendingOperation = null
|
||||
notifyListenersOfQueueChange(op.device.address)
|
||||
if (operationQueue.isNotEmpty()) {
|
||||
doNextOperation()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun doNextOperation() {
|
||||
if (pendingOperation != null) {
|
||||
Log.e(
|
||||
"ConnectionManager",
|
||||
"doNextOperation() called when an operation is pending! Aborting."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val operation = operationQueue.poll() ?: run {
|
||||
Log.d("ConnectionManager", "Operation queue empty, returning")
|
||||
return
|
||||
}
|
||||
pendingOperation = operation
|
||||
|
||||
// Handle Connect separately from other operations that require device to be connected
|
||||
if (operation is Connect) {
|
||||
with(operation) {
|
||||
Log.w("ConnectionManager", "Connecting to ${device.address}")
|
||||
device.connectGatt(
|
||||
context, true, callback, BluetoothDevice.TRANSPORT_LE
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (operation is BondRequest) {
|
||||
if (operation.device.bondState == BOND_NONE) {
|
||||
if (!operation.device.createBond()) {
|
||||
Log.e(
|
||||
"ConnectionManager", "createBond() returned false " +
|
||||
"for device ${operation.device.address}"
|
||||
)
|
||||
signalEndOfOperation(operation)
|
||||
}
|
||||
} else {
|
||||
Log.e(
|
||||
"ConnectionManager",
|
||||
"BondRequest for device ${operation.device.address} aborted. " +
|
||||
"operation.device.bondState (${operation.device.bondState}) == BOND_NONE (${BOND_NONE})"
|
||||
)
|
||||
signalEndOfOperation(operation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (operation is UnbondRequest) {
|
||||
if (operation.device.bondState == BOND_BONDED) {
|
||||
val method = operation.device.javaClass.getMethod("removeBond")
|
||||
val removeBondSuccessful = method.invoke(operation.device) as Boolean
|
||||
if (!removeBondSuccessful) {
|
||||
Log.e(
|
||||
"ConnectionManager", "removeBond() returned false " +
|
||||
"for device ${operation.device.address}"
|
||||
)
|
||||
signalEndOfOperation(operation)
|
||||
}
|
||||
} else {
|
||||
Log.e(
|
||||
"ConnectionManager",
|
||||
"UnbondRequest for device ${operation.device.address} aborted. " +
|
||||
"operation.device.bondState (${operation.device.bondState}) == BOND_BONDED (${BOND_BONDED})"
|
||||
)
|
||||
signalEndOfOperation(operation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check BluetoothGatt availability for other operations
|
||||
val gatt = deviceGattMap[operation.device]
|
||||
?: this@ConnectionManager.run {
|
||||
Log.w(
|
||||
"ConnectionManager",
|
||||
"Not connected to ${operation.device.address}! Aborting $operation operation."
|
||||
)
|
||||
signalEndOfOperation(operation)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
when (operation) {
|
||||
is Disconnect -> with(operation) {
|
||||
Log.w("ConnectionManager", "Disconnecting from ${device.address}")
|
||||
gatt.close()
|
||||
deviceGattMap.remove(device)
|
||||
notifyListeners(gatt.device.address) {
|
||||
it.onDisconnect(gatt)
|
||||
}
|
||||
signalEndOfOperation(operation)
|
||||
}
|
||||
|
||||
is MtuRequest -> with(operation) {
|
||||
gatt.requestMtu(mtu)
|
||||
}
|
||||
|
||||
is DiscoverServicesRequest -> with(operation) {
|
||||
gatt.discoverServices()
|
||||
}
|
||||
|
||||
is ReadChar -> with(operation) {
|
||||
val characteristic = gatt.getService(serviceId)?.getCharacteristic(charId)
|
||||
if (characteristic?.isReadable() == true) {
|
||||
gatt.readCharacteristic(characteristic)
|
||||
} else {
|
||||
Log.e("ConnectionManager", "Char $charId (${serviceId}) is not readable!")
|
||||
signalEndOfOperation(operation)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
is SetNotification -> with(operation) {
|
||||
val characteristic = gatt.getService(serviceId)?.getCharacteristic(charId);
|
||||
|
||||
if (characteristic == null) {
|
||||
Log.e("ConnectionManager", "Char $charId (${serviceId}) not found!")
|
||||
signalEndOfOperation(operation)
|
||||
return
|
||||
}
|
||||
val descriptor = characteristic.getDescriptor(CCC_DESCRIPTOR_UUID)
|
||||
|
||||
if (!characteristic.isNotifiable() && !characteristic.isIndicatable()) {
|
||||
Log.e(
|
||||
"ConnectionManager", "Char ${characteristic.uuid} (service: $serviceId)" +
|
||||
" doesn't support notifications/indications"
|
||||
)
|
||||
signalEndOfOperation(operation)
|
||||
return
|
||||
}
|
||||
|
||||
val payload: ByteArray
|
||||
|
||||
if (enable) {
|
||||
if (!gatt.setCharacteristicNotification(characteristic, true)) {
|
||||
Log.e(
|
||||
"ConnectionManager", "setCharacteristicNotification to true " +
|
||||
"failed for ${characteristic.uuid} (service: $serviceId)"
|
||||
)
|
||||
signalEndOfOperation(operation)
|
||||
return
|
||||
}
|
||||
payload =
|
||||
if (characteristic.isIndicatable())
|
||||
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
|
||||
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
|
||||
|
||||
} else {
|
||||
if (!gatt.setCharacteristicNotification(characteristic, false)) {
|
||||
Log.e(
|
||||
"ConnectionManager", "setCharacteristicNotification to false " +
|
||||
"failed for ${characteristic.uuid} (service: $serviceId)"
|
||||
)
|
||||
signalEndOfOperation(operation)
|
||||
return
|
||||
}
|
||||
payload = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
|
||||
}
|
||||
|
||||
gatt.let { gatt ->
|
||||
descriptor.value = payload
|
||||
gatt.writeDescriptor(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
is ReadRemoteRssi -> {
|
||||
gatt.readRemoteRssi()
|
||||
}
|
||||
|
||||
|
||||
// necessary because of IDE type inference bug
|
||||
// https://youtrack.jetbrains.com/issue/KTIJ-20749/Exhaustive-when-check-does-not-take-into-account-the-values-excluded-by-previous-if-conditions
|
||||
is Connect, is BondRequest, is UnbondRequest -> {
|
||||
Log.e("ConnectionManager", "Shouldn't get here: $operation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
val deviceAddress = gatt.device.address
|
||||
val operation = pendingOperation
|
||||
|
||||
if (operation is Connect
|
||||
&& operation.device.bondState == BOND_BONDED
|
||||
&& deviceAddress == operation.device.address
|
||||
&& status == 133
|
||||
&& newState == STATE_DISCONNECTED
|
||||
) {
|
||||
Log.i(
|
||||
"ConnectionManager",
|
||||
"onConnectionStateChange: Timeout when connecting to bonded device $deviceAddress. " +
|
||||
"Device probably not in vicinity."
|
||||
)
|
||||
notifyListeners(gatt.device.address) {
|
||||
it.onConnectToBondedFailed(gatt)
|
||||
}
|
||||
signalEndOfOperation(operation)
|
||||
} else if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
Log.i(
|
||||
"ConnectionManager",
|
||||
"onConnectionStateChange: connected to $deviceAddress"
|
||||
)
|
||||
deviceGattMap[gatt.device] = gatt
|
||||
notifyListeners(gatt.device.address) {
|
||||
it.onConnect(gatt)
|
||||
}
|
||||
|
||||
if (operation is Connect) {
|
||||
signalEndOfOperation(operation)
|
||||
}
|
||||
} else if (newState == STATE_DISCONNECTED) {
|
||||
Log.e(
|
||||
"ConnectionManager",
|
||||
"onConnectionStateChange: disconnected from $deviceAddress"
|
||||
)
|
||||
notifyListeners(gatt.device.address) {
|
||||
it.onDisconnect(gatt)
|
||||
}
|
||||
teardownConnection(gatt.device)
|
||||
}
|
||||
} else {
|
||||
Log.e(
|
||||
"ConnectionManager",
|
||||
"onConnectionStateChange: status $status encountered for $deviceAddress!"
|
||||
)
|
||||
teardownConnection(gatt.device)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
with(gatt) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
val operation = pendingOperation
|
||||
if (operation is DiscoverServicesRequest) {
|
||||
signalEndOfOperation(operation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
|
||||
Log.w(
|
||||
"ConnectionManager",
|
||||
"ATT MTU changed to $mtu, success: ${status == BluetoothGatt.GATT_SUCCESS}"
|
||||
)
|
||||
|
||||
val operation = pendingOperation
|
||||
if (operation is MtuRequest) {
|
||||
signalEndOfOperation(operation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicRead(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int
|
||||
) {
|
||||
with(characteristic) {
|
||||
when (status) {
|
||||
BluetoothGatt.GATT_SUCCESS -> {
|
||||
Log.i(
|
||||
"ConnectionManager",
|
||||
"Read characteristic $uuid (service: ${service.uuid}): ${value.toHexString()}"
|
||||
)
|
||||
notifyListeners(gatt.device.address) { listener ->
|
||||
listener.onSuccessfulCharRead(
|
||||
gatt,
|
||||
characteristic
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BluetoothGatt.GATT_READ_NOT_PERMITTED -> {
|
||||
Log.e(
|
||||
"BluetoothGattCallback",
|
||||
"Read not permitted for $uuid (service: ${service.uuid})!"
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.e(
|
||||
"BluetoothGattCallback",
|
||||
"Characteristic read failed for $uuid (service: ${service.uuid}), error: $status"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val op = pendingOperation
|
||||
if (op is ReadChar && op.charId == characteristic.uuid && op.serviceId == characteristic.service.uuid) {
|
||||
signalEndOfOperation(op)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(
|
||||
gatt: BluetoothGatt,
|
||||
descriptor: BluetoothGattDescriptor,
|
||||
status: Int
|
||||
) {
|
||||
val op = pendingOperation
|
||||
if (op is SetNotification &&
|
||||
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(op)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic
|
||||
) {
|
||||
notifyListeners(gatt.device.address) { listener ->
|
||||
listener.onCharChange(
|
||||
gatt,
|
||||
characteristic
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
|
||||
if (status == GATT_SUCCESS) {
|
||||
notifyListeners(gatt.device.address) { it.onReadRemoteRssi(gatt, rssi) }
|
||||
} else {
|
||||
Log.e(
|
||||
"BluetoothGattCallback",
|
||||
"ReadRemoteRssi failed for ${gatt.device.address}, error: $status"
|
||||
)
|
||||
}
|
||||
val op = pendingOperation
|
||||
if (op is ReadRemoteRssi && gatt.device.address == op.device.address) {
|
||||
signalEndOfOperation(op)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun BluetoothDevice.isConnected() = deviceGattMap.containsKey(this)
|
||||
}
|
||||
161
app/src/main/java/com/logitech/vc/kirbytest/Database.kt
Normal file
161
app/src/main/java/com/logitech/vc/kirbytest/Database.kt
Normal file
@@ -0,0 +1,161 @@
|
||||
package com.logitech.vc.kirbytest
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.net.Uri
|
||||
import android.provider.BaseColumns
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
|
||||
object LoggerContract {
|
||||
// Table contents are grouped together in an anonymous object.
|
||||
object LogEntry : BaseColumns {
|
||||
const val TABLE_NAME = "measurements"
|
||||
const val COLUMN_NAME_TS = "ts"
|
||||
const val COLUMN_NAME_PAYLOAD = "payload"
|
||||
|
||||
}
|
||||
|
||||
private const val SQL_CREATE_ENTRIES =
|
||||
"CREATE TABLE ${LogEntry.TABLE_NAME} (" +
|
||||
"${BaseColumns._ID} INTEGER PRIMARY KEY," +
|
||||
"${LogEntry.COLUMN_NAME_TS} TEXT," +
|
||||
"${LogEntry.COLUMN_NAME_PAYLOAD} TEXT)"
|
||||
|
||||
private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${LogEntry.TABLE_NAME}"
|
||||
|
||||
class LoggerDbHelper(context: Context) :
|
||||
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(SQL_CREATE_ENTRIES)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
// This database is only a cache for online data, so its upgrade policy is
|
||||
// to simply to discard the data and start over
|
||||
db.execSQL(SQL_DELETE_ENTRIES)
|
||||
onCreate(db)
|
||||
}
|
||||
|
||||
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
onUpgrade(db, oldVersion, newVersion)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
// If you change the database schema, you must increment the database version.
|
||||
const val DATABASE_VERSION = 2
|
||||
const val DATABASE_NAME = "Logger.db"
|
||||
}
|
||||
}
|
||||
|
||||
class LoggerDb(context: Context) {
|
||||
|
||||
private val dbHelper = LoggerDbHelper(context)
|
||||
private val dbWrite = dbHelper.writableDatabase
|
||||
private val dbRead = dbHelper.writableDatabase
|
||||
val context: Context = context
|
||||
|
||||
fun writeLog(payload: Any): Long? {
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(payload)
|
||||
|
||||
val date = LocalDateTime.now()
|
||||
val ts = date.format(DateTimeFormatter.ISO_DATE_TIME)
|
||||
|
||||
Log.i("Database", jsonString)
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(LogEntry.COLUMN_NAME_TS, ts)
|
||||
put(LogEntry.COLUMN_NAME_PAYLOAD, jsonString)
|
||||
}
|
||||
|
||||
return dbWrite?.insert(LogEntry.TABLE_NAME, null, values)
|
||||
}
|
||||
|
||||
|
||||
fun exportToUri(uri: Uri) {
|
||||
val projection =
|
||||
arrayOf(BaseColumns._ID, LogEntry.COLUMN_NAME_PAYLOAD, LogEntry.COLUMN_NAME_TS)
|
||||
|
||||
val sortOrder = "${BaseColumns._ID} ASC"
|
||||
|
||||
val cursor = dbRead.query(
|
||||
LogEntry.TABLE_NAME, // The table to query
|
||||
projection, // The array of columns to return (pass null to get all)
|
||||
null, // The columns for the WHERE clause
|
||||
null, // The values for the WHERE clause
|
||||
null, // don't group the rows
|
||||
null, // don't filter by row groups
|
||||
sortOrder // The sort order
|
||||
)
|
||||
|
||||
try {
|
||||
|
||||
val gson = Gson()
|
||||
val type = object : TypeToken<HashMap<String, Any>>() {}.type
|
||||
var headerWritten = false
|
||||
val sep = ","
|
||||
|
||||
context.contentResolver.openOutputStream(uri)?.use { writer ->
|
||||
val newLine = '\n'
|
||||
|
||||
with(cursor) {
|
||||
while (moveToNext()) {
|
||||
val ts = getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_TS))
|
||||
val storedField =
|
||||
getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_PAYLOAD))
|
||||
|
||||
try {
|
||||
val payload: HashMap<String, Any> = gson.fromJson(storedField, type)
|
||||
|
||||
if (!headerWritten) {
|
||||
val headerRow =
|
||||
"timestamp" + sep + payload.keys.joinToString(sep) + newLine
|
||||
writer.write(headerRow.toByteArray())
|
||||
|
||||
headerWritten = true
|
||||
}
|
||||
|
||||
val row = ts + sep + payload.values.joinToString(sep) + newLine
|
||||
|
||||
writer.write(row.toByteArray())
|
||||
} catch (exception: JsonSyntaxException) {
|
||||
Log.e("db", exception.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
truncate()
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
cursor.close()
|
||||
}
|
||||
|
||||
private fun truncate() {
|
||||
dbWrite.execSQL("DELETE FROM ${LogEntry.TABLE_NAME}");
|
||||
dbWrite.execSQL("VACUUM");
|
||||
}
|
||||
|
||||
fun close() {
|
||||
dbHelper.close()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
117
app/src/main/java/com/logitech/vc/kirbytest/DecoderIaq.kt
Normal file
117
app/src/main/java/com/logitech/vc/kirbytest/DecoderIaq.kt
Normal file
@@ -0,0 +1,117 @@
|
||||
package com.logitech.vc.kirbytest
|
||||
|
||||
import android.util.Log
|
||||
import org.apache.commons.codec.DecoderException
|
||||
import org.apache.commons.codec.binary.Hex
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object DecoderIaq {
|
||||
private const val INVALID_CO2 = 16383
|
||||
private const val INVALID_VOC = 2047
|
||||
private const val INVALID_HUMIDITY = 1023
|
||||
private const val INVALID_TEMPERATURE = 2047
|
||||
private const val INVALID_PRESSURE = 65535
|
||||
private const val INVALID_OCCUPANCY = 3
|
||||
private const val INVALID_PM25 = 1023
|
||||
private const val INVALID_PM10 = 1023
|
||||
|
||||
private val supportedMessageTypes = listOf<Number>(0, 1)
|
||||
|
||||
fun parseMeasurement(input: String): Measurement? {
|
||||
val measurement = Measurement()
|
||||
val inputBytes = hexStringToByteArray(input)
|
||||
val msgType = inputBytes[0].toInt() and 0xFF ushr 4
|
||||
|
||||
if(!supportedMessageTypes.contains(msgType)) {
|
||||
Log.i("Decoder", "Invalid message type: $msgType")
|
||||
return null;
|
||||
}
|
||||
|
||||
measurement.msgType = msgType
|
||||
|
||||
val co2 = parseUnsignedInt(inputBytes, 0, 3) ushr 6 and INVALID_CO2
|
||||
measurement.co2 = if (co2 == INVALID_CO2) null else co2
|
||||
|
||||
val voc = parseUnsignedInt(inputBytes, 2, 4) ushr 3 and INVALID_VOC
|
||||
measurement.voc = if (co2 == INVALID_VOC) null else voc
|
||||
|
||||
val humidity =
|
||||
parseUnsignedInt(inputBytes, 3, 5) ushr 1 and INVALID_HUMIDITY
|
||||
measurement.humidity = if (humidity == INVALID_HUMIDITY) null else humidity / 10
|
||||
|
||||
val temperature =
|
||||
parseUnsignedInt(inputBytes, 4, 7) ushr 6 and INVALID_TEMPERATURE
|
||||
measurement.temperature =
|
||||
if (temperature == INVALID_TEMPERATURE) null else ((temperature / 10.0 - 40) * 10.0).roundToInt() / 10.0
|
||||
|
||||
val pressure =
|
||||
parseUnsignedInt(inputBytes, 6, 9) ushr 6 and INVALID_PRESSURE
|
||||
measurement.pressure =
|
||||
if (pressure == INVALID_PRESSURE) null else (30000 + 19000.0 * pressure / 13107).roundToInt()
|
||||
|
||||
val occupancy =
|
||||
parseUnsignedInt(inputBytes, 8, 9) ushr 4 and INVALID_OCCUPANCY
|
||||
measurement.occupancy = if (occupancy == INVALID_OCCUPANCY) null else occupancy
|
||||
|
||||
if (msgType == 0) {
|
||||
val pm25 =
|
||||
parseUnsignedInt(inputBytes, 8, 10) ushr 2 and INVALID_PM25
|
||||
measurement.pm25 = if (pm25 == INVALID_PM25) null else pm25
|
||||
|
||||
val pm10 = parseUnsignedInt(inputBytes, 9, 11) and INVALID_PM10
|
||||
measurement.pm10 = if (pm10 == INVALID_PM10) null else pm10
|
||||
}
|
||||
return measurement
|
||||
}
|
||||
|
||||
private fun parseUnsignedInt(bytes: ByteArray, startIncl: Int, endExcl: Int): Int {
|
||||
val section = bytes.copyOfRange(startIncl, min(bytes.size, endExcl))
|
||||
var unsignedInt = 0
|
||||
for (i in section.indices) {
|
||||
unsignedInt = unsignedInt shl 8
|
||||
unsignedInt = unsignedInt or (section[i].toInt() and 0xFF)
|
||||
}
|
||||
return unsignedInt
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(encoded: String): ByteArray {
|
||||
return try {
|
||||
Hex.decodeHex(encoded)
|
||||
} catch (e: DecoderException) {
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
}
|
||||
|
||||
data class Measurement (
|
||||
var msgType: Number? = null,
|
||||
var co2: Number? = null,
|
||||
var voc: Number? = null,
|
||||
var humidity: Number? = null,
|
||||
var temperature: Number? = null,
|
||||
var pressure: Number? = null,
|
||||
var occupancy: Number? = null,
|
||||
var pm25: Number? = null,
|
||||
var pm10: Number? = null
|
||||
) {
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return "M{" +
|
||||
"type=" + msgType +
|
||||
", co2=" + co2 +
|
||||
", voc=" + voc +
|
||||
", hum=" + humidity +
|
||||
", temp=" + temperature +
|
||||
", press=" + pressure +
|
||||
", pm25=" + pm25 +
|
||||
", pm10=" + pm10 +
|
||||
", occ=" + occupancy +
|
||||
'}'
|
||||
}
|
||||
|
||||
fun toCsv() : String {
|
||||
return "${msgType},${co2},${voc},${humidity},${temperature},${pressure}${pm25},${pm10},${occupancy}"
|
||||
}
|
||||
}
|
||||
}
|
||||
185
app/src/main/java/com/logitech/vc/kirbytest/DeviceListAdapter.kt
Normal file
185
app/src/main/java/com/logitech/vc/kirbytest/DeviceListAdapter.kt
Normal file
@@ -0,0 +1,185 @@
|
||||
package com.logitech.vc.kirbytest
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.ListView
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
interface Action {
|
||||
fun getLabel(): String
|
||||
fun getIcon(): Int?
|
||||
fun execute(): Unit
|
||||
}
|
||||
|
||||
interface Measurement {
|
||||
fun getLabel(): String
|
||||
fun getIcon(): Int?
|
||||
fun getFormattedValue(): String
|
||||
}
|
||||
|
||||
interface DeviceListEntry {
|
||||
val address: String
|
||||
|
||||
var rssi: Int?
|
||||
|
||||
val name: String?
|
||||
|
||||
val status: String?
|
||||
|
||||
var hasRunningOp: Boolean
|
||||
|
||||
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
|
||||
val deviceProgress: ProgressBar
|
||||
|
||||
|
||||
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)
|
||||
deviceProgress = view.findViewById(R.id.device_progress)
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun bind(result: DeviceListEntry) {
|
||||
val rssi = result.rssi
|
||||
deviceNameView.text = result.name ?: "<N/A>"
|
||||
deviceProgress.visibility = if (result.hasRunningOp) VISIBLE else INVISIBLE
|
||||
macAddressView.text = result.address
|
||||
signalStrengthView.text = "${rssi ?: "-"} dBm"
|
||||
var signalStrengthIcon = R.drawable.signal_strength_weak
|
||||
if (rssi == null) {
|
||||
signalStrengthIcon = R.drawable.signal_strength_none
|
||||
} else if (rssi >= -55) {
|
||||
signalStrengthIcon = R.drawable.signal_strength_strong
|
||||
} else if (rssi >= -80) {
|
||||
signalStrengthIcon = R.drawable.signal_strength_medium
|
||||
}
|
||||
signalStrengthView.setCompoundDrawablesWithIntrinsicBounds(
|
||||
0,
|
||||
0,
|
||||
signalStrengthIcon,
|
||||
0
|
||||
)
|
||||
statusView.text = result.status
|
||||
deviceActions.setOnClickListener {
|
||||
val popup = PopupMenu(context, deviceActions)
|
||||
result.getActions().forEach { action ->
|
||||
val menuItem = popup.menu.add(action.getLabel())
|
||||
menuItem.setOnMenuItemClickListener { item ->
|
||||
action.execute()
|
||||
true
|
||||
}
|
||||
val menuIcon = action.getIcon()
|
||||
if (menuIcon != null) {
|
||||
menuItem.setIcon(menuIcon)
|
||||
}
|
||||
}
|
||||
val inflater = popup.menuInflater
|
||||
popup.setForceShowIcon(true)
|
||||
inflater.inflate(R.menu.device_menu, popup.menu)
|
||||
popup.show()
|
||||
}
|
||||
|
||||
val measurements = result.getMeasurements()
|
||||
val measurementsAdapter = object :
|
||||
ArrayAdapter<Measurement>(
|
||||
context,
|
||||
R.layout.row_measurements_list,
|
||||
measurements
|
||||
) {
|
||||
override fun getView(
|
||||
position: Int,
|
||||
convertView: View?,
|
||||
parent: ViewGroup
|
||||
): View {
|
||||
|
||||
val measurement = measurements[position]
|
||||
val measurementView = convertView ?: LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.row_measurements_list, parent, false)
|
||||
val labelView = measurementView.findViewById<TextView>(R.id.measurement_title)
|
||||
labelView.text = measurement.getLabel()
|
||||
labelView.setCompoundDrawablesWithIntrinsicBounds(
|
||||
measurement.getIcon() ?: 0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
measurementView.findViewById<TextView>(R.id.measurement_body).text =
|
||||
measurement.getFormattedValue()
|
||||
return measurementView
|
||||
}
|
||||
|
||||
override fun isEnabled(position: Int): Boolean {
|
||||
// make measurement rows unclickable
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
measurementsListView.adapter = measurementsAdapter
|
||||
|
||||
if (measurements.isEmpty()) {
|
||||
measurementsListView.visibility = GONE
|
||||
} else {
|
||||
measurementsListView.visibility = VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
390
app/src/main/java/com/logitech/vc/kirbytest/KirbyDevice.kt
Normal file
390
app/src/main/java/com/logitech/vc/kirbytest/KirbyDevice.kt
Normal file
@@ -0,0 +1,390 @@
|
||||
package com.logitech.vc.kirbytest
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothDevice.BOND_BONDED
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.android.volley.Request
|
||||
import com.android.volley.RequestQueue
|
||||
import com.android.volley.toolbox.JsonObjectRequest
|
||||
import com.android.volley.toolbox.Volley
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Base64
|
||||
import java.util.EnumSet
|
||||
import java.util.UUID
|
||||
import java.util.stream.Collectors
|
||||
|
||||
|
||||
private val SERVICE_UUID = UUID.fromString(BuildConfig.SERVICE_UUID)
|
||||
private val CHAR_UUID = UUID.fromString(BuildConfig.CHAR_UUID)
|
||||
|
||||
enum class DeviceStatus {
|
||||
CONNECTED, BONDED, SUBSCRIBED, MISSING
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class KirbyDevice(
|
||||
private val context: Context,
|
||||
private val connectionManager: ConnectionManager,
|
||||
private val bleDevice: BluetoothDevice,
|
||||
private val loggerDb: LoggerContract.LoggerDb,
|
||||
private val onStateChange: (device: KirbyDevice) -> Unit,
|
||||
|
||||
|
||||
) : BleListener(bleDevice.address), DeviceListEntry {
|
||||
private val queue: RequestQueue = Volley.newRequestQueue(context)
|
||||
|
||||
fun subscribe() {
|
||||
|
||||
connectionManager.enableNotification(
|
||||
bleDevice, SERVICE_UUID, CHAR_UUID
|
||||
)
|
||||
}
|
||||
|
||||
fun readIaq() {
|
||||
connectionManager.readChar(bleDevice, SERVICE_UUID, CHAR_UUID)
|
||||
}
|
||||
|
||||
override fun onSuccessfulCharRead(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic
|
||||
) {
|
||||
addMeasurement(characteristic)
|
||||
onStateChange(this)
|
||||
}
|
||||
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
rssi = result.rssi
|
||||
onStateChange(this)
|
||||
}
|
||||
|
||||
override fun onConnect(gatt: BluetoothGatt) {
|
||||
statuses.add(DeviceStatus.CONNECTED)
|
||||
statuses.remove(DeviceStatus.MISSING)
|
||||
|
||||
onStateChange(this)
|
||||
}
|
||||
|
||||
override fun onConnectToBondedFailed(gatt: BluetoothGatt) {
|
||||
statuses.add(DeviceStatus.MISSING)
|
||||
onStateChange(this)
|
||||
}
|
||||
|
||||
override fun onDisconnect(gatt: BluetoothGatt) {
|
||||
statuses.remove(DeviceStatus.CONNECTED)
|
||||
statuses.remove(DeviceStatus.SUBSCRIBED)
|
||||
onStateChange(this)
|
||||
}
|
||||
|
||||
override fun onCharChange(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
addMeasurement(characteristic)
|
||||
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 fun onQueueSizeChange(groupedOps: Map<String, List<BleOperationType>>) {
|
||||
hasRunningOp = groupedOps.getOrDefault(bleDevice.address, emptyList()).isNotEmpty()
|
||||
onStateChange(this)
|
||||
}
|
||||
|
||||
override fun onBonded(device: BluetoothDevice) {
|
||||
statuses.add(DeviceStatus.BONDED)
|
||||
onStateChange(this)
|
||||
}
|
||||
|
||||
override fun onUnbonded(device: BluetoothDevice) {
|
||||
statuses.remove(DeviceStatus.BONDED)
|
||||
onStateChange(this)
|
||||
}
|
||||
|
||||
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int) {
|
||||
this.rssi = rssi
|
||||
onStateChange(this)
|
||||
}
|
||||
|
||||
override var hasRunningOp: Boolean = false
|
||||
|
||||
override var rssi: Int? = null
|
||||
|
||||
private fun addMeasurement(characteristic: BluetoothGattCharacteristic) {
|
||||
val hexPayload = characteristic.value.toHexString().substring(2)
|
||||
val measurement = DecoderIaq.parseMeasurement(hexPayload)
|
||||
var payload : Payload
|
||||
if (measurement == null) {
|
||||
payload = Payload(hexPayload)
|
||||
} else {
|
||||
payload = Payload(measurement.toString())
|
||||
Log.i("BleListener", "Char received: $payload")
|
||||
val base64Payload = Base64.getEncoder().encodeToString(characteristic.value)
|
||||
publishMeasurement(base64Payload)
|
||||
|
||||
loggerDb.writeLog( measurement)
|
||||
}
|
||||
|
||||
measurements.add(payload)
|
||||
|
||||
if (measurements.size > maxMeasurements) {
|
||||
measurements.removeFirst()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun publishMeasurement(payload: String) {
|
||||
val accessKey = BuildConfig.API_KEY
|
||||
val url = BuildConfig.API_BASE_URL
|
||||
val eui = "0000${bleDevice.address.replace(":", "")}"
|
||||
|
||||
val postData = JSONObject()
|
||||
|
||||
try {
|
||||
Log.i("POST", "Transmitting for $eui: $payload")
|
||||
postData.put("accessKey", accessKey)
|
||||
postData.put("metricPayload", payload)
|
||||
postData.put("eui", eui)
|
||||
} catch (e: JSONException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
val request = JsonObjectRequest(
|
||||
Request.Method.POST, url, postData,
|
||||
{ response ->
|
||||
Log.i("sendDataResponse", "Response is: $response")
|
||||
}
|
||||
) { error -> error.printStackTrace() }
|
||||
|
||||
queue.add(request)
|
||||
}
|
||||
|
||||
private val measurements = ArrayList<Payload>()
|
||||
private val maxMeasurements = 20
|
||||
|
||||
private val statuses = EnumSet.noneOf(DeviceStatus::class.java)
|
||||
|
||||
init {
|
||||
if (bleDevice.bondState == BOND_BONDED) {
|
||||
statuses.add(DeviceStatus.BONDED)
|
||||
}
|
||||
}
|
||||
|
||||
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 getMeasurements(): List<Measurement> {
|
||||
val result = mutableListOf<Measurement>()
|
||||
|
||||
measurements.reversed().forEach { m -> result.addAll(payloadToMeasurements(m))}
|
||||
|
||||
/*
|
||||
var pl = Payload(payload = "006b04ab74a1ed0d101404", ts = "2000")
|
||||
result.addAll(payloadToMeasurements(pl))
|
||||
result.addAll(payloadToMeasurements(pl))
|
||||
*/
|
||||
return result
|
||||
}
|
||||
|
||||
override fun getActions(): List<Action> {
|
||||
val actions = mutableListOf<Action>()
|
||||
if (!statuses.contains(DeviceStatus.BONDED)) {
|
||||
actions.add(object : Action {
|
||||
override fun getLabel(): String {
|
||||
return "Bond"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int {
|
||||
return R.drawable.action_icon_bond
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
connectionManager.bond(bleDevice)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!statuses.contains(DeviceStatus.CONNECTED)
|
||||
) {
|
||||
actions.add(object : Action {
|
||||
override fun getLabel(): String {
|
||||
return "Connect"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int {
|
||||
return R.drawable.action_icon_connect
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
connectionManager.connect(bleDevice)
|
||||
connectionManager.readRemoteRssi(bleDevice)
|
||||
connectionManager.discoverServices(bleDevice)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (statuses.contains(DeviceStatus.CONNECTED)) {
|
||||
|
||||
actions.add(object : Action {
|
||||
override fun getLabel(): String {
|
||||
return "Disconnect"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int {
|
||||
return R.drawable.action_icon_disconnect
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
connectionManager.teardownConnection(bleDevice)
|
||||
}
|
||||
})
|
||||
|
||||
actions.add(object : Action {
|
||||
override fun getLabel(): String {
|
||||
return "Fetch Measurement"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int {
|
||||
return R.drawable.action_icon_fetch_measurement
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
readIaq()
|
||||
}
|
||||
})
|
||||
|
||||
if (!statuses.contains(DeviceStatus.SUBSCRIBED)) {
|
||||
actions.add(object : Action {
|
||||
override fun getLabel(): String {
|
||||
return "Subscribe"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int {
|
||||
return R.drawable.action_icon_subscribe
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
subscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
actions.add(object : Action {
|
||||
override fun getLabel(): String {
|
||||
return "Update Signal Strength"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int {
|
||||
return R.drawable.action_icon_update_signal_strength
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
connectionManager.readRemoteRssi(bleDevice)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
if (statuses.contains(DeviceStatus.SUBSCRIBED)) {
|
||||
actions.add(object : Action {
|
||||
override fun getLabel(): String {
|
||||
return "Unsubscribe"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int {
|
||||
return R.drawable.action_icon_subscribe_disable
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
connectionManager.disableNotification(
|
||||
bleDevice, SERVICE_UUID, CHAR_UUID
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (statuses.contains(DeviceStatus.BONDED)
|
||||
) {
|
||||
actions.add(object : Action {
|
||||
override fun getLabel(): String {
|
||||
return "Unbond"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int {
|
||||
return R.drawable.action_icon_unbond
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
connectionManager.unbond(bleDevice)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
data class Payload(
|
||||
val payload: String,
|
||||
val ts: String = LocalDateTime.now()
|
||||
.format(DateTimeFormatter.ofPattern("dd.MM.yy 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
|
||||
}
|
||||
|
||||
|
||||
private fun payloadToMeasurements(payload: Payload): List<Measurement> {
|
||||
return listOf(object : Measurement {
|
||||
override fun getLabel(): String {
|
||||
return payload.ts.toString()
|
||||
}
|
||||
|
||||
override fun getFormattedValue(): String {
|
||||
return payload.payload
|
||||
}
|
||||
|
||||
override fun getIcon(): Int? {
|
||||
return R.drawable.baseline_numbers_24
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
459
app/src/main/java/com/logitech/vc/kirbytest/MainActivity.kt
Normal file
459
app/src/main/java/com/logitech/vc/kirbytest/MainActivity.kt
Normal file
@@ -0,0 +1,459 @@
|
||||
package com.logitech.vc.kirbytest
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import com.logitech.vc.kirbytest.databinding.ActivityMainBinding
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
|
||||
private const val ENABLE_BLUETOOTH_REQUEST_CODE = 1
|
||||
private const val RUNTIME_PERMISSION_REQUEST_CODE = 2
|
||||
|
||||
|
||||
fun Context.hasPermission(permissionType: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(this, permissionType) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun Context.hasRequiredRuntimePermissions(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
hasPermission(Manifest.permission.BLUETOOTH_SCAN) &&
|
||||
hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var loggerDb: LoggerContract.LoggerDb
|
||||
private lateinit var createFileLauncher: ActivityResultLauncher<String>
|
||||
|
||||
|
||||
private val bluetoothAdapter: BluetoothAdapter by lazy {
|
||||
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
bluetoothManager.adapter
|
||||
}
|
||||
|
||||
|
||||
private val connectionManager: ConnectionManager by lazy {
|
||||
newConnectionManager()
|
||||
}
|
||||
|
||||
private val kirbyDevices = mutableListOf<DeviceListEntry>();
|
||||
|
||||
private val deviceListAdapter: DeviceListAdapter by lazy {
|
||||
DeviceListAdapter(kirbyDevices)
|
||||
}
|
||||
|
||||
private var onPermissionsGrantedCallback: Runnable? = null
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
setSupportActionBar(binding.toolbar)
|
||||
|
||||
BLEService.startService(applicationContext, "hello ble service")
|
||||
|
||||
binding.fab.setOnClickListener { view ->
|
||||
if (!hasRequiredRuntimePermissions()) {
|
||||
onPermissionsGrantedCallback = object : Runnable {
|
||||
override fun run() {
|
||||
toggleScanning()
|
||||
}
|
||||
}
|
||||
requestRelevantRuntimePermissions()
|
||||
} else {
|
||||
toggleScanning()
|
||||
}
|
||||
}
|
||||
|
||||
loggerDb = LoggerContract.LoggerDb(this.applicationContext)
|
||||
|
||||
setupDevicesList()
|
||||
|
||||
createFileLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri: Uri? ->
|
||||
uri?.let {
|
||||
// Use the URI to write your CSV content
|
||||
loggerDb.exportToUri(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleScanning(): Unit {
|
||||
if (connectionManager.isScanning) {
|
||||
connectionManager.stopScan()
|
||||
} else {
|
||||
connectionManager.startScan()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isKirbyDevice(device: BluetoothDevice): Boolean {
|
||||
val deviceName = (device.name ?: "").lowercase()
|
||||
return deviceName.contains("kirby") || deviceName.contains("krby")
|
||||
}
|
||||
|
||||
private fun setupDevicesList() {
|
||||
binding.devicesList.apply {
|
||||
adapter = deviceListAdapter
|
||||
}
|
||||
|
||||
val animator = binding.devicesList.itemAnimator
|
||||
if (animator is SimpleItemAnimator) {
|
||||
animator.supportsChangeAnimations = false
|
||||
}
|
||||
|
||||
if (hasRequiredRuntimePermissions()) {
|
||||
addBondedDevices()
|
||||
} else {
|
||||
onPermissionsGrantedCallback = object : Runnable {
|
||||
override fun run() {
|
||||
addBondedDevices()
|
||||
}
|
||||
}
|
||||
requestRelevantRuntimePermissions()
|
||||
}
|
||||
|
||||
|
||||
//addDummyDevices()
|
||||
}
|
||||
|
||||
private fun addBondedDevices(): Unit {
|
||||
bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach {
|
||||
val kirbyDevice = newKirbyDevice(it)
|
||||
kirbyDevice.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
binding.fab.setIconResource(
|
||||
if (isScanning) R.drawable.action_icon_disconnect else R.drawable.action_icon_scan
|
||||
)
|
||||
binding.scanProgress.visibility = if (isScanning) VISIBLE else INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
if (isKirbyDevice(result.device)) {
|
||||
|
||||
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(result.device).onScanResult(callbackType, result)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
})
|
||||
return mngr
|
||||
}
|
||||
|
||||
private fun newKirbyDevice(bleDevice: BluetoothDevice): KirbyDevice {
|
||||
|
||||
val device = KirbyDevice(this.applicationContext, connectionManager, bleDevice, loggerDb) {
|
||||
val i = kirbyDevices.indexOfFirst { d -> d === it }
|
||||
runOnUiThread {
|
||||
deviceListAdapter.notifyItemChanged(i)
|
||||
}
|
||||
}
|
||||
connectionManager.register(device)
|
||||
connectionManager.connect(bleDevice)
|
||||
connectionManager.discoverServices(bleDevice)
|
||||
kirbyDevices.add(device)
|
||||
deviceListAdapter.notifyItemInserted(kirbyDevices.size - 1)
|
||||
return device
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
// Inflate the menu; this adds items to the action bar if it is present.
|
||||
menuInflater.inflate(R.menu.menu_main, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
// Handle action bar item clicks here. The action bar will
|
||||
// automatically handle clicks on the Home/Up button, so long
|
||||
// as you specify a parent activity in AndroidManifest.xml.
|
||||
return when (item.itemId) {
|
||||
R.id.action_export -> {
|
||||
|
||||
val date = LocalDateTime.now()
|
||||
val fileName = "kirby_export_${date.format(DateTimeFormatter.ISO_DATE_TIME)}.csv"
|
||||
createFileLauncher.launch(fileName)
|
||||
return true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (!bluetoothAdapter.isEnabled) {
|
||||
promptEnableBluetooth()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
loggerDb.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
when (requestCode) {
|
||||
ENABLE_BLUETOOTH_REQUEST_CODE -> {
|
||||
if (resultCode != Activity.RESULT_OK) {
|
||||
promptEnableBluetooth()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
when (requestCode) {
|
||||
RUNTIME_PERMISSION_REQUEST_CODE -> {
|
||||
val containsPermanentDenial = permissions.zip(grantResults.toTypedArray()).any {
|
||||
it.second == PackageManager.PERMISSION_DENIED &&
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(this, it.first)
|
||||
}
|
||||
val containsDenial = grantResults.any { it == PackageManager.PERMISSION_DENIED }
|
||||
val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
||||
when {
|
||||
containsPermanentDenial -> {
|
||||
// TODO: Handle permanent denial (e.g., show AlertDialog with justification)
|
||||
// Note: The user will need to navigate to App Settings and manually grant
|
||||
// permissions that were permanently denied
|
||||
}
|
||||
|
||||
containsDenial -> {
|
||||
requestRelevantRuntimePermissions()
|
||||
}
|
||||
|
||||
allGranted && hasRequiredRuntimePermissions() -> {
|
||||
onPermissionsGrantedCallback?.run()
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Unexpected scenario encountered when handling permissions
|
||||
recreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun promptEnableBluetooth() {
|
||||
if (!bluetoothAdapter.isEnabled) {
|
||||
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
|
||||
startActivityForResult(enableBtIntent, ENABLE_BLUETOOTH_REQUEST_CODE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun Activity.requestRelevantRuntimePermissions() {
|
||||
|
||||
if (hasRequiredRuntimePermissions()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
when {
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> {
|
||||
requestLocationPermission()
|
||||
}
|
||||
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
requestBluetoothPermissions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun requestLocationPermission() {
|
||||
val onClick = { dialog: DialogInterface, which: Int ->
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
|
||||
RUNTIME_PERMISSION_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
runOnUiThread {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
|
||||
with(builder)
|
||||
{
|
||||
setTitle("Location permission required")
|
||||
setMessage(
|
||||
"Starting from Android M (6.0), the system requires apps to be granted " +
|
||||
"location access in order to scan for BLE devices."
|
||||
)
|
||||
setCancelable(false)
|
||||
setPositiveButton(
|
||||
android.R.string.ok,
|
||||
DialogInterface.OnClickListener(function = onClick)
|
||||
)
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun requestBluetoothPermissions() {
|
||||
val onClick = { dialog: DialogInterface, which: Int ->
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT
|
||||
),
|
||||
RUNTIME_PERMISSION_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
runOnUiThread {
|
||||
val builder = AlertDialog.Builder(this)
|
||||
|
||||
with(builder)
|
||||
{
|
||||
setTitle("Bluetooth permission required")
|
||||
setMessage(
|
||||
"Starting from Android 12, the system requires apps to be granted " +
|
||||
"Bluetooth access in order to scan for and connect to BLE devices."
|
||||
)
|
||||
setCancelable(false)
|
||||
setPositiveButton(
|
||||
android.R.string.ok,
|
||||
DialogInterface.OnClickListener(function = onClick)
|
||||
)
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class DummyListEntry(override val address: String) : DeviceListEntry {
|
||||
|
||||
override var rssi: Int? = -30
|
||||
|
||||
override val name: String = "Device 123"
|
||||
override val status: String
|
||||
get() = "statusA, statusB"
|
||||
|
||||
override var hasRunningOp: Boolean = true
|
||||
|
||||
override fun getActions(): List<Action> {
|
||||
return listOf(object : Action {
|
||||
override fun getLabel(): String {
|
||||
return "Test action 1"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int? {
|
||||
return R.drawable.action_icon_disconnect
|
||||
}
|
||||
|
||||
override fun execute() {
|
||||
|
||||
}
|
||||
}, object : Action {
|
||||
override fun getLabel(): String {
|
||||
return "Test action 2"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int? {
|
||||
return R.drawable.action_icon_connect
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int? {
|
||||
return R.drawable.baseline_device_thermostat_24
|
||||
}
|
||||
}, object : Measurement {
|
||||
override fun getLabel(): String {
|
||||
return "Pressure"
|
||||
}
|
||||
|
||||
override fun getFormattedValue(): String {
|
||||
return "232 bar"
|
||||
}
|
||||
|
||||
override fun getIcon(): Int? {
|
||||
return R.drawable.baseline_compress_24
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.logitech.vc.kirbytest;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ListView;
|
||||
|
||||
public class NonScrollListView extends ListView {
|
||||
|
||||
public NonScrollListView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
public NonScrollListView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
public NonScrollListView(Context context, AttributeSet attrs, int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
}
|
||||
@Override
|
||||
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
int heightMeasureSpec_custom = MeasureSpec.makeMeasureSpec(
|
||||
Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec_custom);
|
||||
ViewGroup.LayoutParams params = getLayoutParams();
|
||||
params.height = getMeasuredHeight();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user