feat: support BLE low power mode

This commit is contained in:
Stefan Zollinger
2024-08-16 11:06:01 +02:00
parent 83b120b1ce
commit 48c6b2c314
9 changed files with 65 additions and 58 deletions

5
.idea/gradle.xml generated
View File

@@ -4,16 +4,15 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" /> <option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
</set> </set>
</option> </option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

View File

@@ -292,10 +292,6 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
val scanCallback = object : ScanCallback() { val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) { override fun onScanResult(callbackType: Int, result: ScanResult) {
Log.d(
"ScanCallback",
"Found BLE device with address ${result.device.address} (name: ${result.device.name}, rssi: ${result.rssi})"
)
notifyListeners(result.device.address) { it.onScanResult(callbackType, result) } notifyListeners(result.device.address) { it.onScanResult(callbackType, result) }
} }

View File

@@ -55,6 +55,10 @@ object DecoderIaq {
parseUnsignedInt(inputBytes, 8, 9) ushr 4 and INVALID_OCCUPANCY parseUnsignedInt(inputBytes, 8, 9) ushr 4 and INVALID_OCCUPANCY
measurement.occupancy = if (occupancy == INVALID_OCCUPANCY) null else occupancy measurement.occupancy = if (occupancy == INVALID_OCCUPANCY) null else occupancy
val occupancy2 =
parseUnsignedInt(inputBytes, 10, 11) ushr 4 and INVALID_OCCUPANCY
measurement.occupancy = if (occupancy == INVALID_OCCUPANCY) null else occupancy
if (msgType == 0) { if (msgType == 0) {
val pm25 = val pm25 =
parseUnsignedInt(inputBytes, 8, 10) ushr 2 and INVALID_PM25 parseUnsignedInt(inputBytes, 8, 10) ushr 2 and INVALID_PM25

View File

@@ -16,8 +16,6 @@ import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley import com.android.volley.toolbox.Volley
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Base64 import java.util.Base64
@@ -35,6 +33,7 @@ enum class DeviceStatus {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
class KirbyDevice( class KirbyDevice(
private val context: Context, private val context: Context,
private val connectionManager: ConnectionManager, private val connectionManager: ConnectionManager,
private val bleDevice: BluetoothDevice, private val bleDevice: BluetoothDevice,
@@ -45,7 +44,11 @@ class KirbyDevice(
) : BleListener(bleDevice.address), DeviceListEntry { ) : BleListener(bleDevice.address), DeviceListEntry {
private val tag = "KirbyDevice"
private var lastSeen: Long = 0
private val queue: RequestQueue = Volley.newRequestQueue(context) private val queue: RequestQueue = Volley.newRequestQueue(context)
private val reconnectionDelayMs = 10_000
private val settings = settingsRepository.getSettings()
fun subscribe() { fun subscribe() {
if(statuses.contains(DeviceStatus.CONNECTED)) { if(statuses.contains(DeviceStatus.CONNECTED)) {
@@ -56,8 +59,14 @@ class KirbyDevice(
} }
fun connect() { fun connect() {
connectionManager.connect(bleDevice) val now = System.currentTimeMillis()
connectionManager.discoverServices(bleDevice) if (now - lastSeen > reconnectionDelayMs) {
Log.i(tag, "Connecting to device " + bleDevice.address)
connectionManager.connect(bleDevice)
connectionManager.discoverServices(bleDevice)
} else{
Log.i(tag, "Waiting before reconnecting to device " + bleDevice.address)
}
} }
fun readIaq() { fun readIaq() {
@@ -69,7 +78,9 @@ class KirbyDevice(
gatt: BluetoothGatt, gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic characteristic: BluetoothGattCharacteristic
) { ) {
addMeasurement(characteristic) addMeasurement(characteristic)
onStateChange(this) onStateChange(this)
} }
@@ -96,10 +107,18 @@ class KirbyDevice(
statuses.remove(DeviceStatus.CONNECTED) statuses.remove(DeviceStatus.CONNECTED)
statuses.remove(DeviceStatus.SUBSCRIBED) statuses.remove(DeviceStatus.SUBSCRIBED)
onStateChange(this) onStateChange(this)
Log.i(tag, "Disconnected")
} }
override fun onCharChange(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { override fun onCharChange(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
addMeasurement(characteristic) addMeasurement(characteristic)
lastSeen = System.currentTimeMillis()
if(settings.lowPowerMode){
Log.i(tag, "Received data, closing connection")
connectionManager.teardownConnection(bleDevice)
}
onStateChange(this) onStateChange(this)
} }
@@ -145,7 +164,7 @@ class KirbyDevice(
private fun addMeasurement(characteristic: BluetoothGattCharacteristic) { private fun addMeasurement(characteristic: BluetoothGattCharacteristic) {
val hexPayload = characteristic.value.toHexString().substring(2) val hexPayload = characteristic.value.toHexString().substring(2)
val measurement = DecoderIaq.parseMeasurement(hexPayload) val measurement = DecoderIaq.parseMeasurement(hexPayload)
var payload : Payload val payload : Payload
if (measurement == null) { if (measurement == null) {
payload = Payload(hexPayload) payload = Payload(hexPayload)
} else { } else {
@@ -167,7 +186,6 @@ class KirbyDevice(
} }
private fun publishMeasurement(payload: String) { private fun publishMeasurement(payload: String) {
val settings = settingsRepository.readSettings()
val accessKey = settings.apiKey val accessKey = settings.apiKey
val url = settings.apiUrl val url = settings.apiUrl
@@ -376,26 +394,11 @@ data class Payload(
.format(DateTimeFormatter.ofPattern("dd.MM.yy HH:mm:ss")) .format(DateTimeFormatter.ofPattern("dd.MM.yy HH:mm:ss"))
) )
fun bytesToUInt16(arr: ByteArray, start: Int): Int {
return ByteBuffer.wrap(arr, start, 2)
.order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
}
fun bytesToInt16(arr: ByteArray, start: Int): Short {
return ByteBuffer.wrap(arr, start, 2)
.order(ByteOrder.LITTLE_ENDIAN).short
}
fun bytesToInt32(arr: ByteArray, start: Int): Int {
return ByteBuffer.wrap(arr, start, 4)
.order(ByteOrder.LITTLE_ENDIAN).int
}
private fun payloadToMeasurements(payload: Payload): List<Measurement> { private fun payloadToMeasurements(payload: Payload): List<Measurement> {
return listOf(object : Measurement { return listOf(object : Measurement {
override fun getLabel(): String { override fun getLabel(): String {
return payload.ts.toString() return payload.ts
} }
override fun getFormattedValue(): String { override fun getFormattedValue(): String {

View File

@@ -81,7 +81,7 @@ class MainActivity : AppCompatActivity() {
newConnectionManager() newConnectionManager()
} }
private val kirbyDevices = mutableListOf<DeviceListEntry>() private val kirbyDevices = mutableListOf<KirbyDevice>()
private val deviceListAdapter: DeviceListAdapter by lazy { private val deviceListAdapter: DeviceListAdapter by lazy {
DeviceListAdapter(kirbyDevices) DeviceListAdapter(kirbyDevices)
@@ -139,14 +139,14 @@ class MainActivity : AppCompatActivity() {
if (hasRequiredRuntimePermissions()) { if (hasRequiredRuntimePermissions()) {
connectionManager.startScan() connectionManager.startScan()
/*
lifecycleScope.launch { lifecycleScope.launch {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
delay(5000L) delay(5000L)
connectionManager.stopScan() connectionManager.stopScan()
} }
} }
*/
} }
Timer().schedule(object : TimerTask() { Timer().schedule(object : TimerTask() {
@@ -209,14 +209,6 @@ class MainActivity : AppCompatActivity() {
} }
@SuppressLint("NotifyDataSetChanged")
private fun addDummyDevices() {
for (i in 0..14) {
kirbyDevices.add(DummyListEntry("$i"))
}
deviceListAdapter.notifyDataSetChanged()
}
private fun newConnectionManager(): ConnectionManager { private fun newConnectionManager(): ConnectionManager {
val mngr = ConnectionManager(applicationContext, bluetoothAdapter) val mngr = ConnectionManager(applicationContext, bluetoothAdapter)
mngr.register(object : BleListener(null) { mngr.register(object : BleListener(null) {
@@ -237,19 +229,15 @@ class MainActivity : AppCompatActivity() {
"ScanCallback", "ScanCallback",
"Found Kirby device with name ${result.device.name} (address: ${result.device.address}, rssi: ${result.rssi})" "Found Kirby device with name ${result.device.name} (address: ${result.device.address}, rssi: ${result.rssi})"
) )
val kirbyDevice = kirbyDevices.find { it.address == result.device.address } var kirbyDevice = kirbyDevices.find { it.address == result.device.address }
if (kirbyDevice == null) { if (kirbyDevice == null) {
val kirby = newKirbyDevice(result.device) kirbyDevice = newKirbyDevice(result.device)
kirby.onScanResult(callbackType, result) kirbyDevice.onScanResult(callbackType, result)
} }
if (bondedDevices.contains(result.device.address)) { if (bondedDevices.contains(result.device.address)) {
Log.i("KirbyDevice", "Connecting to " + result.device.address) Log.i("KirbyDevice", "Auto connecting to bonded device" + result.device.address)
kirbyDevice.connect()
connectionManager.connect(result.device)
//connectionManager.readRemoteRssi(result.device)
connectionManager.discoverServices(result.device)
} }
} }

View File

@@ -5,8 +5,10 @@ import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.CheckBox
import android.widget.EditText import android.widget.EditText
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.logitech.vc.kirbytest.BuildConfig import com.logitech.vc.kirbytest.BuildConfig
import com.logitech.vc.kirbytest.R import com.logitech.vc.kirbytest.R
@@ -33,30 +35,35 @@ class SettingsRepository(context: Context) {
companion object { companion object {
val API_URL = stringPreferencesKey("api_url") val API_URL = stringPreferencesKey("api_url")
val API_KEY = stringPreferencesKey("api_key") val API_KEY = stringPreferencesKey("api_key")
val BLE_LOW_POWER_MODE = booleanPreferencesKey("ble_low_power_mode")
} }
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
fun saveSettings(apiUrl: String, apiKey: String) { fun saveSettings(apiUrl: String, apiKey: String, isLowPowerMode: Boolean) {
settings.apiKey = apiKey settings.apiKey = apiKey
settings.apiUrl = apiUrl settings.apiUrl = apiUrl
settings.lowPowerMode = isLowPowerMode
coroutineScope.launch(Dispatchers.Main) { coroutineScope.launch(Dispatchers.Main) {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[API_URL] = apiUrl preferences[API_URL] = apiUrl
preferences[API_KEY] = apiKey preferences[API_KEY] = apiKey
preferences[BLE_LOW_POWER_MODE] = isLowPowerMode
} }
} }
} }
fun readSettings(): Settings { fun getSettings(): Settings {
return settings return settings
} }
private val settingsFlow: Flow<Settings> = dataStore.data.map { private val settingsFlow: Flow<Settings> = dataStore.data.map {
Settings( Settings(
apiUrl = it[API_URL] ?: BuildConfig.API_BASE_URL, apiUrl = it[API_URL] ?: BuildConfig.API_BASE_URL,
apiKey = it[API_KEY] ?: BuildConfig.API_KEY apiKey = it[API_KEY] ?: BuildConfig.API_KEY,
lowPowerMode = it[BLE_LOW_POWER_MODE] ?: true
) )
} }
@@ -65,11 +72,12 @@ class SettingsRepository(context: Context) {
if (result != null) { if (result != null) {
settings.apiKey = result.apiKey settings.apiKey = result.apiKey
settings.apiUrl = result.apiUrl settings.apiUrl = result.apiUrl
settings.lowPowerMode = result.lowPowerMode
} }
} }
} }
data class Settings(var apiUrl: String, var apiKey: String) data class Settings(var apiUrl: String, var apiKey: String, var lowPowerMode: Boolean = true)
fun settingsDialog(context: Context, settingsRepo: SettingsRepository): AlertDialog { fun settingsDialog(context: Context, settingsRepo: SettingsRepository): AlertDialog {
@@ -79,10 +87,12 @@ fun settingsDialog(context: Context, settingsRepo: SettingsRepository): AlertDia
val root = layoutInflater.inflate(R.layout.settings_dialog, null) val root = layoutInflater.inflate(R.layout.settings_dialog, null)
val urlField = root.findViewById<EditText>(R.id.apiUrl) val urlField = root.findViewById<EditText>(R.id.apiUrl)
val keyField = root.findViewById<EditText>(R.id.apiKey) val keyField = root.findViewById<EditText>(R.id.apiKey)
val lowPowerMode = root.findViewById<CheckBox>(R.id.checkboxLowPowerMode)
val settings = settingsRepo.readSettings() val settings = settingsRepo.getSettings()
urlField.setText(settings.apiUrl) urlField.setText(settings.apiUrl)
keyField.setText(settings.apiKey) keyField.setText(settings.apiKey)
lowPowerMode.isChecked = settings.lowPowerMode
return androidx.appcompat.app.AlertDialog.Builder(context) return androidx.appcompat.app.AlertDialog.Builder(context)
.setTitle(R.string.settings) .setTitle(R.string.settings)
@@ -92,8 +102,9 @@ fun settingsDialog(context: Context, settingsRepo: SettingsRepository): AlertDia
val url = urlField.text.toString() val url = urlField.text.toString()
val key = keyField.text.toString() val key = keyField.text.toString()
val isLowPowerMode = lowPowerMode.isChecked
if (isFullPath(url) || url.isEmpty()) { if (isFullPath(url) || url.isEmpty()) {
settingsRepo.saveSettings(url, key) settingsRepo.saveSettings(url, key, isLowPowerMode)
} }
} }

View File

@@ -51,6 +51,12 @@
android:inputType="text" android:inputType="text"
android:text="" /> android:text="" />
<CheckBox android:id="@+id/checkboxLowPowerMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="BLE low power mode"/>
</LinearLayout> </LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id 'com.android.application' version '8.1.2' apply false id 'com.android.application' version '8.5.0' apply false
id 'com.android.library' version '8.1.2' apply false id 'com.android.library' version '8.5.0' apply false
id 'org.jetbrains.kotlin.android' version '1.8.20' apply false id 'org.jetbrains.kotlin.android' version '1.8.20' apply false
} }

View File

@@ -1,6 +1,6 @@
#Wed Jun 14 12:17:09 CEST 2023 #Wed Jun 14 12:17:09 CEST 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists