feat: add device status uplink decoder
This commit is contained in:
@@ -204,7 +204,6 @@ object LoggerContract {
|
||||
|
||||
files[deviceId] = file
|
||||
|
||||
Log.i(tag, file.absolutePath)
|
||||
file.setReadable(true, false)
|
||||
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 +
|
||||
'}'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?) {
|
||||
|
||||
49
app/src/main/java/com/logitech/vc/kirbytest/parseUtils.kt
Normal file
49
app/src/main/java/com/logitech/vc/kirbytest/parseUtils.kt
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user