diff --git a/app/src/main/java/com/logitech/vc/kirbytest/Database.kt b/app/src/main/java/com/logitech/vc/kirbytest/Database.kt index 589dd60..0b171b2 100644 --- a/app/src/main/java/com/logitech/vc/kirbytest/Database.kt +++ b/app/src/main/java/com/logitech/vc/kirbytest/Database.kt @@ -204,7 +204,6 @@ object LoggerContract { files[deviceId] = file - Log.i(tag, file.absolutePath) file.setReadable(true, false) file } diff --git a/app/src/main/java/com/logitech/vc/kirbytest/DecoderIaq.kt b/app/src/main/java/com/logitech/vc/kirbytest/DecoderIaq.kt index 7c4a14b..6d2e8e5 100644 --- a/app/src/main/java/com/logitech/vc/kirbytest/DecoderIaq.kt +++ b/app/src/main/java/com/logitech/vc/kirbytest/DecoderIaq.kt @@ -1,12 +1,9 @@ package com.logitech.vc.kirbytest -import android.util.Log 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 + object DecoderIaq { private const val INVALID_CO2 = 16383 private const val INVALID_VOC = 2047 @@ -17,18 +14,70 @@ object DecoderIaq { private const val INVALID_PM25 = 1023 private const val INVALID_PM10 = 1023 - private val supportedMessageTypes = listOf(0, 1) + private val supportedMessageTypes = listOf(0, 1, 5) + + fun parseMeasurement(input: String): Uplink? { - 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") + if (!supportedMessageTypes.contains(msgType)) { 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 val co2 = parseUnsignedInt(inputBytes, 0, 3) ushr 6 and INVALID_CO2 @@ -55,10 +104,6 @@ object DecoderIaq { parseUnsignedInt(inputBytes, 8, 9) ushr 4 and INVALID_OCCUPANCY measurement.occupancy = if (occupancy == INVALID_OCCUPANCY) null else occupancy - val occupancy2 = - parseUnsignedInt(inputBytes, 10, 11) 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 @@ -70,28 +115,16 @@ object DecoderIaq { 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 + + interface Uplink { + var deviceId: String? + var msgType: Number? } - private fun hexStringToByteArray(encoded: String): ByteArray { - return try { - Hex.decodeHex(encoded) - } catch (e: DecoderException) { - throw RuntimeException(e) - } - } - - data class Measurement ( + data class Measurement( @SerializedName("bleAddress") - var deviceId: String? = null, - var msgType: Number? = null, + override var deviceId: String? = null, + override var msgType: Number? = null, var co2: Number? = null, var voc: Number? = null, var humidity: Number? = null, @@ -100,9 +133,7 @@ object DecoderIaq { var occupancy: Number? = null, var pm25: Number? = null, var pm10: Number? = null - ) { - - + ) : Uplink { override fun toString(): String { return "M{" + "type=" + msgType + @@ -116,6 +147,35 @@ object DecoderIaq { ", 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 + + '}' + } } } \ No newline at end of file diff --git a/app/src/main/java/com/logitech/vc/kirbytest/MainActivity.kt b/app/src/main/java/com/logitech/vc/kirbytest/MainActivity.kt index 18d0591..e025d8b 100644 --- a/app/src/main/java/com/logitech/vc/kirbytest/MainActivity.kt +++ b/app/src/main/java/com/logitech/vc/kirbytest/MainActivity.kt @@ -9,9 +9,11 @@ import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager import android.bluetooth.le.ScanResult +import android.content.BroadcastReceiver import android.content.Context import android.content.DialogInterface import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.net.Uri import android.os.Build @@ -35,7 +37,6 @@ import androidx.recyclerview.widget.SimpleItemAnimator import com.logitech.vc.kirbytest.databinding.ActivityMainBinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import settingsDialog @@ -166,6 +167,11 @@ class MainActivity : AppCompatActivity() { 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 { @@ -176,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 { val deviceName = (device.name ?: "").lowercase() return deviceName.contains("kirby") || deviceName.contains("krby") @@ -323,6 +344,8 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { loggerDb.close() super.onDestroy() + + unregisterReceiver(mReceiver); } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { diff --git a/app/src/main/java/com/logitech/vc/kirbytest/parseUtils.kt b/app/src/main/java/com/logitech/vc/kirbytest/parseUtils.kt new file mode 100644 index 0000000..ea6d49d --- /dev/null +++ b/app/src/main/java/com/logitech/vc/kirbytest/parseUtils.kt @@ -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 +} diff --git a/app/src/test/java/com/logitech/vc/kirbytest/DecoderTest.kt b/app/src/test/java/com/logitech/vc/kirbytest/DecoderTest.kt index b4cca1e..09000aa 100644 --- a/app/src/test/java/com/logitech/vc/kirbytest/DecoderTest.kt +++ b/app/src/test/java/com/logitech/vc/kirbytest/DecoderTest.kt @@ -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 ) 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) + } } \ No newline at end of file diff --git a/build.gradle b/build.gradle index 6498050..1933cd8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.5.0' apply false - id 'com.android.library' version '8.5.0' apply false + id 'com.android.application' version '8.5.2' apply false + id 'com.android.library' version '8.5.2' apply false id 'org.jetbrains.kotlin.android' version '1.8.20' apply false } \ No newline at end of file