Compare commits

...

14 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
Stefan Zollinger
803ff2e41a Merge pull request #2 from Logitech/chore/test-ci
chore: better apk file name
2024-04-16 09:23:19 +02:00
Stefan Zollinger
91638d6ccd fix: ask for BLE permission before scanning 2024-04-16 08:58:45 +02:00
Stefan Zollinger
01bf2693ca chore: better apk file name 2024-04-12 14:21:01 +02:00
Stefan Zollinger
0aed2068ec chore: test release 2024-04-12 13:59:07 +02:00
Stefan Zollinger
a5425083da feat: add csv auto export 2024-04-12 12:13:02 +02:00
Stefan Zollinger
62ce221860 fix: properly handle connecting to bonded devices 2024-04-11 15:13:16 +02:00
Stefan Zollinger
7311ba335f feat: add auto release 2024-04-10 14:52:12 +02:00
Stefan Zollinger
bf7722747e fix: use utc timestamp in db 2024-04-10 14:34:32 +02:00
Stefan Zollinger
a3b2944e03 chore: build project on Github 2024-04-10 13:57:24 +02:00
Stefan Zollinger
e53a269b4f chore: rename project 2024-04-10 10:39:07 +02:00
27 changed files with 888 additions and 299 deletions

60
.github/workflows/push.yaml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Push
on: push
jobs:
build:
name: Generate App Bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: set up JDK
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
cache: gradle
- name: Make gradle executable
run: chmod +x gradlew
- name: Create env props
run: |
echo "apiUrl=${{ secrets.API_URL }}" > env.properties
echo "apiKey=${{ secrets.API_KEY }}" >> env.properties
- name: Bundle 'release' with Gradle
run: |
./gradlew assembleDebug
- name: Setup build tool version variable
shell: bash
run: |
BUILD_TOOL_VERSION=$(ls /usr/local/lib/android/sdk/build-tools/ | tail -n 1)
echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
echo Last build tool version is: $BUILD_TOOL_VERSION
- name: Sign package
id: sign
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/debug
signingKeyBase64: ${{ secrets.SIGNING_KEYSTORE }}
keyStorePassword: ${{ secrets.SIGNING_KEYSTORE_PASSWORD }}
alias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
- run: echo "Build status report=${{ job.status }}."
- name: Get tag or commit id
id: get-version-id
uses: iawia002/get-tag-or-commit-id@v1
with:
length: 7
- name: Rename
run: mv ${{steps.sign.outputs.signedReleaseFile}} app/build/outputs/apk/debug/kirby_${{steps.get-version-id.outputs.id}}.apk
- name: Upload App Bundle
uses: actions/upload-artifact@v1
with:
name: signed_apk
path: app/build/outputs/apk/debug/kirby_${{steps.get-version-id.outputs.id}}.apk
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: app/build/outputs/apk/debug/kirby_${{steps.get-version-id.outputs.id}}.apk

2
.idea/.name generated
View File

@@ -1 +1 @@
Sensor Testing App
Kirby Test App

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

@@ -4,11 +4,11 @@ plugins {
}
android {
namespace 'com.example.sensortestingapp'
namespace 'com.logitech.vc.kirbytest'
compileSdk 32
defaultConfig {
applicationId "com.example.sensortestingapp"
applicationId "com.logitech.vc.kirbytest"
minSdk 29
targetSdk 32
versionCode 1
@@ -59,8 +59,10 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.2.0'
implementation 'com.google.code.gson:gson:2.8.8'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
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

@@ -1,4 +1,4 @@
package com.example.sensortestingapp
package com.logitech.vc.kirbytest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
@@ -17,6 +17,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.sensortestingapp", appContext.packageName)
assertEquals("com.logitech.vc.kirbytest", appContext.packageName)
}
}

View File

