Compare commits

...

4 Commits

Author SHA1 Message Date
Stefan Zollinger
bcd501a44b feat: add device status uplink decoder 2024-08-23 15:00:42 +02:00
Stefan Zollinger
d210a2754d feat: improve scanning stability & connect to freshly bonded devs 2024-08-19 14:39:23 +02:00
Stefan Zollinger
48c6b2c314 feat: support BLE low power mode 2024-08-16 11:06:01 +02:00
Stefan Zollinger
83b120b1ce feat: add api configuration settings 2024-04-16 18:22:05 +02:00
16 changed files with 483 additions and 108 deletions

5
.idea/gradle.xml generated
View File

@@ -4,16 +4,15 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

View File

@@ -64,4 +64,5 @@ dependencies {
implementation group: 'commons-codec', name: 'commons-codec', version: '1.16.0'
implementation "com.android.volley:volley:1.2.1"
implementation "androidx.datastore:datastore-preferences:1.0.0"
}

View File

@@ -18,6 +18,7 @@ import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.bluetooth.le.ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -210,10 +211,9 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
private val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
.build()
private val scanFilters = listOf( ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString(BuildConfig.SERVICE_UUID)).build())
private val bleScanner by lazy {
bleAdapter.bluetoothLeScanner
}
@@ -294,10 +294,6 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
@SuppressLint("MissingPermission")
val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
Log.d(
"ScanCallback",
"Found BLE device with address ${result.device.address} (name: ${result.device.name}, rssi: ${result.rssi})"
)
notifyListeners(result.device.address) { it.onScanResult(callbackType, result) }
}
@@ -314,7 +310,8 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
fun startScan() {
if (!isScanning) {
isScanning = true
bleScanner.startScan( null, scanSettings, scanCallback)
val filter = ScanFilter.Builder().setDeviceName("KIRBY").build()
bleScanner.startScan(listOf(filter), scanSettings, scanCallback)
}
}
@@ -353,6 +350,9 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
}
fun discoverServices(device: BluetoothDevice) {
if(operationQueue.any { it.device.address === device.address && it is DiscoverServicesRequest }) {
return
}
enqueueOperation(DiscoverServicesRequest(device))
}
@@ -377,6 +377,9 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
}
fun readRemoteRssi(device: BluetoothDevice) {
if(operationQueue.any { it.device.address === device.address && it is ReadRemoteRssi }) {
return
}
enqueueOperation(ReadRemoteRssi(device))
}

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")
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
@@ -66,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
}
private fun hexStringToByteArray(encoded: String): ByteArray {
return try {
Hex.decodeHex(encoded)
} catch (e: DecoderException) {
throw RuntimeException(e)
}
interface Uplink {
var deviceId: String?
var msgType: Number?
}
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,
@@ -96,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 +
@@ -112,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

@@ -1,5 +1,6 @@
package com.logitech.vc.kirbytest
import SettingsRepository
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.BOND_BONDED
@@ -15,8 +16,6 @@ import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
import org.json.JSONException
import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Base64
@@ -34,15 +33,22 @@ enum class DeviceStatus {
@SuppressLint("MissingPermission")
class KirbyDevice(
private val context: Context,
private val connectionManager: ConnectionManager,
private val bleDevice: BluetoothDevice,
private val loggerDb: LoggerContract.LoggerDb,
private val settingsRepository: SettingsRepository,
private val onStateChange: (device: KirbyDevice) -> Unit,
) : BleListener(bleDevice.address), DeviceListEntry {
private val tag = "KirbyDevice"
private var lastSeen: Long = 0
private val queue: RequestQueue = Volley.newRequestQueue(context)
private val reconnectionDelayMs = 10_000
private val settings = settingsRepository.getSettings()
fun subscribe() {
if(statuses.contains(DeviceStatus.CONNECTED)) {
@@ -53,8 +59,14 @@ class KirbyDevice(
}
fun connect() {
val now = System.currentTimeMillis()
if (now - lastSeen > reconnectionDelayMs) {
Log.i(tag, "Connecting to device " + bleDevice.address)
connectionManager.connect(bleDevice)
connectionManager.discoverServices(bleDevice)
} else{
Log.i(tag, "Waiting before reconnecting to device " + bleDevice.address)
}
}
fun readIaq() {
@@ -66,7 +78,9 @@ class KirbyDevice(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
addMeasurement(characteristic)
onStateChange(this)
}
@@ -93,10 +107,18 @@ class KirbyDevice(
statuses.remove(DeviceStatus.CONNECTED)
statuses.remove(DeviceStatus.SUBSCRIBED)
onStateChange(this)
Log.i(tag, "Disconnected")
}
override fun onCharChange(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
addMeasurement(characteristic)
lastSeen = System.currentTimeMillis()
if(settings.lowPowerMode){
Log.i(tag, "Received data, closing connection")
connectionManager.teardownConnection(bleDevice)
}
onStateChange(this)
}
@@ -142,7 +164,7 @@ class KirbyDevice(
private fun addMeasurement(characteristic: BluetoothGattCharacteristic) {
val hexPayload = characteristic.value.toHexString().substring(2)
val measurement = DecoderIaq.parseMeasurement(hexPayload)
var payload : Payload
val payload : Payload
if (measurement == null) {
payload = Payload(hexPayload)
} else {
@@ -164,10 +186,10 @@ class KirbyDevice(
}
private fun publishMeasurement(payload: String) {
val accessKey = BuildConfig.API_KEY
val url = BuildConfig.API_BASE_URL
val accessKey = settings.apiKey
val url = settings.apiUrl
if(url.isEmpty()) {
if(url.isEmpty() || accessKey.isEmpty()) {
return
}
@@ -372,26 +394,11 @@ data class Payload(
.format(DateTimeFormatter.ofPattern("dd.MM.yy HH:mm:ss"))
)
fun bytesToUInt16(arr: ByteArray, start: Int): Int {
return ByteBuffer.wrap(arr, start, 2)
.order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
}
fun bytesToInt16(arr: ByteArray, start: Int): Short {
return ByteBuffer.wrap(arr, start, 2)
.order(ByteOrder.LITTLE_ENDIAN).short
}
fun bytesToInt32(arr: ByteArray, start: Int): Int {
return ByteBuffer.wrap(arr, start, 4)
.order(ByteOrder.LITTLE_ENDIAN).int
}
private fun payloadToMeasurements(payload: Payload): List<Measurement> {
return listOf(object : Measurement {
override fun getLabel(): String {
return payload.ts.toString()
return payload.ts
}
override fun getFormattedValue(): String {

View File

@@ -1,5 +1,6 @@
package com.logitech.vc.kirbytest
import SettingsRepository
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
@@ -8,22 +9,23 @@ 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
import android.os.Bundle
import android.os.Handler
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
@@ -35,9 +37,9 @@ 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
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Timer
@@ -69,6 +71,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var loggerDb: LoggerContract.LoggerDb
private lateinit var createFileLauncher: ActivityResultLauncher<String>
private val bondedDevices = HashSet<String>()
private lateinit var settings: SettingsRepository
private val bluetoothAdapter: BluetoothAdapter by lazy {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
@@ -80,7 +83,7 @@ class MainActivity : AppCompatActivity() {
newConnectionManager()
}
private val kirbyDevices = mutableListOf<DeviceListEntry>()
private val kirbyDevices = mutableListOf<KirbyDevice>()
private val deviceListAdapter: DeviceListAdapter by lazy {
DeviceListAdapter(kirbyDevices)
@@ -93,7 +96,7 @@ class MainActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
settings = SettingsRepository(applicationContext)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -138,14 +141,14 @@ class MainActivity : AppCompatActivity() {
if (hasRequiredRuntimePermissions()) {
connectionManager.startScan()
/*
lifecycleScope.launch {
withContext(Dispatchers.Main) {
delay(5000L)
connectionManager.stopScan()
}
}
*/
}
Timer().schedule(object : TimerTask() {
@@ -153,6 +156,22 @@ class MainActivity : AppCompatActivity() {
loggerDb.exportToMultipleCSV()
}
}, 1000, 10000)
lifecycleScope.launch {
settings.loadSettings()
if(settings.getSettings().lowPowerMode) {
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 {
@@ -163,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")
@@ -194,6 +228,7 @@ class MainActivity : AppCompatActivity() {
}
private fun addBondedDevices(): Unit {
bondedDevices.clear()
bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach {
bondedDevices.add(it.address)
newKirbyDevice(it)
@@ -201,14 +236,6 @@ class MainActivity : AppCompatActivity() {
}
@SuppressLint("NotifyDataSetChanged")
private fun addDummyDevices() {
for (i in 0..14) {
kirbyDevices.add(DummyListEntry("$i"))
}
deviceListAdapter.notifyDataSetChanged()
}
private fun newConnectionManager(): ConnectionManager {
val mngr = ConnectionManager(applicationContext, bluetoothAdapter)
mngr.register(object : BleListener(null) {
@@ -229,20 +256,13 @@ class MainActivity : AppCompatActivity() {
"ScanCallback",
"Found Kirby device with name ${result.device.name} (address: ${result.device.address}, rssi: ${result.rssi})"
)
val kirbyDevice = kirbyDevices.find { it.address == result.device.address }
var kirbyDevice = kirbyDevices.find { it.address == result.device.address }
if (kirbyDevice == null) {
val kirby = newKirbyDevice(result.device)
kirby.onScanResult(callbackType, result)
}
if (bondedDevices.contains(result.device.address)) {
Log.i("KirbyDevice", "Connecting to " + result.device.address)
connectionManager.connect(result.device)
connectionManager.readRemoteRssi(result.device)
connectionManager.discoverServices(result.device)
kirbyDevice = newKirbyDevice(result.device)
kirbyDevice.onScanResult(callbackType, result)
}
kirbyDevice.connect()
}
}
})
@@ -253,7 +273,8 @@ class MainActivity : AppCompatActivity() {
bleDevice: BluetoothDevice
): KirbyDevice {
val device = KirbyDevice(this.applicationContext, connectionManager, bleDevice, loggerDb) {
val device =
KirbyDevice(this.applicationContext, connectionManager, bleDevice, loggerDb, settings) {
val i = kirbyDevices.indexOfFirst { d -> d === it }
runOnUiThread {
deviceListAdapter.notifyItemChanged(i)
@@ -303,6 +324,12 @@ class MainActivity : AppCompatActivity() {
return true
}
R.id.action_settings -> {
val settingsDialog = settingsDialog(this, settings)
settingsDialog.show()
return true
}
else -> super.onOptionsItemSelected(item)
}
}
@@ -317,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,128 @@
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import android.content.Context
import android.content.DialogInterface
import android.util.Log
import android.view.LayoutInflater
import android.widget.CheckBox
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.logitech.vc.kirbytest.BuildConfig
import com.logitech.vc.kirbytest.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.net.URL
val Context.dataStore by preferencesDataStore(name = "app_settings")
class SettingsRepository(context: Context) {
private val dataStore = context.dataStore
private val settings = Settings(apiUrl = BuildConfig.API_BASE_URL, apiKey = BuildConfig.API_KEY)
private val coroutineScope = CoroutineScope(Dispatchers.IO)
companion object {
val API_URL = stringPreferencesKey("api_url")
val API_KEY = stringPreferencesKey("api_key")
val BLE_LOW_POWER_MODE = booleanPreferencesKey("ble_low_power_mode")
}
@OptIn(DelicateCoroutinesApi::class)
fun saveSettings(apiUrl: String, apiKey: String, isLowPowerMode: Boolean) {
settings.apiKey = apiKey
settings.apiUrl = apiUrl
settings.lowPowerMode = isLowPowerMode
coroutineScope.launch(Dispatchers.Main) {
dataStore.edit { preferences ->
preferences[API_URL] = apiUrl
preferences[API_KEY] = apiKey
preferences[BLE_LOW_POWER_MODE] = isLowPowerMode
}
}
}
fun getSettings(): Settings {
return settings
}
private val settingsFlow: Flow<Settings> = dataStore.data.map {
Settings(
apiUrl = it[API_URL] ?: BuildConfig.API_BASE_URL,
apiKey = it[API_KEY] ?: BuildConfig.API_KEY,
lowPowerMode = it[BLE_LOW_POWER_MODE] ?: true
)
}
suspend fun loadSettings() {
val result = settingsFlow.firstOrNull()
if (result != null) {
settings.apiKey = result.apiKey
settings.apiUrl = result.apiUrl
settings.lowPowerMode = result.lowPowerMode
}
}
}
data class Settings(var apiUrl: String, var apiKey: String, var lowPowerMode: Boolean = true)
fun settingsDialog(context: Context, settingsRepo: SettingsRepository): AlertDialog {
val layoutInflater = LayoutInflater.from(context)
val root = layoutInflater.inflate(R.layout.settings_dialog, null)
val urlField = root.findViewById<EditText>(R.id.apiUrl)
val keyField = root.findViewById<EditText>(R.id.apiKey)
val lowPowerMode = root.findViewById<CheckBox>(R.id.checkboxLowPowerMode)
val settings = settingsRepo.getSettings()
urlField.setText(settings.apiUrl)
keyField.setText(settings.apiKey)
lowPowerMode.isChecked = settings.lowPowerMode
return androidx.appcompat.app.AlertDialog.Builder(context)
.setTitle(R.string.settings)
.setView(root)
.setPositiveButton(R.string.save) { dialog: DialogInterface?, whichButton: Int ->
Log.d("SettingsDialog", "save settings")
val url = urlField.text.toString()
val key = keyField.text.toString()
val isLowPowerMode = lowPowerMode.isChecked
if (isFullPath(url) || url.isEmpty()) {
settingsRepo.saveSettings(url, key, isLowPowerMode)
}
}
.setNegativeButton(R.string.cancel) { dialog: DialogInterface?, whichButton: Int ->
//Do something
Log.d("SettingsDialog", "cancel settings")
}
.create()
}
fun isFullPath(potentialUrl: String): Boolean {
try {
URL(potentialUrl).toURI()
return true
} catch (e: Exception) {
e.printStackTrace()
}
return false
}

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

@@ -44,7 +44,7 @@
android:id="@+id/fab"
android:layout_width="150dp"
android:layout_height="match_parent"
android:text='Start Scan'
android:text='@string/start_scan'
app:icon="@drawable/action_icon_scan"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/apiUrlLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="10"
android:text="API url" />
<EditText
android:id="@+id/apiUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="10"
android:ems="10"
android:inputType="textUri"
android:text="" />
<TextView
android:id="@+id/apiKeyLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="10"
android:text="API key" />
<EditText
android:id="@+id/apiKey"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="20"
android:autofillHints="password"
android:ems="10"
android:inputType="text"
android:text="" />
<CheckBox android:id="@+id/checkboxLowPowerMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="BLE low power mode"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -12,4 +12,9 @@
android:orderInCategory="200"
android:title="@string/action_reset_log"
app:showAsAction="never" />
<item
android:id="@+id/action_settings"
android:orderInCategory="300"
android:title="@string/settings"
app:showAsAction="never" />
</menu>

View File

@@ -7,5 +7,11 @@
<string name="second_fragment_label">Second Fragment</string>
<string name="next">Next</string>
<string name="previous">Previous</string>
<string name="settings">Settings</string>
<string name="api_url">API url</string>
<string name="api_key">API key</string>
<string name="save">Save</string>
<string name="cancel">Cancel</string>
<string name="start_scan">Start Scan</string>
</resources>

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.1.2' apply false
id 'com.android.library' version '8.1.2' 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
}

View File

@@ -1,6 +1,6 @@
#Wed Jun 14 12:17:09 CEST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists