Compare commits

...

4 Commits

Author SHA1 Message Date
Stefan Zollinger
bcd501a44b feat: add device status uplink decoder 2024-08-23 15:00:42 +02:00
Stefan Zollinger
d210a2754d feat: improve scanning stability & connect to freshly bonded devs 2024-08-19 14:39:23 +02:00
Stefan Zollinger
48c6b2c314 feat: support BLE low power mode 2024-08-16 11:06:01 +02:00
Stefan Zollinger
83b120b1ce feat: add api configuration settings 2024-04-16 18:22:05 +02:00
16 changed files with 483 additions and 108 deletions

5
.idea/gradle.xml generated
View File

@@ -4,16 +4,15 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
</set> </set>
</option> </option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

View File

@@ -64,4 +64,5 @@ dependencies {
implementation group: 'commons-codec', name: 'commons-codec', version: '1.16.0' implementation group: 'commons-codec', name: 'commons-codec', version: '1.16.0'
implementation "com.android.volley:volley:1.2.1" implementation "com.android.volley:volley:1.2.1"
implementation "androidx.datastore:datastore-preferences:1.0.0"
} }

View File

@@ -18,6 +18,7 @@ import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings
import android.bluetooth.le.ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -210,10 +211,9 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
private val scanSettings = ScanSettings.Builder() private val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
.build() .build()
private val scanFilters = listOf( ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString(BuildConfig.SERVICE_UUID)).build())
private val bleScanner by lazy { private val bleScanner by lazy {
bleAdapter.bluetoothLeScanner bleAdapter.bluetoothLeScanner
} }
@@ -294,10 +294,6 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
val scanCallback = object : ScanCallback() { val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) { 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) } notifyListeners(result.device.address) { it.onScanResult(callbackType, result) }
} }
@@ -314,7 +310,8 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
fun startScan() { fun startScan() {
if (!isScanning) { if (!isScanning) {
isScanning = true isScanning = true
bleScanner.startScan( null, scanSettings, scanCallback) val filter = ScanFilter.Builder().setDeviceName("KIRBY").build()
bleScanner.startScan(listOf(filter), scanSettings, scanCallback)
} }
} }
@@ -353,6 +350,9 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
} }
fun discoverServices(device: BluetoothDevice) { fun discoverServices(device: BluetoothDevice) {
if(operationQueue.any { it.device.address === device.address && it is DiscoverServicesRequest }) {
return
}
enqueueOperation(DiscoverServicesRequest(device)) enqueueOperation(DiscoverServicesRequest(device))
} }
@@ -377,6 +377,9 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
} }
fun readRemoteRssi(device: BluetoothDevice) { fun readRemoteRssi(device: BluetoothDevice) {
if(operationQueue.any { it.device.address === device.address && it is ReadRemoteRssi }) {
return
}
enqueueOperation(ReadRemoteRssi(device)) enqueueOperation(ReadRemoteRssi(device))
} }

View File

@@ -204,7 +204,6 @@ object LoggerContract {
files[deviceId] = file files[deviceId] = file
Log.i(tag, file.absolutePath)
file.setReadable(true, false) file.setReadable(true, false)
file file
} }

View File

