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 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
@@ -55,10 +104,6 @@ object DecoderIaq {
parseUnsignedInt(inputBytes, 8, 9) ushr 4 and INVALID_OCCUPANCY parseUnsignedInt(inputBytes, 8, 9) ushr 4 and INVALID_OCCUPANCY
measurement.occupancy = if (occupancy == INVALID_OCCUPANCY) null else 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) { if (msgType == 0) {
val pm25 = val pm25 =
parseUnsignedInt(inputBytes, 8, 10) ushr 2 and INVALID_PM25 parseUnsignedInt(inputBytes, 8, 10) ushr 2 and INVALID_PM25
@@ -70,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,
@@ -100,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 +
@@ -116,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

@@ -9,9 +9,11 @@ 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
@@ -35,7 +37,6 @@ 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 settingsDialog
@@ -166,6 +167,11 @@ class MainActivity : AppCompatActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 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 {
@@ -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 { 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")
@@ -323,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,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 ) 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.5.0' apply false id 'com.android.application' version '8.5.2' apply false
id 'com.android.library' version '8.5.0' 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
} }