feat(bluetooth): conditional RSSI polling (#3489)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2025-10-16 21:08:41 -05:00
committed by GitHub
parent c3ede38b4c
commit 3dbfd81b43
5 changed files with 47 additions and 13 deletions

View File

@@ -158,14 +158,18 @@ constructor(
@Suppress("LoopWithTooManyJumpStatements", "MagicNumber")
val pollingJob =
service.serviceScope.handledLaunch {
while (true) {
try {
delay(2500) // Poll every 5 seconds
safe?.asyncReadRemoteRssi { res -> res.getOrNull()?.let { trySend(it) } }
} catch (ex: CancellationException) {
break // Stop polling on cancellation
} catch (ex: Exception) {
Timber.d("RSSI polling error: ${ex.message}")
service.isRssiPollingEnabled.collect { isEnabled ->
if (isEnabled) {
while (true) {
try {
delay(10000) // Poll every 10 seconds
safe?.asyncReadRemoteRssi { res -> res.getOrNull()?.let { trySend(it) } }
} catch (ex: CancellationException) {
break // Stop polling on cancellation
} catch (ex: Exception) {
Timber.d("RSSI polling error: ${ex.message}")
}
}
}
}
}

View File

@@ -87,6 +87,13 @@ constructor(
private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr)
val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
private val _isRssiPollingEnabled = MutableStateFlow(false)
val isRssiPollingEnabled: StateFlow<Boolean> = _isRssiPollingEnabled.asStateFlow()
fun setRssiPolling(enabled: Boolean) {
_isRssiPollingEnabled.value = enabled
}
private val logSends = false
private val logReceives = false
private lateinit var sentPacketsLog: BinaryLogFile

View File

@@ -17,12 +17,14 @@
package com.geeksville.mesh.service
import android.Manifest
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.os.Build
import androidx.annotation.RequiresPermission
import com.geeksville.mesh.logAssert
import com.geeksville.mesh.util.exceptionReporter
import timber.log.Timber
@@ -43,20 +45,20 @@ internal class SafeBluetoothGattCallback(private val safeBluetooth: SafeBluetoot
private const val MYSTERY_STATUS_CODE = 257
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
@Suppress("CyclomaticComplexMethod")
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) = exceptionReporter {
Timber.i("new bluetooth connection state $newState, status $status")
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
safeBluetooth.state =
newState // we only care about connected/disconnected - not the transitional states
// If autoconnect is on and this connect attempt failed, hopefully some future attempt will
// succeed
if (status != BluetoothGatt.GATT_SUCCESS && safeBluetooth.autoReconnect) {
Timber.e("Connect attempt failed $status, not calling connect completion handler...")
if (status != BluetoothGatt.GATT_SUCCESS) {
Timber.e("Connect attempt failed with status $status")
safeBluetooth.lostConnection("connection failed with status $status")
} else {
safeBluetooth.state = newState
workQueue.completeWork(status, Unit)
}
}
@@ -111,6 +113,10 @@ internal class SafeBluetoothGattCallback(private val safeBluetooth: SafeBluetoot
}
}
}
else -> {
// Anything that is not a successful connection should be treated as a failure.
safeBluetooth.lostConnection("unexpected connection state: $newState")
}
}
}
@@ -142,6 +148,7 @@ internal class SafeBluetoothGattCallback(private val safeBluetooth: SafeBluetoot
workQueue.completeWork(status, Unit)
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
val reliable = safeBluetooth.currentReliableWrite
if (reliable != null) {

View File

@@ -38,6 +38,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -116,6 +117,11 @@ fun ConnectionsScreen(
val recentTcpDevices by scanModel.recentTcpDevicesForUi.collectAsStateWithLifecycle()
val usbDevices by scanModel.usbDevicesForUi.collectAsStateWithLifecycle()
DisposableEffect(Unit) {
connectionsViewModel.onStart()
onDispose { connectionsViewModel.onStop() }
}
/* Animate waiting for the configurations */
var isWaiting by remember { mutableStateOf(false) }
if (isWaiting) {

View File

@@ -19,6 +19,7 @@ package com.geeksville.mesh.ui.connections
import androidx.lifecycle.ViewModel
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -39,10 +40,19 @@ class ConnectionsViewModel
constructor(
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
private val radioInterfaceService: RadioInterfaceService,
nodeRepository: NodeRepository,
bluetoothRepository: BluetoothRepository,
private val uiPrefs: UiPrefs,
) : ViewModel() {
fun onStart() {
radioInterfaceService.setRssiPolling(true)
}
fun onStop() {
radioInterfaceService.setRssiPolling(false)
}
val localConfig: StateFlow<LocalConfig> =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance())