Compare commits

..

10 Commits

Author SHA1 Message Date
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
18 changed files with 327 additions and 113 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

View File

@@ -4,11 +4,11 @@ plugins {
} }
android { android {
namespace 'com.example.sensortestingapp' namespace 'com.logitech.vc.kirbytest'
compileSdk 32 compileSdk 32
defaultConfig { defaultConfig {
applicationId "com.example.sensortestingapp" applicationId "com.logitech.vc.kirbytest"
minSdk 29 minSdk 29
targetSdk 32 targetSdk 32
versionCode 1 versionCode 1
@@ -59,6 +59,7 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.2.0' implementation 'androidx.recyclerview:recyclerview:1.2.0'
implementation 'com.google.code.gson:gson:2.8.8' 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 group: 'commons-codec', name: 'commons-codec', version: '1.16.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.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
@@ -17,6 +17,6 @@ class ExampleInstrumentedTest {
fun useAppContext() { fun useAppContext() {
// Context of the app under test. // Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.sensortestingapp", appContext.packageName) assertEquals("com.logitech.vc.kirbytest", appContext.packageName)
} }
} }

View File

@@ -1,4 +1,4 @@
package com.example.sensortestingapp package com.logitech.vc.kirbytest
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager 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.annotation.SuppressLint
import android.app.Service import android.app.Service
@@ -90,7 +90,7 @@ open class BleListener(private val deviceAddress: String?) {
open fun isRelevantMessage(address: String?): Boolean { open fun isRelevantMessage(address: String?): Boolean {
if (deviceAddress != null && deviceAddress == address) { if (deviceAddress != null && deviceAddress == address) {
return true; return true
} }
return deviceAddress == null return deviceAddress == null
} }
@@ -165,7 +165,7 @@ private fun BluetoothGatt.printGattTable() {
separator = "\n|--", separator = "\n|--",
prefix = "|--" prefix = "|--"
) { ) {
"${it.uuid.toString()} | " + "${it.uuid} | " +
"readable: ${it.isReadable()}, " + "readable: ${it.isReadable()}, " +
"writable: ${it.isWritable()}, " + "writable: ${it.isWritable()}, " +
"writableWithoutResponse: ${it.isWritableWithoutResponse()}, " + "writableWithoutResponse: ${it.isWritableWithoutResponse()}, " +
@@ -325,7 +325,11 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
fun connect(device: BluetoothDevice) { 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}!") Log.e("ConnectionManager", "Already connected to ${device.address}!")
} else { } else {
enqueueOperation(Connect(device, context)) enqueueOperation(Connect(device, context))
@@ -394,9 +398,11 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
@Synchronized @Synchronized
private fun enqueueOperation(operation: BleOperationType) { private fun enqueueOperation(operation: BleOperationType) {
/*
if (isScanning) { if (isScanning) {
stopScan() stopScan()
} }
*/
operationQueue.add(operation) operationQueue.add(operation)
notifyListenersOfQueueChange(operation.device.address) notifyListenersOfQueueChange(operation.device.address)
if (pendingOperation == null) { if (pendingOperation == null) {
@@ -526,7 +532,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
} }
is SetNotification -> with(operation) { is SetNotification -> with(operation) {
val characteristic = gatt.getService(serviceId)?.getCharacteristic(charId); val characteristic = gatt.getService(serviceId)?.getCharacteristic(charId)
if (characteristic == null) { if (characteristic == null) {
Log.e("ConnectionManager", "Char $charId (${serviceId}) not found!") Log.e("ConnectionManager", "Char $charId (${serviceId}) not found!")
@@ -558,7 +564,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
payload = payload =
if (characteristic.isIndicatable()) if (characteristic.isIndicatable())
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
} else { } else {
if (!gatt.setCharacteristicNotification(characteristic, false)) { if (!gatt.setCharacteristicNotification(characteristic, false)) {
@@ -611,7 +617,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
it.onConnectToBondedFailed(gatt) it.onConnectToBondedFailed(gatt)
} }
signalEndOfOperation(operation) signalEndOfOperation(operation)
} else if (status == BluetoothGatt.GATT_SUCCESS) { } else if (status == GATT_SUCCESS) {
if (newState == BluetoothProfile.STATE_CONNECTED) { if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.i( Log.i(
"ConnectionManager", "ConnectionManager",
@@ -647,7 +653,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
with(gatt) { with(gatt) {
if (status == BluetoothGatt.GATT_SUCCESS) { if (status == GATT_SUCCESS) {
Log.w( Log.w(
"ConnectionManager", "ConnectionManager",
"Discovered ${services.size} services for ${device.address}." "Discovered ${services.size} services for ${device.address}."
@@ -671,7 +677,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.w( Log.w(
"ConnectionManager", "ConnectionManager",
"ATT MTU changed to $mtu, success: ${status == BluetoothGatt.GATT_SUCCESS}" "ATT MTU changed to $mtu, success: ${status == GATT_SUCCESS}"
) )
val operation = pendingOperation val operation = pendingOperation
@@ -687,7 +693,7 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
) { ) {
with(characteristic) { with(characteristic) {
when (status) { when (status) {
BluetoothGatt.GATT_SUCCESS -> { GATT_SUCCESS -> {
Log.i( Log.i(
"ConnectionManager", "ConnectionManager",
"Read characteristic $uuid (service: ${service.uuid}): ${value.toHexString()}" "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.ContentValues
import android.content.Context import android.content.Context
@@ -10,8 +10,11 @@ import android.util.Log
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonSyntaxException import com.google.gson.JsonSyntaxException
import com.google.gson.reflect.TypeToken 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.time.format.DateTimeFormatter
import java.util.SortedMap
object LoggerContract { object LoggerContract {
@@ -31,6 +34,7 @@ object LoggerContract {
private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${LogEntry.TABLE_NAME}" private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${LogEntry.TABLE_NAME}"
class LoggerDbHelper(context: Context) : class LoggerDbHelper(context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
override fun onCreate(db: SQLiteDatabase) { override fun onCreate(db: SQLiteDatabase) {
@@ -61,16 +65,15 @@ object LoggerContract {
private val dbHelper = LoggerDbHelper(context) private val dbHelper = LoggerDbHelper(context)
private val dbWrite = dbHelper.writableDatabase private val dbWrite = dbHelper.writableDatabase
private val dbRead = dbHelper.writableDatabase private val dbRead = dbHelper.writableDatabase
private val tag = "LoggerDb"
val context: Context = context val context: Context = context
fun writeLog(payload: Any): Long? { fun writeLog(payload: Any): Long? {
val gson = Gson() val gson = Gson()
val jsonString = gson.toJson(payload) val jsonString = gson.toJson(payload)
val ts = Instant.now().toString()
val date = LocalDateTime.now()
val ts = date.format(DateTimeFormatter.ISO_DATE_TIME)
Log.i("Database", jsonString)
val values = ContentValues().apply { val values = ContentValues().apply {
put(LogEntry.COLUMN_NAME_TS, ts) put(LogEntry.COLUMN_NAME_TS, ts)
@@ -80,6 +83,21 @@ object LoggerContract {
return dbWrite?.insert(LogEntry.TABLE_NAME, null, values) 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) { fun exportToUri(uri: Uri) {
val projection = val projection =
@@ -100,7 +118,7 @@ object LoggerContract {
try { try {
val gson = Gson() val gson = Gson()
val type = object : TypeToken<HashMap<String, Any>>() {}.type
var headerWritten = false var headerWritten = false
val sep = "," val sep = ","
@@ -112,30 +130,26 @@ object LoggerContract {
val ts = getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_TS)) val ts = getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_TS))
val storedField = val storedField =
getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_PAYLOAD)) getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_PAYLOAD))
val payload = parsePayload(storedField)
try { try {
val payload: HashMap<String, Any> = gson.fromJson(storedField, type)
if (!headerWritten) { if (!headerWritten) {
val headerRow = val headerRow =
"timestamp" + sep + payload.keys.joinToString(sep) + newLine "timestamp" + sep + "local_time" + sep + payload.keys.joinToString(sep) + newLine
writer.write(headerRow.toByteArray()) writer.write(headerRow.toByteArray())
headerWritten = true headerWritten = true
} }
val localTime = convertIsoToLocalTime(ts)
val row = ts + sep + payload.values.joinToString(sep) + newLine val row = ts + sep + localTime + sep + payload.values.joinToString(sep) + newLine
writer.write(row.toByteArray()) writer.write(row.toByteArray())
} catch (exception: JsonSyntaxException) { } catch (exception: JsonSyntaxException) {
Log.e("db", exception.toString()) Log.e(tag, exception.toString())
} }
} }
} }
} }
truncate()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
@@ -143,9 +157,80 @@ object LoggerContract {
cursor.close() cursor.close()
} }
private fun truncate() { fun exportToMultipleCSV() {
dbWrite.execSQL("DELETE FROM ${LogEntry.TABLE_NAME}"); val projection =
dbWrite.execSQL("VACUUM"); 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() { fun close() {
@@ -155,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.example.sensortestingapp package com.logitech.vc.kirbytest
import android.util.Log import android.util.Log
import com.google.gson.annotations.SerializedName
import org.apache.commons.codec.DecoderException import org.apache.commons.codec.DecoderException
import org.apache.commons.codec.binary.Hex import org.apache.commons.codec.binary.Hex
import kotlin.math.min import kotlin.math.min
@@ -25,7 +26,7 @@ object DecoderIaq {
if(!supportedMessageTypes.contains(msgType)) { if(!supportedMessageTypes.contains(msgType)) {
Log.i("Decoder", "Invalid message type: $msgType") Log.i("Decoder", "Invalid message type: $msgType")
return null; return null
} }
measurement.msgType = msgType measurement.msgType = msgType
@@ -84,6 +85,8 @@ object DecoderIaq {
} }
data class Measurement ( data class Measurement (
@SerializedName("bleAddress")
var deviceId: String? = null,
var msgType: Number? = null, var msgType: Number? = null,
var co2: Number? = null, var co2: Number? = null,
var voc: Number? = null, var voc: Number? = null,
@@ -110,8 +113,5 @@ object DecoderIaq {
'}' '}'
} }
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.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context

View File

@@ -1,4 +1,4 @@
package com.example.sensortestingapp package com.logitech.vc.kirbytest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
@@ -45,16 +45,23 @@ class KirbyDevice(
private val queue: RequestQueue = Volley.newRequestQueue(context) private val queue: RequestQueue = Volley.newRequestQueue(context)
fun subscribe() { fun subscribe() {
if(statuses.contains(DeviceStatus.CONNECTED)) {
connectionManager.enableNotification( connectionManager.enableNotification(
bleDevice, SERVICE_UUID, CHAR_UUID bleDevice, SERVICE_UUID, CHAR_UUID
) )
} }
}
fun connect() {
connectionManager.connect(bleDevice)
connectionManager.discoverServices(bleDevice)
}
fun readIaq() { fun readIaq() {
connectionManager.readChar(bleDevice, SERVICE_UUID, CHAR_UUID) connectionManager.readChar(bleDevice, SERVICE_UUID, CHAR_UUID)
} }
override fun onSuccessfulCharRead( override fun onSuccessfulCharRead(
gatt: BluetoothGatt, gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic characteristic: BluetoothGattCharacteristic
@@ -72,7 +79,9 @@ class KirbyDevice(
statuses.add(DeviceStatus.CONNECTED) statuses.add(DeviceStatus.CONNECTED)
statuses.remove(DeviceStatus.MISSING) statuses.remove(DeviceStatus.MISSING)
subscribe()
onStateChange(this) onStateChange(this)
} }
override fun onConnectToBondedFailed(gatt: BluetoothGatt) { override fun onConnectToBondedFailed(gatt: BluetoothGatt) {
@@ -113,6 +122,7 @@ class KirbyDevice(
override fun onBonded(device: BluetoothDevice) { override fun onBonded(device: BluetoothDevice) {
statuses.add(DeviceStatus.BONDED) statuses.add(DeviceStatus.BONDED)
onStateChange(this) onStateChange(this)
connect()
} }
override fun onUnbonded(device: BluetoothDevice) { override fun onUnbonded(device: BluetoothDevice) {
@@ -136,12 +146,13 @@ class KirbyDevice(
if (measurement == null) { if (measurement == null) {
payload = Payload(hexPayload) payload = Payload(hexPayload)
} else { } else {
measurement.deviceId = bleDevice.address
payload = Payload(measurement.toString()) payload = Payload(measurement.toString())
Log.i("BleListener", "Char received: $payload") Log.i("BleListener", "Char received: $payload")
val base64Payload = Base64.getEncoder().encodeToString(characteristic.value) val base64Payload = Base64.getEncoder().encodeToString(characteristic.value)
publishMeasurement(base64Payload) publishMeasurement(base64Payload)
loggerDb.writeLog( measurement) loggerDb.writeLog(measurement)
} }
measurements.add(payload) measurements.add(payload)
@@ -155,6 +166,11 @@ class KirbyDevice(
private fun publishMeasurement(payload: String) { private fun publishMeasurement(payload: String) {
val accessKey = BuildConfig.API_KEY val accessKey = BuildConfig.API_KEY
val url = BuildConfig.API_BASE_URL val url = BuildConfig.API_BASE_URL
if(url.isEmpty()) {
return
}
val eui = "0000${bleDevice.address.replace(":", "")}" val eui = "0000${bleDevice.address.replace(":", "")}"
val postData = JSONObject() val postData = JSONObject()
@@ -346,7 +362,7 @@ class KirbyDevice(
}) })
} }
return actions; return actions
} }
} }
@@ -382,7 +398,7 @@ private fun payloadToMeasurements(payload: Payload): List<Measurement> {
return payload.payload return payload.payload
} }
override fun getIcon(): Int? { override fun getIcon(): Int {
return R.drawable.baseline_numbers_24 return R.drawable.baseline_numbers_24
} }
} }

View File

@@ -1,4 +1,4 @@
package com.example.sensortestingapp package com.logitech.vc.kirbytest
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -15,22 +15,33 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.util.Log import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.SimpleItemAnimator 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.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Timer
import java.util.TimerTask
private const val ENABLE_BLUETOOTH_REQUEST_CODE = 1 private const val ENABLE_BLUETOOTH_REQUEST_CODE = 1
@@ -57,7 +68,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var loggerDb: LoggerContract.LoggerDb private lateinit var loggerDb: LoggerContract.LoggerDb
private lateinit var createFileLauncher: ActivityResultLauncher<String> private lateinit var createFileLauncher: ActivityResultLauncher<String>
private val bondedDevices = HashSet<String>()
private val bluetoothAdapter: BluetoothAdapter by lazy { private val bluetoothAdapter: BluetoothAdapter by lazy {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
@@ -69,7 +80,7 @@ class MainActivity : AppCompatActivity() {
newConnectionManager() newConnectionManager()
} }
private val kirbyDevices = mutableListOf<DeviceListEntry>(); private val kirbyDevices = mutableListOf<DeviceListEntry>()
private val deviceListAdapter: DeviceListAdapter by lazy { private val deviceListAdapter: DeviceListAdapter by lazy {
DeviceListAdapter(kirbyDevices) DeviceListAdapter(kirbyDevices)
@@ -108,13 +119,41 @@ class MainActivity : AppCompatActivity() {
setupDevicesList() setupDevicesList()
createFileLauncher = createFileLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri: Uri? -> registerForActivityResult(CreateDocument("text/csv")) { uri: Uri? ->
uri?.let { uri?.let {
// Use the URI to write your CSV content // Use the URI to write your CSV content
CoroutineScope(Dispatchers.IO).launch {
loggerDb.exportToUri(uri) 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)
}
private fun toggleScanning(): Unit { private fun toggleScanning(): Unit {
if (connectionManager.isScanning) { if (connectionManager.isScanning) {
@@ -156,11 +195,12 @@ class MainActivity : AppCompatActivity() {
private fun addBondedDevices(): Unit { private fun addBondedDevices(): Unit {
bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach { bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach {
val kirbyDevice = newKirbyDevice(it) bondedDevices.add(it.address)
kirbyDevice.subscribe() newKirbyDevice(it)
} }
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
private fun addDummyDevices() { private fun addDummyDevices() {
for (i in 0..14) { for (i in 0..14) {
@@ -174,7 +214,7 @@ class MainActivity : AppCompatActivity() {
mngr.register(object : BleListener(null) { mngr.register(object : BleListener(null) {
override fun onScanningStateChange(isScanning: Boolean) { override fun onScanningStateChange(isScanning: Boolean) {
runOnUiThread { runOnUiThread {
binding.fab.setText(if (isScanning) "Stop Scan" else "Start Scan") binding.fab.text = if (isScanning) "Stop Scan" else "Start Scan"
binding.fab.setIconResource( binding.fab.setIconResource(
if (isScanning) R.drawable.action_icon_disconnect else R.drawable.action_icon_scan if (isScanning) R.drawable.action_icon_disconnect else R.drawable.action_icon_scan
) )
@@ -183,7 +223,7 @@ class MainActivity : AppCompatActivity() {
} }
override fun onScanResult(callbackType: Int, result: ScanResult) { override fun onScanResult(callbackType: Int, result: ScanResult) {
if (isKirbyDevice(result.device)) { if (isKirbyDevice(result.device) && result.isConnectable) {
Log.i( Log.i(
"ScanCallback", "ScanCallback",
@@ -191,7 +231,16 @@ class MainActivity : AppCompatActivity() {
) )
val kirbyDevice = kirbyDevices.find { it.address == result.device.address } val kirbyDevice = kirbyDevices.find { it.address == result.device.address }
if (kirbyDevice == null) { 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)
} }
} }
@@ -200,7 +249,9 @@ class MainActivity : AppCompatActivity() {
return mngr 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) {
val i = kirbyDevices.indexOfFirst { d -> d === it } val i = kirbyDevices.indexOfFirst { d -> d === it }
@@ -208,9 +259,9 @@ class MainActivity : AppCompatActivity() {
deviceListAdapter.notifyItemChanged(i) deviceListAdapter.notifyItemChanged(i)
} }
} }
Log.i("MainActivity", bleDevice.address)
connectionManager.register(device) connectionManager.register(device)
connectionManager.connect(bleDevice)
connectionManager.discoverServices(bleDevice)
kirbyDevices.add(device) kirbyDevices.add(device)
deviceListAdapter.notifyItemInserted(kirbyDevices.size - 1) deviceListAdapter.notifyItemInserted(kirbyDevices.size - 1)
return device return device
@@ -236,6 +287,22 @@ class MainActivity : AppCompatActivity() {
return true 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) else -> super.onOptionsItemSelected(item)
} }
} }
@@ -407,7 +474,7 @@ class DummyListEntry(override val address: String) : DeviceListEntry {
return "Test action 1" return "Test action 1"
} }
override fun getIcon(): Int? { override fun getIcon(): Int {
return R.drawable.action_icon_disconnect return R.drawable.action_icon_disconnect
} }
@@ -419,7 +486,7 @@ class DummyListEntry(override val address: String) : DeviceListEntry {
return "Test action 2" return "Test action 2"
} }
override fun getIcon(): Int? { override fun getIcon(): Int {
return R.drawable.action_icon_connect return R.drawable.action_icon_connect
} }
@@ -439,7 +506,7 @@ class DummyListEntry(override val address: String) : DeviceListEntry {
return "21.2 °C" return "21.2 °C"
} }
override fun getIcon(): Int? { override fun getIcon(): Int {
return R.drawable.baseline_device_thermostat_24 return R.drawable.baseline_device_thermostat_24
} }
}, object : Measurement { }, object : Measurement {
@@ -451,7 +518,7 @@ class DummyListEntry(override val address: String) : DeviceListEntry {
return "232 bar" return "232 bar"
} }
override fun getIcon(): Int? { override fun getIcon(): Int {
return R.drawable.baseline_compress_24 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.content.Context;
import android.util.AttributeSet; import android.util.AttributeSet;

View File

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

View File

@@ -1,3 +1,3 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:tools="http://schemas.android.com/tools" <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,15 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" <menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
tools:context="com.example.sensortestingapp.MainActivity"> tools:context="com.logitech.vc.kirbytest.MainActivity">
<item <item
android:id="@+id/action_export" android:id="@+id/action_export"
android:orderInCategory="100" android:orderInCategory="100"
android:title="@string/action_export" android:title="@string/action_export"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/action_reset_log"
android:orderInCategory="200"
android:title="@string/action_reset_log"
app:showAsAction="never" />
</menu> </menu>

View File

@@ -1,46 +1,11 @@
<resources> <resources>
<string name="app_name">Kirby Test App</string> <string name="app_name">Kirby Test App</string>
<string name="action_export">Export to csv</string> <string name="action_export">Export to csv</string>
<string name="action_reset_log">Reset log</string>
<!-- Strings used for fragments for navigation --> <!-- Strings used for fragments for navigation -->
<string name="first_fragment_label">First Fragment</string> <string name="first_fragment_label">First Fragment</string>
<string name="second_fragment_label">Second Fragment</string> <string name="second_fragment_label">Second Fragment</string>
<string name="next">Next</string> <string name="next">Next</string>
<string name="previous">Previous</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> </resources>

View File

@@ -1,4 +1,4 @@
package com.example.sensortestingapp package com.logitech.vc.kirbytest
import org.junit.Assert.* import org.junit.Assert.*
import org.junit.Test import org.junit.Test
@@ -12,7 +12,7 @@ import org.junit.Test
class DecoderTest { class DecoderTest {
@Test @Test
fun message_type_0_decodes_correctly() { 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 ) 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) assertEquals(testMeasurement, res2)
} }
@@ -20,7 +20,7 @@ class DecoderTest {
@Test @Test
fun message_type_1_decodes_correctly() { 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 ) 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) assertEquals(testMeasurement, res2)
} }

View File

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