feat(bluetooth): expose and display bluetooth signal strength (RSSI) (#3235)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-09-29 07:44:12 -05:00 committed by GitHub
parent 00e9be0919
commit 92202e3ebf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 496 additions and 35 deletions

View file

@ -50,7 +50,6 @@ fun NavGraphBuilder.connectionsGraph(navController: NavHostController) {
restoreState = true
}
},
onNavigateToSettings = { navController.navigate(SettingsRoutes.Settings()) },
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> navController.navigate(route) },
)

View file

@ -36,6 +36,8 @@ import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.util.anonymize
import java.lang.reflect.Method
import java.util.UUID
@ -137,6 +139,42 @@ constructor(
private lateinit var fromNum: BluetoothGattCharacteristic
// RSSI flow & polling job (null when unavailable / disconnected)
private val _rssiFlow = MutableStateFlow<Int?>(null)
val rssiFlow: StateFlow<Int?> = _rssiFlow
@Volatile private var rssiPollingJob: Job? = null
// Start polling RSSI every 5 seconds (immediate first read)
@Suppress("MagicNumber", "LoopWithTooManyJumpStatements")
private fun startRssiPolling() {
rssiPollingJob?.cancel()
val s = safe ?: return
// Immediate read for faster UI update
s.asyncReadRemoteRssi { first -> first.getOrNull()?.let { _rssiFlow.value = it } }
rssiPollingJob =
service.serviceScope.handledLaunch {
while (true) {
try {
delay(5000)
if (safe == null) break
safe?.asyncReadRemoteRssi { res -> res.getOrNull()?.let { _rssiFlow.value = it } }
} catch (ex: CancellationException) {
break
} catch (ex: Exception) {
debug("RSSI polling error: ${ex.message}")
}
}
}
}
// Stop polling and clear current value
private fun stopRssiPolling() {
rssiPollingJob?.cancel()
rssiPollingJob = null
_rssiFlow.value = null
}
/**
* With the new rev2 api, our first send is to start the configure readbacks. In that case, rather than waiting for
* FromNum notifies - we try to just aggressively read all of the responses.
@ -201,6 +239,7 @@ constructor(
/** We had some problem, schedule a reconnection attempt (if one isn't already queued) */
private fun scheduleReconnect(reason: String) {
stopRssiPolling()
if (reconnectJob == null) {
warn("Scheduling reconnect because $reason")
reconnectJob = service.serviceScope.handledLaunch { retryDueToException() }
@ -391,6 +430,7 @@ constructor(
service.serviceScope.handledLaunch {
info("Connected to radio!")
startRssiPolling()
if (
needForceRefresh
@ -431,6 +471,7 @@ constructor(
override fun close() {
reconnectJob?.cancel() // Cancel any queued reconnect attempts
stopRssiPolling()
if (safe != null) {
info("Closing BluetoothInterface")

View file

@ -97,6 +97,10 @@ constructor(
private var radioIf: IRadioInterface = NopInterface("")
// Expose current bluetooth RSSI (null if not connected or not BLE)
private val _bluetoothRssi = MutableStateFlow<Int?>(null)
val bluetoothRssi: StateFlow<Int?> = _bluetoothRssi.asStateFlow()
/**
* true if we have started our interface
*
@ -254,6 +258,13 @@ constructor(
}
radioIf = interfaceFactory.createInterface(address)
// If the new interface is bluetooth, collect its RSSI flow
if (radioIf is BluetoothInterface) {
(radioIf as BluetoothInterface).rssiFlow.onEach { _bluetoothRssi.emit(it) }.launchIn(serviceScope)
} else {
_bluetoothRssi.value = null
}
}
}
}
@ -280,6 +291,7 @@ constructor(
if (r !is NopInterface) {
onDisconnect(isPermanent = true) // Tell any clients we are now offline
}
_bluetoothRssi.value = null
}
/**

View file

@ -325,6 +325,7 @@ class MeshService :
serviceScope.handledLaunch { radioInterfaceService.connect() }
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(serviceScope)
radioInterfaceService.receivedData.onEach(::onReceiveFromRadio).launchIn(serviceScope)
radioInterfaceService.bluetoothRssi.onEach { serviceRepository.setBluetoothRssi(it) }.launchIn(serviceScope)
radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(serviceScope)
radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it }.launchIn(serviceScope)
radioConfigRepository.channelSetFlow.onEach { channelSet = it }.launchIn(serviceScope)

View file

@ -317,6 +317,11 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD
override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
completeWork(status, descriptor)
}
// Added: callback for remote RSSI reads
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
completeWork(status, rssi)
}
}
// To test loss of BLE faults we can randomly fail a certain % of all work items. We
@ -654,6 +659,14 @@ class SafeBluetooth(private val context: Context, private val device: BluetoothD
fun asyncWriteDescriptor(c: BluetoothGattDescriptor, cb: (Result<BluetoothGattDescriptor>) -> Unit) =
queueWriteDescriptor(c, CallbackContinuation(cb))
// Added: Support reading remote RSSI
private fun queueReadRemoteRssi(cont: Continuation<Int>, timeout: Long = 0) =
queueWork("readRSSI", cont, timeout) { gatt?.readRemoteRssi() ?: false }
fun asyncReadRemoteRssi(cb: (Result<Int>) -> Unit) = queueReadRemoteRssi(CallbackContinuation(cb))
fun readRemoteRssi(timeout: Long = timeoutMsec): Int = makeSync { queueReadRemoteRssi(it, timeout) }
/**
* Some old androids have a bug where calling disconnect doesn't guarantee that the onConnectionStateChange callback
* gets called but the only safe way to call gatt.close is from that callback. So we set a flag once we start

View file

@ -50,6 +50,15 @@ class ServiceRepository @Inject constructor() : Logging {
_connectionState.value = connectionState
}
// Current bluetooth link RSSI (dBm). Null if not connected or not a bluetooth interface.
private val _bluetoothRssi = MutableStateFlow<Int?>(null)
val bluetoothRssi: StateFlow<Int?>
get() = _bluetoothRssi
fun setBluetoothRssi(rssi: Int?) {
_bluetoothRssi.value = rssi
}
private val _clientNotification = MutableStateFlow<MeshProtos.ClientNotification?>(null)
val clientNotification: StateFlow<MeshProtos.ClientNotification?>
get() = _clientNotification

View file

@ -103,7 +103,6 @@ fun ConnectionsScreen(
scanModel: BTScanModel = hiltViewModel(),
radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit,
onNavigateToSettings: () -> Unit,
onNavigateToNodeDetails: (Int) -> Unit,
onConfigNavigate: (Route) -> Unit,
) {
@ -120,6 +119,7 @@ fun ConnectionsScreen(
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle()
val regionUnset = config.lora.region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET
val bluetoothRssi by connectionsViewModel.bluetoothRssi.collectAsStateWithLifecycle()
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
@ -222,8 +222,8 @@ fun ConnectionsScreen(
node = node,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onSetShowSharedContact = { showSharedContact = it },
onNavigateToSettings = onNavigateToSettings,
onClickDisconnect = { scanModel.disconnect() },
bluetoothRssi = bluetoothRssi,
)
}
}

View file

@ -60,6 +60,9 @@ constructor(
val bluetoothState = bluetoothRepository.state
// Newly added: bluetooth RSSI stream (dBm, null if unavailable)
val bluetoothRssi = serviceRepository.bluetoothRssi
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning)
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()

View file

@ -23,13 +23,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -48,19 +45,31 @@ import com.geeksville.mesh.ui.node.components.NodeMenuAction
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.component.MaterialBluetoothSignalInfo
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
/** Converts Bluetooth RSSI to a 0-4 bar signal strength level. */
@Composable
fun CurrentlyConnectedInfo(
node: Node,
onNavigateToNodeDetails: (Int) -> Unit,
onSetShowSharedContact: (Node) -> Unit,
onNavigateToSettings: () -> Unit,
onClickDisconnect: () -> Unit,
modifier: Modifier = Modifier,
bluetoothRssi: Int? = null,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
MaterialBatteryInfo(level = node.batteryLevel)
if (bluetoothRssi != null) {
MaterialBluetoothSignalInfo(rssi = bluetoothRssi)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
@ -79,8 +88,6 @@ fun CurrentlyConnectedInfo(
}
},
)
MaterialBatteryInfo(level = node.batteryLevel)
}
Column(modifier = Modifier.weight(1f, fill = true)) {
@ -95,13 +102,6 @@ fun CurrentlyConnectedInfo(
)
}
}
IconButton(enabled = true, onClick = onNavigateToSettings) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = stringResource(id = R.string.radio_configuration),
)
}
}
Button(
@ -124,23 +124,26 @@ fun CurrentlyConnectedInfo(
@Composable
private fun CurrentlyConnectedInfoPreview() {
AppTheme {
CurrentlyConnectedInfo(
node =
Node(
num = 13444,
user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build(),
isIgnored = false,
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
environmentMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder()
.setTemperature(25f)
.setRelativeHumidity(60f)
.build(),
),
onNavigateToNodeDetails = {},
onSetShowSharedContact = {},
onNavigateToSettings = {},
onClickDisconnect = {},
)
Surface {
CurrentlyConnectedInfo(
node =
Node(
num = 13444,
user =
MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build(),
isIgnored = false,
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
environmentMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder()
.setTemperature(25f)
.setRelativeHumidity(60f)
.build(),
),
bluetoothRssi = -75, // Example RSSI for signal preview
onNavigateToNodeDetails = {},
onSetShowSharedContact = {},
onClickDisconnect = {},
)
}
}
}