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

@ -0,0 +1,141 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.SignalCellularOff
import androidx.compose.material3.Icon
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.SignalCellular0Bar
import org.meshtastic.core.ui.icon.SignalCellular1Bar
import org.meshtastic.core.ui.icon.SignalCellular2Bar
import org.meshtastic.core.ui.icon.SignalCellular3Bar
import org.meshtastic.core.ui.icon.SignalCellular4Bar
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
private const val SIZE_ICON = 20
/**
* A composable that displays a signal strength indicator with an icon and optional text value. The icon and its color
* change based on the number of signal bars.
*
* @param modifier Modifier for this composable.
* @param signalBars The number of signal bars, typically from 0 to 4. Values outside this range (e.g., < 0) will
* display a "signal off" or unknown state icon.
* @param signalStrengthValue Optional text to display next to the icon, such as dBm or SNR value.
*/
@Suppress("MagicNumber")
@Composable
fun MaterialSignalInfo(
signalBars: Int,
modifier: Modifier = Modifier,
signalStrengthValue: String? = null,
typeIcon: ImageVector? = null,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
val (iconVector, iconTint) =
when (signalBars) {
0 -> MeshtasticIcons.SignalCellular0Bar to MaterialTheme.colorScheme.StatusRed
1 -> MeshtasticIcons.SignalCellular1Bar to MaterialTheme.colorScheme.StatusRed
2 -> MeshtasticIcons.SignalCellular2Bar to MaterialTheme.colorScheme.StatusOrange
3 -> MeshtasticIcons.SignalCellular3Bar to MaterialTheme.colorScheme.StatusYellow
4 -> MeshtasticIcons.SignalCellular4Bar to MaterialTheme.colorScheme.StatusGreen
else -> Icons.Rounded.SignalCellularOff to MaterialTheme.colorScheme.onSurfaceVariant
}
val foregroundPainter = typeIcon?.let { rememberVectorPainter(typeIcon) }
Icon(
imageVector = iconVector,
contentDescription = null,
tint = iconTint,
modifier =
Modifier.size(SIZE_ICON.dp).drawWithContent {
drawContent()
@Suppress("MagicNumber")
if (foregroundPainter != null) {
val badgeSize = size.width * .45f
with(foregroundPainter) {
draw(Size(badgeSize, badgeSize), colorFilter = ColorFilter.tint(iconTint))
}
}
},
)
signalStrengthValue?.let {
Text(text = it, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
fun MaterialBluetoothSignalInfo(rssi: Int, modifier: Modifier = Modifier) {
MaterialSignalInfo(
modifier = modifier,
signalBars = getBluetoothSignalBars(rssi = rssi),
signalStrengthValue = stringResource(R.string.dbm_value, rssi),
typeIcon = Icons.Rounded.Bluetooth,
)
}
@Suppress("MagicNumber")
private fun getBluetoothSignalBars(rssi: Int): Int = when {
rssi > -60 -> 4 // Excellent
rssi > -70 -> 3 // Good
rssi > -80 -> 2 // Fair
rssi > -90 -> 1 // Weak
else -> 0 // Poor/No Signal
}
class SignalStrengthProvider : PreviewParameterProvider<Int> {
override val values: Sequence<Int> = sequenceOf(-95, -85, -75, -65, -55)
}
@PreviewLightDark
@Composable
private fun MaterialBluetoothSignalInfoPreview(@PreviewParameter(SignalStrengthProvider::class) rssi: Int) {
AppTheme { Surface { MaterialBluetoothSignalInfo(rssi = rssi) } }
}

View file

@ -0,0 +1,238 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val MeshtasticIcons.SignalCellular0Bar: ImageVector
get() {
if (signalCellular0Bar != null) {
return signalCellular0Bar!!
}
signalCellular0Bar =
ImageVector.Builder(
name = "SignalCellular0Bar",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color(0xFFE3E3E3))) {
moveTo(177f, 880f)
quadToRelative(-27f, 0f, -37.5f, -24.5f)
reflectiveQuadTo(148f, 812f)
lineToRelative(664f, -664f)
quadToRelative(19f, -19f, 43.5f, -8.5f)
reflectiveQuadTo(880f, 177f)
verticalLineToRelative(643f)
quadToRelative(0f, 25f, -17.5f, 42.5f)
reflectiveQuadTo(820f, 880f)
lineTo(177f, 880f)
close()
moveTo(273f, 800f)
horizontalLineToRelative(527f)
verticalLineToRelative(-526f)
lineTo(273f, 800f)
close()
}
}
.build()
return signalCellular0Bar!!
}
private var signalCellular0Bar: ImageVector? = null
val MeshtasticIcons.SignalCellular1Bar: ImageVector
get() {
if (signalCellular1Bar != null) {
return signalCellular1Bar!!
}
signalCellular1Bar =
ImageVector.Builder(
name = "SignalCellular1Bar",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color(0xFFE3E3E3))) {
moveTo(177f, 880f)
quadToRelative(-18f, 0f, -29.5f, -12f)
reflectiveQuadTo(136f, 840f)
quadToRelative(0f, -8f, 3f, -15f)
reflectiveQuadToRelative(9f, -13f)
lineToRelative(664f, -664f)
quadToRelative(6f, -6f, 13f, -9f)
reflectiveQuadToRelative(15f, -3f)
quadToRelative(16f, 0f, 28f, 11.5f)
reflectiveQuadToRelative(12f, 29.5f)
verticalLineToRelative(643f)
quadToRelative(0f, 25f, -17.5f, 42.5f)
reflectiveQuadTo(820f, 880f)
lineTo(177f, 880f)
close()
moveTo(400f, 800f)
horizontalLineToRelative(400f)
verticalLineToRelative(-526f)
lineTo(400f, 674f)
verticalLineToRelative(126f)
close()
}
}
.build()
return signalCellular1Bar!!
}
private var signalCellular1Bar: ImageVector? = null
val MeshtasticIcons.SignalCellular2Bar: ImageVector
get() {
if (signalCellular2Bar != null) {
return signalCellular2Bar!!
}
signalCellular2Bar =
ImageVector.Builder(
name = "SignalCellular2Bar",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color(0xFFE3E3E3))) {
moveTo(177f, 880f)
quadToRelative(-18f, 0f, -29.5f, -12f)
reflectiveQuadTo(136f, 840f)
quadToRelative(0f, -8f, 3f, -15f)
reflectiveQuadToRelative(9f, -13f)
lineToRelative(664f, -664f)
quadToRelative(6f, -6f, 13f, -9f)
reflectiveQuadToRelative(15f, -3f)
quadToRelative(16f, 0f, 28f, 11.5f)
reflectiveQuadToRelative(12f, 29.5f)
verticalLineToRelative(643f)
quadToRelative(0f, 25f, -17.5f, 42.5f)
reflectiveQuadTo(820f, 880f)
lineTo(177f, 880f)
close()
moveTo(520f, 800f)
horizontalLineToRelative(280f)
verticalLineToRelative(-526f)
lineTo(520f, 554f)
verticalLineToRelative(246f)
close()
}
}
.build()
return signalCellular2Bar!!
}
private var signalCellular2Bar: ImageVector? = null
val MeshtasticIcons.SignalCellular3Bar: ImageVector
get() {
if (signalCellular3Bar != null) {
return signalCellular3Bar!!
}
signalCellular3Bar =
ImageVector.Builder(
name = "SignalCellular3Bar",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color(0xFFE3E3E3))) {
moveTo(177f, 880f)
quadToRelative(-18f, 0f, -29.5f, -12f)
reflectiveQuadTo(136f, 840f)
quadToRelative(0f, -8f, 3f, -15f)
reflectiveQuadToRelative(9f, -13f)
lineToRelative(664f, -664f)
quadToRelative(6f, -6f, 13f, -9f)
reflectiveQuadToRelative(15f, -3f)
quadToRelative(16f, 0f, 28f, 11.5f)
reflectiveQuadToRelative(12f, 29.5f)
verticalLineToRelative(643f)
quadToRelative(0f, 25f, -17.5f, 42.5f)
reflectiveQuadTo(820f, 880f)
lineTo(177f, 880f)
close()
moveTo(600f, 800f)
horizontalLineToRelative(200f)
verticalLineToRelative(-526f)
lineTo(600f, 474f)
verticalLineToRelative(326f)
close()
}
}
.build()
return signalCellular3Bar!!
}
private var signalCellular3Bar: ImageVector? = null
val MeshtasticIcons.SignalCellular4Bar: ImageVector
get() {
if (signalCellular4Bar != null) {
return signalCellular4Bar!!
}
signalCellular4Bar =
ImageVector.Builder(
name = "SignalCellular4Bar",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
)
.apply {
path(fill = SolidColor(Color(0xFFE3E3E3))) {
moveTo(177f, 880f)
quadToRelative(-18f, 0f, -29.5f, -12f)
reflectiveQuadTo(136f, 840f)
quadToRelative(0f, -8f, 3f, -15f)
reflectiveQuadToRelative(9f, -13f)
lineToRelative(664f, -664f)
quadToRelative(6f, -6f, 13f, -9f)
reflectiveQuadToRelative(15f, -3f)
quadToRelative(16f, 0f, 28f, 11.5f)
reflectiveQuadToRelative(12f, 29.5f)
verticalLineToRelative(643f)
quadToRelative(0f, 25f, -17.5f, 42.5f)
reflectiveQuadTo(820f, 880f)
lineTo(177f, 880f)
close()
}
}
.build()
return signalCellular4Bar!!
}
private var signalCellular4Bar: ImageVector? = null