@@ -1,117 +0,0 @@
package com.example.sensortestingapp
import android.util.Log
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
private const val INVALID_HUMIDITY = 1023
private const val INVALID_TEMPERATURE = 2047
private const val INVALID_PRESSURE = 65535
private const val INVALID_OCCUPANCY = 3
private const val INVALID_PM25 = 1023
private const val INVALID_PM10 = 1023
private val supportedMessageTypes = listOf<Number>(0, 1)
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;
}
measurement.msgType = msgType
val co2 = parseUnsignedInt(inputBytes, 0, 3) ushr 6 and INVALID_CO2
measurement.co2 = if (co2 == INVALID_CO2) null else co2
val voc = parseUnsignedInt(inputBytes, 2, 4) ushr 3 and INVALID_VOC
measurement.voc = if (co2 == INVALID_VOC) null else voc
val humidity =
parseUnsignedInt(inputBytes, 3, 5) ushr 1 and INVALID_HUMIDITY
measurement.humidity = if (humidity == INVALID_HUMIDITY) null else humidity / 10
val temperature =
parseUnsignedInt(inputBytes, 4, 7) ushr 6 and INVALID_TEMPERATURE
measurement.temperature =
if (temperature == INVALID_TEMPERATURE) null else ((temperature / 10.0 - 40) * 10.0).roundToInt() / 10.0
val pressure =
parseUnsignedInt(inputBytes, 6, 9) ushr 6 and INVALID_PRESSURE
measurement.pressure =
if (pressure == INVALID_PRESSURE) null else (30000 + 19000.0 * pressure / 13107).roundToInt()
val occupancy =
parseUnsignedInt(inputBytes, 8, 9) 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
measurement.pm25 = if (pm25 == INVALID_PM25) null else pm25
val pm10 = parseUnsignedInt(inputBytes, 9, 11) and INVALID_PM10
measurement.pm10 = if (pm10 == INVALID_PM10) null else pm10
}
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)
}
}
data class Measurement (
var msgType: Number? = null,
var co2: Number? = null,
var voc: Number? = null,
var humidity: Number? = null,
var temperature: Number? = null,
var pressure: Number? = null,
var occupancy: Number? = null,
var pm25: Number? = null,
var pm10: Number? = null
) {
override fun toString(): String {
return "M{" +
"type=" + msgType +
", co2=" + co2 +
", voc=" + voc +
", hum=" + humidity +
", temp=" + temperature +
", press=" + pressure +
", pm25=" + pm25 +
", pm10=" + pm10 +
", occ=" + occupancy +
'}'
}
fun toCsv() : String {
return "${msgType},${co2},${voc},${humidity},${temperature},${pressure}${pm25},${pm10},${occupancy}"
}
}
}

View File

@@ -1,4 +1,4 @@
package com.example.sensortestingapp
package com.logitech.vc.kirbytest
import android.app.NotificationChannel
import android.app.NotificationManager

View File

