Compare commits

...

7 Commits

Author SHA1 Message Date
Awin Huang
943f6b65c2 Update targetSdk to 33 2024-10-28 15:27:18 +08:00
Awin Huang
a549304540 Align rawData, decodeSuccess, source to AutoServer 2024-10-28 15:22:11 +08:00
Awin Huang
d691c9c47c conflict fixed 2024-10-25 14:57:52 +08:00
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
15 changed files with 488 additions and 108 deletions

View File

@@ -10,7 +10,7 @@ android {
defaultConfig {
applicationId "com.logitech.vc.kirbytest"
minSdk 27
targetSdk 32
targetSdk 33
versionCode 1
versionName "1.0"
@@ -88,4 +88,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 {
@@ -150,8 +172,8 @@ class KirbyDevice(
payload = Payload(measurement.toString())
Log.i("BleListener", "Char received: $payload")
val base64Payload = Base64.getEncoder().encodeToString(characteristic.value)
// publishMeasurement(base64Payload)
publishMeasurementAutoServer(measurement)
publishMeasurement(base64Payload)
publishMeasurementAutoServer(hexPayload, measurement as DecoderIaq.Measurement)
loggerDb.writeLog(measurement)
}
@@ -165,10 +187,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
}
@@ -195,7 +217,7 @@ class KirbyDevice(
queue.add(request)
}
private fun publishMeasurementAutoServer(measurement: DecoderIaq.Measurement) {
private fun publishMeasurementAutoServer(hexPayload: String, measurement: DecoderIaq.Measurement) {
// Read url from SharedPreferences
val sharedPref = context.getSharedPreferences(context.getString(R.string.app_name), Context.MODE_PRIVATE)
val url = sharedPref.getString(
@@ -217,6 +239,7 @@ class KirbyDevice(
postData.put("eui", eui)
postData.put("deviceId", measurement.deviceId)
postData.put("msgType", measurement.msgType)
postData.put("rawData", hexPayload)
postData.put("co2", measurement.co2)
postData.put("voc", measurement.voc)
postData.put("humidity", measurement.humidity)
@@ -225,6 +248,8 @@ class KirbyDevice(
postData.put("occupancy", measurement.occupancy)
postData.put("pm25", measurement.pm25)
postData.put("pm10", measurement.pm10)
postData.put("decodeSuccess", true)
postData.put("source", "bt")
} catch (e: JSONException) {
e.printStackTrace()
}
@@ -417,26 +442,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,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
@@ -20,6 +23,7 @@ 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.CreateDocument
@@ -33,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
@@ -67,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
@@ -78,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)
@@ -91,7 +96,7 @@ class MainActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
settings = SettingsRepository(applicationContext)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -136,14 +141,14 @@ class MainActivity : AppCompatActivity() {
if (hasRequiredRuntimePermissions()) {
connectionManager.startScan()
/*
lifecycleScope.launch {
withContext(Dispatchers.Main) {
delay(5000L)
connectionManager.stopScan()
}
}
*/
}
Timer().schedule(object : TimerTask() {
@@ -151,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 {
@@ -161,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")
@@ -192,6 +228,7 @@ class MainActivity : AppCompatActivity() {
}
private fun addBondedDevices(): Unit {
bondedDevices.clear()
bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach {
bondedDevices.add(it.address)
newKirbyDevice(it)
@@ -199,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) {
@@ -227,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()
}
}
})
@@ -251,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)
@@ -301,6 +324,12 @@ class MainActivity : AppCompatActivity() {
return true
}
R.id.action_settings -> {
val settingsDialog = settingsDialog(this, settings)
settingsDialog.show()
return true
}
R.id.action_server_setting -> {
// Goto server setting intent
val intent = Intent(this, ServerSettingActivity::class.java)
@@ -322,6 +351,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,6 +12,11 @@
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" />
<item
android:id="@+id/action_server_setting"
android:orderInCategory="200"

View File

@@ -8,10 +8,15 @@
<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>
<string name="server_url">Server URL</string>
<string name="kirby_data_post_url_default">http://vc-automation.logitech.com/api/kirby/addData</string>
<string name="done">Done</string>
<string name="cancel">Cancel</string>
<string name="defaultString">Default</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 = false, errorVoc = false, errorTemp = false, errorCo2 = false, errorRadar = false)
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 = false, errorVoc = false, errorTemp = false, errorCo2 = false, errorRadar = false)
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