feat: add device status uplink decoder
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 +
|
||||||
|
'}'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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?) {
|
||||||
|
|||||||
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 )
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user