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