feat: add csv auto export

This commit is contained in:
Stefan Zollinger
2024-04-12 12:13:02 +02:00
parent 62ce221860
commit a5425083da
8 changed files with 189 additions and 89 deletions

View File

@@ -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()}"

View File

@@ -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<HashMap<String, Any>>() {}.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<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()
}
@@ -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<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
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<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

@@ -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,

View File

@@ -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<Measurement> {
return payload.payload
}
override fun getIcon(): Int? {
override fun getIcon(): Int {
return R.drawable.baseline_numbers_24
}
}

View File

@@ -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<String>
private val bondedDevices = HashSet<String>()
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<DeviceListEntry>();
private val kirbyDevices = mutableListOf<DeviceListEntry>()
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
}
})

View File

@@ -7,4 +7,9 @@
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" />
</menu>

View File

@@ -1,46 +1,11 @@
<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="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

@@ -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)
}