feat: add device status uplink decoder

This commit is contained in:
Stefan Zollinger
2024-08-23 15:00:42 +02:00
parent d210a2754d
commit bcd501a44b
6 changed files with 193 additions and 39 deletions

View File

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

View File

@@ -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<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 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 +
'}'
}
}
}

View File

@@ -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?) {

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

@@ -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)
}
}

View File

@@ -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
}