diff --git a/app/src/main/java/com/logitech/vc/kirbytest/ConnectionManager.kt b/app/src/main/java/com/logitech/vc/kirbytest/ConnectionManager.kt index b171c11..442d255 100644 --- a/app/src/main/java/com/logitech/vc/kirbytest/ConnectionManager.kt +++ b/app/src/main/java/com/logitech/vc/kirbytest/ConnectionManager.kt @@ -90,7 +90,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 +165,7 @@ private fun BluetoothGatt.printGattTable() { separator = "\n|--", prefix = "|--" ) { - "${it.uuid.toString()} | " + + "${it.uuid} | " + "readable: ${it.isReadable()}, " + "writable: ${it.isWritable()}, " + "writableWithoutResponse: ${it.isWritableWithoutResponse()}, " + @@ -325,7 +325,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)) @@ -528,7 +532,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!") @@ -560,7 +564,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)) { @@ -613,7 +617,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", @@ -649,7 +653,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}." @@ -673,7 +677,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 @@ -689,7 +693,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()}" diff --git a/app/src/main/java/com/logitech/vc/kirbytest/Database.kt b/app/src/main/java/com/logitech/vc/kirbytest/Database.kt index 7945095..589dd60 100644 --- a/app/src/main/java/com/logitech/vc/kirbytest/Database.kt +++ b/app/src/main/java/com/logitech/vc/kirbytest/Database.kt @@ -10,7 +10,11 @@ import android.util.Log import com.google.gson.Gson import com.google.gson.JsonSyntaxException import com.google.gson.reflect.TypeToken +import java.io.File import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.SortedMap object LoggerContract { @@ -30,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) { @@ -60,6 +65,9 @@ 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? { @@ -67,8 +75,6 @@ object LoggerContract { val jsonString = gson.toJson(payload) val ts = Instant.now().toString() - Log.i("Database", jsonString) - val values = ContentValues().apply { put(LogEntry.COLUMN_NAME_TS, ts) put(LogEntry.COLUMN_NAME_PAYLOAD, jsonString) @@ -77,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 = @@ -97,7 +118,7 @@ object LoggerContract { try { val gson = Gson() - val type = object : TypeToken>() {}.type + var headerWritten = false val sep = "," @@ -109,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 = 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() } @@ -140,9 +157,80 @@ 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() + + 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 + + Log.i(tag, file.absolutePath) + 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() { @@ -152,7 +240,16 @@ object LoggerContract { } } +fun parsePayload(payload: String): SortedMap { + val type = object : TypeToken>() {}.type + val gson = Gson() + val parsed : HashMap = 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) +} diff --git a/app/src/main/java/com/logitech/vc/kirbytest/DecoderIaq.kt b/app/src/main/java/com/logitech/vc/kirbytest/DecoderIaq.kt index 03bcad1..f385f22 100644 --- a/app/src/main/java/com/logitech/vc/kirbytest/DecoderIaq.kt +++ b/app/src/main/java/com/logitech/vc/kirbytest/DecoderIaq.kt @@ -1,6 +1,7 @@ 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 @@ -25,7 +26,7 @@ object DecoderIaq { if(!supportedMessageTypes.contains(msgType)) { Log.i("Decoder", "Invalid message type: $msgType") - return null; + return null } measurement.msgType = msgType @@ -84,6 +85,7 @@ object DecoderIaq { } data class Measurement ( + @SerializedName("bleAddress") var deviceId: String? = null, var msgType: Number? = null, var co2: Number? = null, diff --git a/app/src/main/java/com/logitech/vc/kirbytest/KirbyDevice.kt b/app/src/main/java/com/logitech/vc/kirbytest/KirbyDevice.kt index 560a356..8fc7469 100644 --- a/app/src/main/java/com/logitech/vc/kirbytest/KirbyDevice.kt +++ b/app/src/main/java/com/logitech/vc/kirbytest/KirbyDevice.kt @@ -52,6 +52,11 @@ class KirbyDevice( } } + fun connect() { + connectionManager.connect(bleDevice) + connectionManager.discoverServices(bleDevice) + } + fun readIaq() { connectionManager.readChar(bleDevice, SERVICE_UUID, CHAR_UUID) } @@ -68,11 +73,6 @@ class KirbyDevice( override fun onScanResult(callbackType: Int, result: ScanResult) { rssi = result.rssi onStateChange(this) - - if(result.isConnectable) { - connectionManager.connect(bleDevice) - connectionManager.discoverServices(bleDevice) - } } override fun onConnect(gatt: BluetoothGatt) { @@ -122,6 +122,7 @@ class KirbyDevice( override fun onBonded(device: BluetoothDevice) { statuses.add(DeviceStatus.BONDED) onStateChange(this) + connect() } override fun onUnbonded(device: BluetoothDevice) { @@ -361,7 +362,7 @@ class KirbyDevice( }) } - return actions; + return actions } } @@ -397,7 +398,7 @@ private fun payloadToMeasurements(payload: Payload): List { return payload.payload } - override fun getIcon(): Int? { + override fun getIcon(): Int { return R.drawable.baseline_numbers_24 } } diff --git a/app/src/main/java/com/logitech/vc/kirbytest/MainActivity.kt b/app/src/main/java/com/logitech/vc/kirbytest/MainActivity.kt index a7cea79..abc3d0a 100644 --- a/app/src/main/java/com/logitech/vc/kirbytest/MainActivity.kt +++ b/app/src/main/java/com/logitech/vc/kirbytest/MainActivity.kt @@ -40,6 +40,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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 @@ -66,7 +68,7 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var loggerDb: LoggerContract.LoggerDb private lateinit var createFileLauncher: ActivityResultLauncher - + private val bondedDevices = HashSet() private val bluetoothAdapter: BluetoothAdapter by lazy { val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager @@ -78,7 +80,7 @@ class MainActivity : AppCompatActivity() { newConnectionManager() } - private val kirbyDevices = mutableListOf(); + private val kirbyDevices = mutableListOf() private val deviceListAdapter: DeviceListAdapter by lazy { DeviceListAdapter(kirbyDevices) @@ -144,6 +146,12 @@ class MainActivity : AppCompatActivity() { connectionManager.stopScan() } } + + Timer().schedule(object : TimerTask() { + override fun run() { + loggerDb.exportToMultipleCSV() + } + }, 1000, 10000) } private fun toggleScanning(): Unit { @@ -186,6 +194,7 @@ class MainActivity : AppCompatActivity() { private fun addBondedDevices(): Unit { bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach { + bondedDevices.add(it.address) newKirbyDevice(it) } } @@ -204,7 +213,7 @@ class MainActivity : AppCompatActivity() { 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 ) @@ -221,7 +230,16 @@ class MainActivity : AppCompatActivity() { ) val kirbyDevice = kirbyDevices.find { it.address == result.device.address } if (kirbyDevice == null) { - newKirbyDevice(result.device).onScanResult(callbackType, result) + 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) } } @@ -231,8 +249,7 @@ class MainActivity : AppCompatActivity() { } private fun newKirbyDevice( - bleDevice: BluetoothDevice, - autoConnect: Boolean = false + bleDevice: BluetoothDevice ): KirbyDevice { val device = KirbyDevice(this.applicationContext, connectionManager, bleDevice, loggerDb) { @@ -244,13 +261,6 @@ class MainActivity : AppCompatActivity() { Log.i("MainActivity", bleDevice.address) connectionManager.register(device) - if (autoConnect) { - connectionManager.connect(bleDevice) - connectionManager.discoverServices(bleDevice) - - } - - kirbyDevices.add(device) deviceListAdapter.notifyItemInserted(kirbyDevices.size - 1) return device @@ -276,6 +286,22 @@ 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 + } + else -> super.onOptionsItemSelected(item) } } @@ -447,7 +473,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 } @@ -459,7 +485,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 } @@ -479,7 +505,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 { @@ -491,7 +517,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 } }) diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml index c9bc211..8d99837 100644 --- a/app/src/main/res/menu/menu_main.xml +++ b/app/src/main/res/menu/menu_main.xml @@ -7,4 +7,9 @@ android:orderInCategory="100" android:title="@string/action_export" app:showAsAction="never" /> + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 72b6fb6..9b65df0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,46 +1,11 @@ Kirby Test App Export to csv + Reset log First Fragment Second Fragment Next Previous - - 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. - \ No newline at end of file diff --git a/app/src/test/java/com/logitech/vc/kirbytest/DecoderTest.kt b/app/src/test/java/com/logitech/vc/kirbytest/DecoderTest.kt index 5f3bf02..b4cca1e 100644 --- a/app/src/test/java/com/logitech/vc/kirbytest/DecoderTest.kt +++ b/app/src/test/java/com/logitech/vc/kirbytest/DecoderTest.kt @@ -12,7 +12,7 @@ import org.junit.Test class DecoderTest { @Test fun message_type_0_decodes_correctly() { - val res2 = DecoderIaq.parseMeasurement("006b04ab74a1ed0d101404"); + 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) } @@ -20,7 +20,7 @@ class DecoderTest { @Test fun message_type_1_decodes_correctly() { - val res2 = DecoderIaq.parseMeasurement("106b04ab74a1ed0d10"); + 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) }