@@ -1,4 +1,4 @@
package com.example.sensortestingapp
package com.logitech.vc.kirbytest
import android.annotation.SuppressLint
import android.app.Service
@@ -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
@@ -90,7 +91,7 @@ open class BleListener(private val deviceAddress: String?) {
open fun isRelevantMessage(address: String?): Boolean {
if (deviceAddress != null && deviceAddress == address) {
return true;
return true
}
return deviceAddress == null
}
@@ -165,7 +166,7 @@ private fun BluetoothGatt.printGattTable() {
separator = "\n|--",
prefix = "|--"
) {
"${it.uuid.toString()} | " +
"${it.uuid} | " +
"readable: ${it.isReadable()}, " +
"writable: ${it.isWritable()}, " +
"writableWithoutResponse: ${it.isWritableWithoutResponse()}, " +
@@ -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)
}
}
@@ -325,7 +322,11 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
fun connect(device: BluetoothDevice) {
if (device.isConnected()) {
Log.i("ConnectionManager", "connecting to " +device.address)
val isConnecting = pendingOperation != null && pendingOperation is Connect && (pendingOperation as Connect).device.address == device.address
if(device.isConnected() or isConnecting or operationQueue.any { it.device.address === device.address && it is Connect }) {
Log.e("ConnectionManager", "Already connected to ${device.address}!")
} else {
enqueueOperation(Connect(device, context))
@@ -349,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))
}
@@ -373,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))
}
@@ -394,9 +401,11 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
@Synchronized
private fun enqueueOperation(operation: BleOperationType) {
/*
if (isScanning) {
stopScan()
}
*/
operationQueue.add(operation)
notifyListenersOfQueueChange(operation.device.address)
if (pendingOperation == null) {
@@ -526,7 +535,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
}
is SetNotification -> with(operation) {
val characteristic = gatt.getService(serviceId)?.getCharacteristic(charId);
val characteristic = gatt.getService(serviceId)?.getCharacteristic(charId)
if (characteristic == null) {
Log.e("ConnectionManager", "Char $charId (${serviceId}) not found!")
@@ -558,7 +567,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
payload =
if (characteristic.isIndicatable())
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
} else {
if (!gatt.setCharacteristicNotification(characteristic, false)) {
@@ -611,7 +620,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
it.onConnectToBondedFailed(gatt)
}
signalEndOfOperation(operation)
} else if (status == BluetoothGatt.GATT_SUCCESS) {
} else if (status == GATT_SUCCESS) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.i(
"ConnectionManager",
@@ -647,7 +656,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
with(gatt) {
if (status == BluetoothGatt.GATT_SUCCESS) {
if (status == GATT_SUCCESS) {
Log.w(
"ConnectionManager",
"Discovered ${services.size} services for ${device.address}."
@@ -671,7 +680,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.w(
"ConnectionManager",
"ATT MTU changed to $mtu, success: ${status == BluetoothGatt.GATT_SUCCESS}"
"ATT MTU changed to $mtu, success: ${status == GATT_SUCCESS}"
)
val operation = pendingOperation
@@ -687,7 +696,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
) {
with(characteristic) {
when (status) {
BluetoothGatt.GATT_SUCCESS -> {
GATT_SUCCESS -> {
Log.i(
"ConnectionManager",
"Read characteristic $uuid (service: ${service.uuid}): ${value.toHexString()}"

View File

@@ -1,4 +1,4 @@
package com.example.sensortestingapp
package com.logitech.vc.kirbytest
import android.content.ContentValues
import android.content.Context
@@ -10,8 +10,11 @@ import android.util.Log
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.google.gson.reflect.TypeToken
import java.time.LocalDateTime
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.SortedMap
object LoggerContract {
@@ -31,6 +34,7 @@ object LoggerContract {
private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${LogEntry.TABLE_NAME}"
class LoggerDbHelper(context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
@@ -61,16 +65,15 @@ object LoggerContract {
private val dbHelper = LoggerDbHelper(context)
private val dbWrite = dbHelper.writableDatabase
private val dbRead = dbHelper.writableDatabase
private val tag = "LoggerDb"
val context: Context = context
fun writeLog(payload: Any): Long? {
val gson = Gson()
val jsonString = gson.toJson(payload)
val date = LocalDateTime.now()
val ts = date.format(DateTimeFormatter.ISO_DATE_TIME)
Log.i("Database", jsonString)
val ts = Instant.now().toString()
val values = ContentValues().apply {
put(LogEntry.COLUMN_NAME_TS, ts)
@@ -80,6 +83,21 @@ object LoggerContract {
return dbWrite?.insert(LogEntry.TABLE_NAME, null, values)
}
fun getExportFileUri(): Uri? {
val file = File(context.filesDir, "export.csv")
if (!file.exists()) {
file.createNewFile()
}
file.setReadable(true, false)
return Uri.fromFile(file)
}
fun exportToCsv() {
val uri = getExportFileUri() ?: return
exportToUri(uri)
}
fun exportToUri(uri: Uri) {
val projection =
@@ -100,7 +118,7 @@ object LoggerContract {
try {
val gson = Gson()
val type = object : TypeToken<HashMap<String, Any>>() {}.type
var headerWritten = false
val sep = ","
@@ -112,30 +130,26 @@ object LoggerContract {
val ts = getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_TS))
val storedField =
getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_PAYLOAD))
val payload = parsePayload(storedField)
try {
val payload: HashMap<String, Any> = gson.fromJson(storedField, type)
if (!headerWritten) {
val headerRow =
"timestamp" + sep + payload.keys.joinToString(sep) + newLine
"timestamp" + sep + "local_time" + sep + payload.keys.joinToString(sep) + newLine
writer.write(headerRow.toByteArray())
headerWritten = true
}
val row = ts + sep + payload.values.joinToString(sep) + newLine
val localTime = convertIsoToLocalTime(ts)
val row = ts + sep + localTime + sep + payload.values.joinToString(sep) + newLine
writer.write(row.toByteArray())
} catch (exception: JsonSyntaxException) {
Log.e("db", exception.toString())
Log.e(tag, exception.toString())
}
}
}
}
truncate()
} catch (e: Exception) {
e.printStackTrace()
}
@@ -143,9 +157,79 @@ object LoggerContract {
cursor.close()
}
private fun truncate() {
dbWrite.execSQL("DELETE FROM ${LogEntry.TABLE_NAME}");
dbWrite.execSQL("VACUUM");
fun exportToMultipleCSV() {
val projection =
arrayOf(BaseColumns._ID, LogEntry.COLUMN_NAME_PAYLOAD, LogEntry.COLUMN_NAME_TS)
val sortOrder = "${BaseColumns._ID} ASC"
val cursor = dbRead.query(
LogEntry.TABLE_NAME, // The table to query
projection, // The array of columns to return (pass null to get all)
null, // The columns for the WHERE clause
null, // The values for the WHERE clause
null, // don't group the rows
null, // don't filter by row groups
sortOrder // The sort order
)
val files = HashMap<String, File>()
try {
val sep = ","
val newLine = '\n'
with(cursor) {
while (moveToNext()) {
val ts = getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_TS))
val storedField =
getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_PAYLOAD))
try {
val payload = parsePayload(storedField)
val deviceId = payload.getOrDefault("bleAddress", "unknown") as String
val fileName = "kirby_export_${deviceId.replace(":", "")}.csv"
val f = files.getOrElse(deviceId) {
val file = File(context.filesDir, fileName)
if (!file.exists()) {
file.createNewFile()
}
val headerRow =
"timestamp" + sep + "local_time" + sep + payload.keys.joinToString(
sep
) + newLine
file.writeText(headerRow)
files[deviceId] = file
file.setReadable(true, false)
file
}
val localTime = convertIsoToLocalTime(ts)
val row =
ts + sep + localTime + sep + payload.values.joinToString(sep) + newLine
f.appendText(row)
} catch (exception: JsonSyntaxException) {
Log.e(tag, exception.toString())
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
cursor.close()
}
fun reset() {
dbWrite.execSQL("DELETE FROM ${LogEntry.TABLE_NAME}")
dbWrite.execSQL("VACUUM")
}
fun close() {
@@ -155,7 +239,16 @@ object LoggerContract {
}
}
fun parsePayload(payload: String): SortedMap<String, Any> {
val type = object : TypeToken<HashMap<String, Any>>() {}.type
val gson = Gson()
val parsed : HashMap<String, Any> = gson.fromJson(payload, type)
return parsed.toSortedMap()
}
fun convertIsoToLocalTime(isoDateTime: String): String {
val systemZone = ZoneId.systemDefault()
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
return Instant.parse(isoDateTime).atZone(systemZone).format(formatter)
}

View File

@@ -0,0 +1,181 @@
package com.logitech.vc.kirbytest
import com.google.gson.annotations.SerializedName
import kotlin.math.roundToInt
object DecoderIaq {
private const val INVALID_CO2 = 16383
private const val INVALID_VOC = 2047
private const val INVALID_HUMIDITY = 1023
private const val INVALID_TEMPERATURE = 2047
private const val INVALID_PRESSURE = 65535
private const val INVALID_OCCUPANCY = 3
private const val INVALID_PM25 = 1023
private const val INVALID_PM10 = 1023
private val supportedMessageTypes = listOf<Number>(0, 1, 5)
fun parseMeasurement(input: String): Uplink? {
val inputBytes = hexStringToByteArray(input)
val msgType = inputBytes[0].toInt() and 0xFF ushr 4
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
measurement.co2 = if (co2 == INVALID_CO2) null else co2
val voc = parseUnsignedInt(inputBytes, 2, 4) ushr 3 and INVALID_VOC
measurement.voc = if (co2 == INVALID_VOC) null else voc
val humidity =
parseUnsignedInt(inputBytes, 3, 5) ushr 1 and INVALID_HUMIDITY
measurement.humidity = if (humidity == INVALID_HUMIDITY) null else humidity / 10
val temperature =
parseUnsignedInt(inputBytes, 4, 7) ushr 6 and INVALID_TEMPERATURE
measurement.temperature =
if (temperature == INVALID_TEMPERATURE) null else ((temperature / 10.0 - 40) * 10.0).roundToInt() / 10.0
val pressure =
parseUnsignedInt(inputBytes, 6, 9) ushr 6 and INVALID_PRESSURE
measurement.pressure =
if (pressure == INVALID_PRESSURE) null else (30000 + 19000.0 * pressure / 13107).roundToInt()
val occupancy =
parseUnsignedInt(inputBytes, 8, 9) 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
measurement.pm25 = if (pm25 == INVALID_PM25) null else pm25
val pm10 = parseUnsignedInt(inputBytes, 9, 11) and INVALID_PM10
measurement.pm10 = if (pm10 == INVALID_PM10) null else pm10
}
return measurement
}
interface Uplink {
var deviceId: String?
var msgType: Number?
}
data class Measurement(
@SerializedName("bleAddress")
override var deviceId: String? = null,
override var msgType: Number? = null,
var co2: Number? = null,
var voc: Number? = null,
var humidity: Number? = null,
var temperature: Number? = null,
var pressure: Number? = null,
var occupancy: Number? = null,
var pm25: Number? = null,
var pm10: Number? = null
) : Uplink {
override fun toString(): String {
return "M{" +
"type=" + msgType +
", co2=" + co2 +
", voc=" + voc +
", hum=" + humidity +
", temp=" + temperature +
", press=" + pressure +
", pm25=" + pm25 +
", pm10=" + pm10 +
", 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,4 +1,4 @@
package com.example.sensortestingapp
package com.logitech.vc.kirbytest
import android.annotation.SuppressLint
import android.content.Context

View File

@@ -1,5 +1,6 @@
package com.example.sensortestingapp
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,32 +33,54 @@ 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)) {
connectionManager.enableNotification(
bleDevice, SERVICE_UUID, CHAR_UUID
)
}
}
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() {
connectionManager.readChar(bleDevice, SERVICE_UUID, CHAR_UUID)
}
override fun onSuccessfulCharRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
addMeasurement(characteristic)
onStateChange(this)
}
@@ -72,7 +93,9 @@ class KirbyDevice(
statuses.add(DeviceStatus.CONNECTED)
statuses.remove(DeviceStatus.MISSING)
subscribe()
onStateChange(this)
}
override fun onConnectToBondedFailed(gatt: BluetoothGatt) {
@@ -84,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)
}
@@ -113,6 +144,7 @@ class KirbyDevice(
override fun onBonded(device: BluetoothDevice) {
statuses.add(DeviceStatus.BONDED)
onStateChange(this)
connect()
}
override fun onUnbonded(device: BluetoothDevice) {
@@ -132,10 +164,11 @@ 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 {
measurement.deviceId = bleDevice.address
payload = Payload(measurement.toString())
Log.i("BleListener", "Char received: $payload")
val base64Payload = Base64.getEncoder().encodeToString(characteristic.value)
@@ -153,8 +186,13 @@ 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() || accessKey.isEmpty()) {
return
}
val eui = "0000${bleDevice.address.replace(":", "")}"
val postData = JSONObject()
@@ -346,7 +384,7 @@ class KirbyDevice(
})
}
return actions;
return actions
}
}
@@ -356,33 +394,18 @@ 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 {
return payload.payload
}
override fun getIcon(): Int? {
override fun getIcon(): Int {
return R.drawable.baseline_numbers_24
}
}

View File

@@ -1,5 +1,6 @@
package com.example.sensortestingapp
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,17 +23,27 @@ 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
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.SimpleItemAnimator
import com.example.sensortestingapp.databinding.ActivityMainBinding
import com.logitech.vc.kirbytest.databinding.ActivityMainBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import settingsDialog
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Timer
import java.util.TimerTask
private const val ENABLE_BLUETOOTH_REQUEST_CODE = 1
@@ -57,7 +70,8 @@ class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
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
@@ -69,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)
@@ -82,7 +96,7 @@ class MainActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
settings = SettingsRepository(applicationContext)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -108,13 +122,57 @@ class MainActivity : AppCompatActivity() {
setupDevicesList()
createFileLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri: Uri? ->
registerForActivityResult(CreateDocument("text/csv")) { uri: Uri? ->
uri?.let {
// Use the URI to write your CSV content
CoroutineScope(Dispatchers.IO).launch {
loggerDb.exportToUri(uri)
withContext(Dispatchers.Main) {
Toast.makeText(
this@MainActivity,
"Log export completed.",
Toast.LENGTH_SHORT
).show()
}
}
}
}
if (hasRequiredRuntimePermissions()) {
connectionManager.startScan()
/*
lifecycleScope.launch {
withContext(Dispatchers.Main) {
delay(5000L)
connectionManager.stopScan()
}
}
*/
}
Timer().schedule(object : TimerTask() {
override fun run() {
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 {
if (connectionManager.isScanning) {
@@ -124,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")
@@ -155,26 +228,20 @@ class MainActivity : AppCompatActivity() {
}
private fun addBondedDevices(): Unit {
bondedDevices.clear()
bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach {
val kirbyDevice = newKirbyDevice(it)
kirbyDevice.subscribe()
bondedDevices.add(it.address)
newKirbyDevice(it)
}
}
@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) {
override fun onScanningStateChange(isScanning: Boolean) {
runOnUiThread {
binding.fab.setText(if (isScanning) "Stop Scan" else "Start Scan")
binding.fab.text = if (isScanning) "Stop Scan" else "Start Scan"
binding.fab.setIconResource(
if (isScanning) R.drawable.action_icon_disconnect else R.drawable.action_icon_scan
)
@@ -183,34 +250,39 @@ class MainActivity : AppCompatActivity() {
}
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (isKirbyDevice(result.device)) {
if (isKirbyDevice(result.device) && result.isConnectable) {
Log.i(
"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) {
newKirbyDevice(result.device).onScanResult(callbackType, result)
kirbyDevice = newKirbyDevice(result.device)
kirbyDevice.onScanResult(callbackType, result)
}
kirbyDevice.connect()
}
}
})
return mngr
}
private fun newKirbyDevice(bleDevice: BluetoothDevice): KirbyDevice {
private fun newKirbyDevice(
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)
}
}
Log.i("MainActivity", bleDevice.address)
connectionManager.register(device)
connectionManager.connect(bleDevice)
connectionManager.discoverServices(bleDevice)
kirbyDevices.add(device)
deviceListAdapter.notifyItemInserted(kirbyDevices.size - 1)
return device
@@ -236,6 +308,28 @@ class MainActivity : AppCompatActivity() {
return true
}
R.id.action_reset_log -> {
val builder = AlertDialog.Builder(this@MainActivity)
builder.setMessage("Are you sure you want to reset the log?")
.setCancelable(false)
.setPositiveButton("Yes") { dialog, id ->
loggerDb.reset()
}
.setNegativeButton("No") { dialog, id ->
// Dismiss the dialog
dialog.dismiss()
}
val alert = builder.create()
alert.show()
return true
}
R.id.action_settings -> {
val settingsDialog = settingsDialog(this, settings)
settingsDialog.show()
return true
}
else -> super.onOptionsItemSelected(item)
}
}
@@ -250,6 +344,8 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
loggerDb.close()
super.onDestroy()
unregisterReceiver(mReceiver);
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@@ -407,7 +503,7 @@ class DummyListEntry(override val address: String) : DeviceListEntry {
return "Test action 1"
}
override fun getIcon(): Int? {
override fun getIcon(): Int {
return R.drawable.action_icon_disconnect
}
@@ -419,7 +515,7 @@ class DummyListEntry(override val address: String) : DeviceListEntry {
return "Test action 2"
}
override fun getIcon(): Int? {
override fun getIcon(): Int {
return R.drawable.action_icon_connect
}
@@ -439,7 +535,7 @@ class DummyListEntry(override val address: String) : DeviceListEntry {
return "21.2 °C"
}
override fun getIcon(): Int? {
override fun getIcon(): Int {
return R.drawable.baseline_device_thermostat_24
}
}, object : Measurement {
@@ -451,7 +547,7 @@ class DummyListEntry(override val address: String) : DeviceListEntry {
return "232 bar"
}
override fun getIcon(): Int? {
override fun getIcon(): Int {
return R.drawable.baseline_compress_24
}
})

View File

@@ -1,4 +1,4 @@
package com.example.sensortestingapp;
package com.logitech.vc.kirbytest;
import android.content.Context;
import android.util.AttributeSet;

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

@@ -124,7 +124,7 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<com.example.sensortestingapp.NonScrollListView
<com.logitech.vc.kirbytest.NonScrollListView
android:id="@+id/measurement_fields"
android:layout_width="match_parent"
android:layout_height="wrap_content"

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

@@ -1,3 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:tools="http://schemas.android.com/tools"
tools:context="com.example.sensortestingapp.MainActivity" />
tools:context="com.logitech.vc.kirbytest.MainActivity" />

View File

@@ -1,10 +1,20 @@
<menu 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"
tools:context="com.example.sensortestingapp.MainActivity">
tools:context="com.logitech.vc.kirbytest.MainActivity">
<item
android:id="@+id/action_export"
android:orderInCategory="100"
android:title="@string/action_export"
app:showAsAction="never" />
<item
android:id="@+id/action_reset_log"
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

@@ -1,46 +1,17 @@
<resources>
<string name="app_name">Kirby Test App</string>
<string name="action_export">Export to csv</string>
<string name="action_reset_log">Reset log</string>
<!-- Strings used for fragments for navigation -->
<string name="first_fragment_label">First Fragment</string>
<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="lorem_ipsum">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam in scelerisque sem. Mauris
volutpat, dolor id interdum ullamcorper, risus dolor egestas lectus, sit amet mattis purus
dui nec risus. Maecenas non sodales nisi, vel dictum dolor. Class aptent taciti sociosqu ad
litora torquent per conubia nostra, per inceptos himenaeos. Suspendisse blandit eleifend
diam, vel rutrum tellus vulputate quis. Aliquam eget libero aliquet, imperdiet nisl a,
ornare ex. Sed rhoncus est ut libero porta lobortis. Fusce in dictum tellus.\n\n
Suspendisse interdum ornare ante. Aliquam nec cursus lorem. Morbi id magna felis. Vivamus
egestas, est a condimentum egestas, turpis nisl iaculis ipsum, in dictum tellus dolor sed
neque. Morbi tellus erat, dapibus ut sem a, iaculis tincidunt dui. Interdum et malesuada
fames ac ante ipsum primis in faucibus. Curabitur et eros porttitor, ultricies urna vitae,
molestie nibh. Phasellus at commodo eros, non aliquet metus. Sed maximus nisl nec dolor
bibendum, vel congue leo egestas.\n\n
Sed interdum tortor nibh, in sagittis risus mollis quis. Curabitur mi odio, condimentum sit
amet auctor at, mollis non turpis. Nullam pretium libero vestibulum, finibus orci vel,
molestie quam. Fusce blandit tincidunt nulla, quis sollicitudin libero facilisis et. Integer
interdum nunc ligula, et fermentum metus hendrerit id. Vestibulum lectus felis, dictum at
lacinia sit amet, tristique id quam. Cras eu consequat dui. Suspendisse sodales nunc ligula,
in lobortis sem porta sed. Integer id ultrices magna, in luctus elit. Sed a pellentesque
est.\n\n
Aenean nunc velit, lacinia sed dolor sed, ultrices viverra nulla. Etiam a venenatis nibh.
Morbi laoreet, tortor sed facilisis varius, nibh orci rhoncus nulla, id elementum leo dui
non lorem. Nam mollis ipsum quis auctor varius. Quisque elementum eu libero sed commodo. In
eros nisl, imperdiet vel imperdiet et, scelerisque a mauris. Pellentesque varius ex nunc,
quis imperdiet eros placerat ac. Duis finibus orci et est auctor tincidunt. Sed non viverra
ipsum. Nunc quis augue egestas, cursus lorem at, molestie sem. Morbi a consectetur ipsum, a
placerat diam. Etiam vulputate dignissim convallis. Integer faucibus mauris sit amet finibus
convallis.\n\n
Phasellus in aliquet mi. Pellentesque habitant morbi tristique senectus et netus et
malesuada fames ac turpis egestas. In volutpat arcu ut felis sagittis, in finibus massa
gravida. Pellentesque id tellus orci. Integer dictum, lorem sed efficitur ullamcorper,
libero justo consectetur ipsum, in mollis nisl ex sed nisl. Donec maximus ullamcorper
sodales. Praesent bibendum rhoncus tellus nec feugiat. In a ornare nulla. Donec rhoncus
libero vel nunc consequat, quis tincidunt nisl eleifend. Cras bibendum enim a justo luctus
vestibulum. Fusce dictum libero quis erat maximus, vitae volutpat diam dignissim.
</string>
</resources>

View File

@@ -1,27 +0,0 @@
package com.example.sensortestingapp
import org.junit.Assert.*
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class DecoderTest {
@Test
fun message_type_0_decodes_correctly() {
val res2 = DecoderIaq.parseMeasurement("006b04ab74a1ed0d101404");
val testMeasurement = DecoderIaq.Measurement(msgType = 0, co2 = 428, voc = 149, humidity = 44, pressure = 96873, occupancy = 1, pm10 = 4, pm25 = 5, temperature = 24.7 )
assertEquals(testMeasurement, res2)
}
@Test
fun message_type_1_decodes_correctly() {
val res2 = DecoderIaq.parseMeasurement("106b04ab74a1ed0d10");
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)
}
}

View File

@@ -0,0 +1,50 @@
package com.logitech.vc.kirbytest
import org.junit.Assert.*
import org.junit.Test
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class DecoderTest {
@Test
fun message_type_0_decodes_correctly() {
val res2 = DecoderIaq.parseMeasurement("006b04ab74a1ed0d101404")
val testMeasurement = DecoderIaq.Measurement(msgType = 0, co2 = 428, voc = 149, humidity = 44, pressure = 96873, occupancy = 1, pm10 = 4, pm25 = 5, temperature = 24.7 )
assertEquals(testMeasurement, res2)
}
@Test
fun message_type_1_decodes_correctly() {
val res2 = DecoderIaq.parseMeasurement("106b04ab74a1ed0d10")
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

View File

@@ -12,5 +12,5 @@ dependencyResolutionManagement {
mavenCentral()
}
}
rootProject.name = "Sensor Testing App"
rootProject.name = "Kirby Test App"
include ':app'