@@ -1,12 +1,9 @@
package com.logitech.vc.kirbytest package com.logitech.vc.kirbytest
import android.util.Log
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import org.apache.commons.codec.DecoderException
import org.apache.commons.codec.binary.Hex
import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
object DecoderIaq { object DecoderIaq {
private const val INVALID_CO2 = 16383 private const val INVALID_CO2 = 16383
private const val INVALID_VOC = 2047 private const val INVALID_VOC = 2047
@@ -17,18 +14,70 @@ object DecoderIaq {
private const val INVALID_PM25 = 1023 private const val INVALID_PM25 = 1023
private const val INVALID_PM10 = 1023 private const val INVALID_PM10 = 1023
private val supportedMessageTypes = listOf<Number>(0, 1) private val supportedMessageTypes = listOf<Number>(0, 1, 5)
fun parseMeasurement(input: String): Uplink? {
fun parseMeasurement(input: String): Measurement? {
val measurement = Measurement()
val inputBytes = hexStringToByteArray(input) val inputBytes = hexStringToByteArray(input)
val msgType = inputBytes[0].toInt() and 0xFF ushr 4 val msgType = inputBytes[0].toInt() and 0xFF ushr 4
if(!supportedMessageTypes.contains(msgType)) { if (!supportedMessageTypes.contains(msgType)) {
Log.i("Decoder", "Invalid message type: $msgType")
return null return null
} }
if (msgType < 3) {
return parseIaq(msgType, inputBytes)
} else if (msgType == 5) {
return parseDeviceStatus(msgType, inputBytes)
}
return null
}
private fun parseDeviceStatus(msgType: Number, inputBytes: ByteArray): DeviceStatus {
val status = DeviceStatus(msgType = msgType)
val fwMajor = parseUnsignedIntFromBytes(inputBytes, 4, 8)
val fwMinor = parseUnsignedIntFromBytes(inputBytes, 12, 8)
val fwPatch = parseUnsignedIntFromBytes(inputBytes, 20, 8)
val fwTweak = parseUnsignedIntFromBytes(inputBytes, 28, 8)
status.firmware = "$fwMajor.$fwMinor.$fwPatch"
if(fwTweak > 0u) {
status.firmware += "-$fwTweak"
}
status.operationMode = parseUnsignedIntFromBytes(inputBytes, 36, 4).toInt()
val batteryVoltage = parseUnsignedIntFromBytes(inputBytes, 40, 16).toInt()
if (batteryVoltage == 0) {
status.usbPowered = true
} else {
status.batteryVoltage = batteryVoltage / 1000f
}
if (inputBytes.size > 7) {
val errors = inputBytes[7].toInt()
val errorCo2 = (errors shr 0) and 1
val errorVoc = (errors shr 1) and 1
val errorPm = (errors shr 2) and 1
val errorRadar = (errors shr 3) and 1
val errorTemp = (errors shr 4) and 1
status.errorCo2 = errorCo2 == 1
status.errorVoc = errorVoc == 1
status.errorPm = errorPm == 1
status.errorRadar = errorRadar == 1
status.errorTemp = errorTemp == 1
}
return status
}
private fun parseIaq(msgType: Number, inputBytes: ByteArray): Measurement {
val measurement = Measurement()
measurement.msgType = msgType measurement.msgType = msgType
val co2 = parseUnsignedInt(inputBytes, 0, 3) ushr 6 and INVALID_CO2 val co2 = parseUnsignedInt(inputBytes, 0, 3) ushr 6 and INVALID_CO2
@@ -66,28 +115,16 @@ object DecoderIaq {
return measurement return measurement
} }
private fun parseUnsignedInt(bytes: ByteArray, startIncl: Int, endExcl: Int): Int {
val section = bytes.copyOfRange(startIncl, min(bytes.size, endExcl)) interface Uplink {
var unsignedInt = 0 var deviceId: String?
for (i in section.indices) { var msgType: Number?
unsignedInt = unsignedInt shl 8
unsignedInt = unsignedInt or (section[i].toInt() and 0xFF)
}
return unsignedInt
} }
private fun hexStringToByteArray(encoded: String): ByteArray { data class Measurement(
return try {
Hex.decodeHex(encoded)
} catch (e: DecoderException) {
throw RuntimeException(e)
}
}
data class Measurement (
@SerializedName("bleAddress") @SerializedName("bleAddress")
var deviceId: String? = null, override var deviceId: String? = null,
var msgType: Number? = null, override var msgType: Number? = null,
var co2: Number? = null, var co2: Number? = null,
var voc: Number? = null, var voc: Number? = null,
var humidity: Number? = null, var humidity: Number? = null,
@@ -96,9 +133,7 @@ object DecoderIaq {
var occupancy: Number? = null, var occupancy: Number? = null,
var pm25: Number? = null, var pm25: Number? = null,
var pm10: Number? = null var pm10: Number? = null
) { ) : Uplink {
override fun toString(): String { override fun toString(): String {
return "M{" + return "M{" +
"type=" + msgType + "type=" + msgType +
@@ -112,6 +147,35 @@ object DecoderIaq {
", occ=" + occupancy + ", occ=" + occupancy +
'}' '}'
} }
}
data class DeviceStatus(
@SerializedName("bleAddress")
override var deviceId: String? = null,
override var msgType: Number? = null,
var firmware: String? = null,
var operationMode: Number? = null,
var batteryVoltage: Number? = null,
var usbPowered: Boolean = false,
var errorCo2: Boolean = false,
var errorVoc: Boolean = false,
var errorPm: Boolean = false,
var errorRadar: Boolean = false,
var errorTemp: Boolean = false,
) : Uplink {
override fun toString(): String {
return "S{" +
"type=" + msgType +
", fw=" + firmware +
", op=" + operationMode +
", usb=" + usbPowered +
", batt=" + batteryVoltage +
", errTemp=" + errorTemp +
", errCo2=" + errorCo2 +
", errVoc=" + errorVoc +
", errPm=" + errorPm +
", errRad=" + errorRadar +
'}'
}
} }
} }

View File

@@ -1,5 +1,6 @@
package com.logitech.vc.kirbytest package com.logitech.vc.kirbytest
import SettingsRepository
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.BOND_BONDED import android.bluetooth.BluetoothDevice.BOND_BONDED
@@ -15,8 +16,6 @@ import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley import com.android.volley.toolbox.Volley
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Base64 import java.util.Base64
@@ -34,15 +33,22 @@ enum class DeviceStatus {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
class KirbyDevice( class KirbyDevice(
private val context: Context, private val context: Context,
private val connectionManager: ConnectionManager, private val connectionManager: ConnectionManager,
private val bleDevice: BluetoothDevice, private val bleDevice: BluetoothDevice,
private val loggerDb: LoggerContract.LoggerDb, private val loggerDb: LoggerContract.LoggerDb,
private val settingsRepository: SettingsRepository,
private val onStateChange: (device: KirbyDevice) -> Unit, private val onStateChange: (device: KirbyDevice) -> Unit,
) : BleListener(bleDevice.address), DeviceListEntry { ) : BleListener(bleDevice.address), DeviceListEntry {
private val tag = "KirbyDevice"
private var lastSeen: Long = 0
private val queue: RequestQueue = Volley.newRequestQueue(context) private val queue: RequestQueue = Volley.newRequestQueue(context)
private val reconnectionDelayMs = 10_000
private val settings = settingsRepository.getSettings()
fun subscribe() { fun subscribe() {
if(statuses.contains(DeviceStatus.CONNECTED)) { if(statuses.contains(DeviceStatus.CONNECTED)) {
@@ -53,8 +59,14 @@ class KirbyDevice(
} }
fun connect() { fun connect() {
connectionManager.connect(bleDevice) val now = System.currentTimeMillis()
connectionManager.discoverServices(bleDevice) if (now - lastSeen > reconnectionDelayMs) {
Log.i(tag, "Connecting to device " + bleDevice.address)
connectionManager.connect(bleDevice)
connectionManager.discoverServices(bleDevice)
} else{
Log.i(tag, "Waiting before reconnecting to device " + bleDevice.address)
}
} }
fun readIaq() { fun readIaq() {
@@ -66,7 +78,9 @@ class KirbyDevice(
gatt: BluetoothGatt, gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic characteristic: BluetoothGattCharacteristic
) { ) {
addMeasurement(characteristic) addMeasurement(characteristic)
onStateChange(this) onStateChange(this)
} }
@@ -93,10 +107,18 @@ class KirbyDevice(
statuses.remove(DeviceStatus.CONNECTED) statuses.remove(DeviceStatus.CONNECTED)
statuses.remove(DeviceStatus.SUBSCRIBED) statuses.remove(DeviceStatus.SUBSCRIBED)
onStateChange(this) onStateChange(this)
Log.i(tag, "Disconnected")
} }
override fun onCharChange(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { override fun onCharChange(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
addMeasurement(characteristic) addMeasurement(characteristic)
lastSeen = System.currentTimeMillis()
if(settings.lowPowerMode){
Log.i(tag, "Received data, closing connection")
connectionManager.teardownConnection(bleDevice)
}
onStateChange(this) onStateChange(this)
} }
@@ -142,7 +164,7 @@ class KirbyDevice(
private fun addMeasurement(characteristic: BluetoothGattCharacteristic) { private fun addMeasurement(characteristic: BluetoothGattCharacteristic) {
val hexPayload = characteristic.value.toHexString().substring(2) val hexPayload = characteristic.value.toHexString().substring(2)
val measurement = DecoderIaq.parseMeasurement(hexPayload) val measurement = DecoderIaq.parseMeasurement(hexPayload)
var payload : Payload val payload : Payload
if (measurement == null) { if (measurement == null) {
payload = Payload(hexPayload) payload = Payload(hexPayload)
} else { } else {
@@ -164,10 +186,10 @@ class KirbyDevice(
} }
private fun publishMeasurement(payload: String) { private fun publishMeasurement(payload: String) {
val accessKey = BuildConfig.API_KEY val accessKey = settings.apiKey
val url = BuildConfig.API_BASE_URL val url = settings.apiUrl
if(url.isEmpty()) { if(url.isEmpty() || accessKey.isEmpty()) {
return return
} }
@@ -372,26 +394,11 @@ data class Payload(
.format(DateTimeFormatter.ofPattern("dd.MM.yy HH:mm:ss")) .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> { private fun payloadToMeasurements(payload: Payload): List<Measurement> {
return listOf(object : Measurement { return listOf(object : Measurement {
override fun getLabel(): String { override fun getLabel(): String {
return payload.ts.toString() return payload.ts
} }
override fun getFormattedValue(): String { override fun getFormattedValue(): String {

View File

@@ -1,5 +1,6 @@
package com.logitech.vc.kirbytest package com.logitech.vc.kirbytest
import SettingsRepository
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
@@ -8,22 +9,23 @@ import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanResult
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.util.Log import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@@ -35,9 +37,9 @@ import androidx.recyclerview.widget.SimpleItemAnimator
import com.logitech.vc.kirbytest.databinding.ActivityMainBinding import com.logitech.vc.kirbytest.databinding.ActivityMainBinding
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import settingsDialog
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Timer import java.util.Timer
@@ -69,6 +71,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var loggerDb: LoggerContract.LoggerDb private lateinit var loggerDb: LoggerContract.LoggerDb
private lateinit var createFileLauncher: ActivityResultLauncher<String> private lateinit var createFileLauncher: ActivityResultLauncher<String>
private val bondedDevices = HashSet<String>() private val bondedDevices = HashSet<String>()
private lateinit var settings: SettingsRepository
private val bluetoothAdapter: BluetoothAdapter by lazy { private val bluetoothAdapter: BluetoothAdapter by lazy {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
@@ -80,7 +83,7 @@ class MainActivity : AppCompatActivity() {
newConnectionManager() newConnectionManager()
} }
private val kirbyDevices = mutableListOf<DeviceListEntry>() private val kirbyDevices = mutableListOf<KirbyDevice>()
private val deviceListAdapter: DeviceListAdapter by lazy { private val deviceListAdapter: DeviceListAdapter by lazy {
DeviceListAdapter(kirbyDevices) DeviceListAdapter(kirbyDevices)
@@ -93,7 +96,7 @@ class MainActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
settings = SettingsRepository(applicationContext)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
@@ -136,23 +139,39 @@ class MainActivity : AppCompatActivity() {
} }
} }
if(hasRequiredRuntimePermissions()) { if (hasRequiredRuntimePermissions()) {
connectionManager.startScan() connectionManager.startScan()
/*
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.Main) {
withContext(Dispatchers.Main) { delay(5000L)
delay(5000L) connectionManager.stopScan()
connectionManager.stopScan()
}
} }
} }
*/
}
Timer().schedule(object : TimerTask() { Timer().schedule(object : TimerTask() {
override fun run() { override fun run() {
loggerDb.exportToMultipleCSV() loggerDb.exportToMultipleCSV()
} }
}, 1000, 10000) }, 1000, 10000)
lifecycleScope.launch {
settings.loadSettings()
if(settings.getSettings().lowPowerMode) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
// Register for broadcasts on BluetoothAdapter state change
val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
registerReceiver(mReceiver, filter)
} }
private fun toggleScanning(): Unit { private fun toggleScanning(): Unit {
@@ -163,6 +182,21 @@ class MainActivity : AppCompatActivity() {
} }
} }
private val mReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action == BluetoothAdapter.ACTION_STATE_CHANGED) {
val state = intent.getIntExtra(
BluetoothAdapter.EXTRA_STATE,
BluetoothAdapter.ERROR
)
Log.i("statechanged", "got state " + state.toString())
}
}
}
private fun isKirbyDevice(device: BluetoothDevice): Boolean { private fun isKirbyDevice(device: BluetoothDevice): Boolean {
val deviceName = (device.name ?: "").lowercase() val deviceName = (device.name ?: "").lowercase()
return deviceName.contains("kirby") || deviceName.contains("krby") return deviceName.contains("kirby") || deviceName.contains("krby")
@@ -194,6 +228,7 @@ class MainActivity : AppCompatActivity() {
} }
private fun addBondedDevices(): Unit { private fun addBondedDevices(): Unit {
bondedDevices.clear()
bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach { bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach {
bondedDevices.add(it.address) bondedDevices.add(it.address)
newKirbyDevice(it) newKirbyDevice(it)
@@ -201,14 +236,6 @@ class MainActivity : AppCompatActivity() {
} }
@SuppressLint("NotifyDataSetChanged")
private fun addDummyDevices() {
for (i in 0..14) {
kirbyDevices.add(DummyListEntry("$i"))
}
deviceListAdapter.notifyDataSetChanged()
}
private fun newConnectionManager(): ConnectionManager { private fun newConnectionManager(): ConnectionManager {
val mngr = ConnectionManager(applicationContext, bluetoothAdapter) val mngr = ConnectionManager(applicationContext, bluetoothAdapter)
mngr.register(object : BleListener(null) { mngr.register(object : BleListener(null) {
@@ -229,20 +256,13 @@ class MainActivity : AppCompatActivity() {
"ScanCallback", "ScanCallback",
"Found Kirby device with name ${result.device.name} (address: ${result.device.address}, rssi: ${result.rssi})" "Found Kirby device with name ${result.device.name} (address: ${result.device.address}, rssi: ${result.rssi})"
) )
val kirbyDevice = kirbyDevices.find { it.address == result.device.address } var kirbyDevice = kirbyDevices.find { it.address == result.device.address }
if (kirbyDevice == null) { if (kirbyDevice == null) {
val kirby = newKirbyDevice(result.device) kirbyDevice = newKirbyDevice(result.device)
kirby.onScanResult(callbackType, result) kirbyDevice.onScanResult(callbackType, result)
}
if (bondedDevices.contains(result.device.address)) {
Log.i("KirbyDevice", "Connecting to " + result.device.address)
connectionManager.connect(result.device)
connectionManager.readRemoteRssi(result.device)
connectionManager.discoverServices(result.device)
} }
kirbyDevice.connect()
} }
} }
}) })
@@ -253,12 +273,13 @@ class MainActivity : AppCompatActivity() {
bleDevice: BluetoothDevice bleDevice: BluetoothDevice
): KirbyDevice { ): KirbyDevice {
val device = KirbyDevice(this.applicationContext, connectionManager, bleDevice, loggerDb) { val device =
val i = kirbyDevices.indexOfFirst { d -> d === it } KirbyDevice(this.applicationContext, connectionManager, bleDevice, loggerDb, settings) {
runOnUiThread { val i = kirbyDevices.indexOfFirst { d -> d === it }
deviceListAdapter.notifyItemChanged(i) runOnUiThread {
deviceListAdapter.notifyItemChanged(i)
}
} }
}
Log.i("MainActivity", bleDevice.address) Log.i("MainActivity", bleDevice.address)
connectionManager.register(device) connectionManager.register(device)
@@ -303,6 +324,12 @@ class MainActivity : AppCompatActivity() {
return true return true
} }
R.id.action_settings -> {
val settingsDialog = settingsDialog(this, settings)
settingsDialog.show()
return true
}
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
@@ -317,6 +344,8 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
loggerDb.close() loggerDb.close()
super.onDestroy() super.onDestroy()
unregisterReceiver(mReceiver);
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {

View File

@@ -0,0 +1,128 @@
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import android.content.Context
import android.content.DialogInterface
import android.util.Log
import android.view.LayoutInflater
import android.widget.CheckBox
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.logitech.vc.kirbytest.BuildConfig
import com.logitech.vc.kirbytest.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.net.URL
val Context.dataStore by preferencesDataStore(name = "app_settings")
class SettingsRepository(context: Context) {
private val dataStore = context.dataStore
private val settings = Settings(apiUrl = BuildConfig.API_BASE_URL, apiKey = BuildConfig.API_KEY)
private val coroutineScope = CoroutineScope(Dispatchers.IO)
companion object {
val API_URL = stringPreferencesKey("api_url")
val API_KEY = stringPreferencesKey("api_key")
val BLE_LOW_POWER_MODE = booleanPreferencesKey("ble_low_power_mode")
}
@OptIn(DelicateCoroutinesApi::class)
fun saveSettings(apiUrl: String, apiKey: String, isLowPowerMode: Boolean) {
settings.apiKey = apiKey
settings.apiUrl = apiUrl
settings.lowPowerMode = isLowPowerMode
coroutineScope.launch(Dispatchers.Main) {
dataStore.edit { preferences ->
preferences[API_URL] = apiUrl
preferences[API_KEY] = apiKey
preferences[BLE_LOW_POWER_MODE] = isLowPowerMode
}
}
}
fun getSettings(): Settings {
return settings
}
private val settingsFlow: Flow<Settings> = dataStore.data.map {
Settings(
apiUrl = it[API_URL] ?: BuildConfig.API_BASE_URL,
apiKey = it[API_KEY] ?: BuildConfig.API_KEY,
lowPowerMode = it[BLE_LOW_POWER_MODE] ?: true
)
}
suspend fun loadSettings() {
val result = settingsFlow.firstOrNull()
if (result != null) {
settings.apiKey = result.apiKey
settings.apiUrl = result.apiUrl
settings.lowPowerMode = result.lowPowerMode
}
}
}
data class Settings(var apiUrl: String, var apiKey: String, var lowPowerMode: Boolean = true)
fun settingsDialog(context: Context, settingsRepo: SettingsRepository): AlertDialog {
val layoutInflater = LayoutInflater.from(context)
val root = layoutInflater.inflate(R.layout.settings_dialog, null)
val urlField = root.findViewById<EditText>(R.id.apiUrl)
val keyField = root.findViewById<EditText>(R.id.apiKey)
val lowPowerMode = root.findViewById<CheckBox>(R.id.checkboxLowPowerMode)
val settings = settingsRepo.getSettings()
urlField.setText(settings.apiUrl)
keyField.setText(settings.apiKey)
lowPowerMode.isChecked = settings.lowPowerMode
return androidx.appcompat.app.AlertDialog.Builder(context)
.setTitle(R.string.settings)
.setView(root)
.setPositiveButton(R.string.save) { dialog: DialogInterface?, whichButton: Int ->
Log.d("SettingsDialog", "save settings")
val url = urlField.text.toString()
val key = keyField.text.toString()
val isLowPowerMode = lowPowerMode.isChecked
if (isFullPath(url) || url.isEmpty()) {
settingsRepo.saveSettings(url, key, isLowPowerMode)
}
}
.setNegativeButton(R.string.cancel) { dialog: DialogInterface?, whichButton: Int ->
//Do something
Log.d("SettingsDialog", "cancel settings")
}
.create()
}
fun isFullPath(potentialUrl: String): Boolean {
try {
URL(potentialUrl).toURI()
return true
} catch (e: Exception) {
e.printStackTrace()
}
return false
}

View File

@@ -0,0 +1,49 @@
package com.logitech.vc.kirbytest
import org.apache.commons.codec.DecoderException
import org.apache.commons.codec.binary.Hex
import kotlin.math.min
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
}
fun hexStringToByteArray(encoded: String): ByteArray {
return try {
Hex.decodeHex(encoded)
} catch (e: DecoderException) {
throw RuntimeException(e)
}
}
fun parseUnsignedIntFromBytes(byteArray: ByteArray, offset: Int, bits: Int): UInt {
require(bits in 1..32) { "Bits should be between 1 and 32" }
require(offset >= 0 && offset < byteArray.size * 8) { "Offset out of bounds" }
var result: UInt = 0u
var remainingBits = bits
var currentOffset = offset
while (remainingBits > 0) {
val byteIndex = currentOffset / 8
val bitIndex = currentOffset % 8
val bitsToRead = minOf(remainingBits, 8 - bitIndex)
val byteValue = byteArray[byteIndex].toInt() and 0xFF
val mask = (1 shl bitsToRead) - 1
val value = (byteValue shr (8 - bitIndex - bitsToRead)) and mask
result = (result shl bitsToRead) or value.toUInt()
currentOffset += bitsToRead
remainingBits -= bitsToRead
}
return result
}

View File

@@ -44,7 +44,7 @@
android:id="@+id/fab" android:id="@+id/fab"
android:layout_width="150dp" android:layout_width="150dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:text='Start Scan' android:text='@string/start_scan'
app:icon="@drawable/action_icon_scan" app:icon="@drawable/action_icon_scan"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/apiUrlLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="10"
android:text="API url" />
<EditText
android:id="@+id/apiUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="10"
android:ems="10"
android:inputType="textUri"
android:text="" />
<TextView
android:id="@+id/apiKeyLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="10"
android:text="API key" />
<EditText
android:id="@+id/apiKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="20"
android:autofillHints="password"
android:ems="10"
android:inputType="text"
android:text="" />
<CheckBox android:id="@+id/checkboxLowPowerMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="BLE low power mode"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -12,4 +12,9 @@
android:orderInCategory="200" android:orderInCategory="200"
android:title="@string/action_reset_log" android:title="@string/action_reset_log"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/action_settings"
android:orderInCategory="300"
android:title="@string/settings"
app:showAsAction="never" />
</menu> </menu>

View File

@@ -7,5 +7,11 @@
<string name="second_fragment_label">Second Fragment</string> <string name="second_fragment_label">Second Fragment</string>
<string name="next">Next</string> <string name="next">Next</string>
<string name="previous">Previous</string> <string name="previous">Previous</string>
<string name="settings">Settings</string>
<string name="api_url">API url</string>
<string name="api_key">API key</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string>
<string name="start_scan">Start Scan</string>
</resources> </resources>

View File

@@ -24,4 +24,27 @@ class DecoderTest {
val testMeasurement = DecoderIaq.Measurement(msgType = 1, co2 = 428, voc = 149, humidity = 44, pressure = 96873, occupancy = 1, pm10 = null, pm25 = null, temperature = 24.7 ) val testMeasurement = DecoderIaq.Measurement(msgType = 1, co2 = 428, voc = 149, humidity = 44, pressure = 96873, occupancy = 1, pm10 = null, pm25 = null, temperature = 24.7 )
assertEquals(testMeasurement, res2) assertEquals(testMeasurement, res2)
} }
@Test
fun message_type_5() {
val res = DecoderIaq.parseMeasurement("501001C0430000")
val expected = DecoderIaq.DeviceStatus(msgType = 5, firmware = "1.0.28-4", operationMode = 3, batteryVoltage = 0, errorPm = null, errorVoc = null, errorTemp = null, errorCo2 = null, errorRadar = null)
assertEquals(expected, res)
}
@Test
fun message_type_5_battery() {
val res = DecoderIaq.parseMeasurement("501001C0430FF9")
val expected = DecoderIaq.DeviceStatus(msgType = 5, firmware = "1.0.28-4", operationMode = 3, batteryVoltage = 4089f/1000, errorPm = null, errorVoc = null, errorTemp = null, errorCo2 = null, errorRadar = null)
assertEquals(expected, res)
}
@Test
fun message_type_5_with_errors() {
val res = DecoderIaq.parseMeasurement("501001C0430FF928")
val expected = DecoderIaq.DeviceStatus(msgType = 5, firmware = "1.0.28-4", operationMode = 3, batteryVoltage = 4089f/1000, errorPm = true, errorVoc = true, errorTemp = true, errorCo2 = true, errorRadar = true)
assertEquals(expected, res)
}
} }

View File

@@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id 'com.android.application' version '8.1.2' apply false id 'com.android.application' version '8.5.2' apply false
id 'com.android.library' version '8.1.2' apply false id 'com.android.library' version '8.5.2' apply false
id 'org.jetbrains.kotlin.android' version '1.8.20' apply false id 'org.jetbrains.kotlin.android' version '1.8.20' apply false
} }

View File

@@ -1,6 +1,6 @@
#Wed Jun 14 12:17:09 CEST 2023 #Wed Jun 14 12:17:09 CEST 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists