feat(config): implement excluded modules validation (#1460)

* feat(config): implement excluded modules validation

* feat: hide excluded configs from metadata

* refactor: save local metadata from WantConfig

* refactor: delete metadata from deleted nodes

* fix: always request metadata for admin routes

* feat: show node firmware when metadata is available

* refactor: rename filter function

* feat: add `ServiceAction` request metadata
This commit is contained in:
Andre K 2025-01-02 06:38:33 -03:00 committed by GitHub
parent bdefbc3ce2
commit 60e7e18116
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1164 additions and 358 deletions

View file

@ -21,7 +21,6 @@ import androidx.annotation.StringRes
import com.geeksville.mesh.MeshProtos.Routing
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.R
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.Reaction
@Suppress("CyclomaticComplexMethod")
@ -49,7 +48,7 @@ fun getStringResFrom(routingError: Int): Int = when (routingError) {
data class Message(
val uuid: Long,
val receivedTime: Long,
val node: NodeEntity,
val node: Node,
val text: String,
val time: String,
val read: Boolean,

View file

@ -38,7 +38,6 @@ import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.map.CustomTileSource
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.ui.Route
@ -71,7 +70,7 @@ data class MetricsState(
val isManaged: Boolean = true,
val isFahrenheit: Boolean = false,
val displayUnits: DisplayUnits = DisplayUnits.METRIC,
val node: NodeEntity? = null,
val node: Node? = null,
val deviceMetrics: List<Telemetry> = emptyList(),
val environmentMetrics: List<Telemetry> = emptyList(),
val signalMetrics: List<MeshPacket> = emptyList(),

View file

@ -0,0 +1,146 @@
/*
* Copyright (c) 2024 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 com.geeksville.mesh.model
import android.graphics.Color
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos.DeviceMetrics
import com.geeksville.mesh.TelemetryProtos.EnvironmentMetrics
import com.geeksville.mesh.TelemetryProtos.PowerMetrics
import com.geeksville.mesh.util.GPSFormat
import com.geeksville.mesh.util.latLongToMeter
import com.geeksville.mesh.util.toDistanceString
import com.google.protobuf.ByteString
@Suppress("MagicNumber")
data class Node(
val num: Int,
val metadata: MeshProtos.DeviceMetadata? = null,
val user: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
val position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(),
val snr: Float = Float.MAX_VALUE,
val rssi: Int = Int.MAX_VALUE,
val lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
val deviceMetrics: DeviceMetrics = DeviceMetrics.getDefaultInstance(),
val channel: Int = 0,
val viaMqtt: Boolean = false,
val hopsAway: Int = -1,
val isFavorite: Boolean = false,
val isIgnored: Boolean = false,
val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics.getDefaultInstance(),
val powerMetrics: PowerMetrics = PowerMetrics.getDefaultInstance(),
val paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
) {
val colors: Pair<Int, Int>
get() { // returns foreground and background @ColorInt for each 'num'
val r = (num and 0xFF0000) shr 16
val g = (num and 0x00FF00) shr 8
val b = num and 0x0000FF
val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
}
val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET
val hasPKC get() = !user.publicKey.isEmpty
val errorByteString: ByteString get() = ByteString.copyFrom(ByteArray(32) { 0 })
val mismatchKey get() = user.publicKey == errorByteString
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance()
val hasPowerMetrics: Boolean
get() = powerMetrics != PowerMetrics.getDefaultInstance()
val batteryLevel get() = deviceMetrics.batteryLevel
val voltage get() = deviceMetrics.voltage
val batteryStr get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
val latitude get() = position.latitudeI * 1e-7
val longitude get() = position.longitudeI * 1e-7
private fun hasValidPosition(): Boolean {
return latitude != 0.0 && longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
}
val validPosition: MeshProtos.Position? get() = position.takeIf { hasValidPosition() }
// @return distance in meters to some other node (or null if unknown)
fun distance(o: Node): Int? = when {
validPosition == null || o.validPosition == null -> null
else -> latLongToMeter(latitude, longitude, o.latitude, o.longitude).toInt()
}
// @return a nice human readable string for the distance, or null for unknown
fun distanceStr(o: Node, displayUnits: Int = 0): String? = distance(o)?.let { dist ->
val system = DisplayConfig.DisplayUnits.forNumber(displayUnits)
return if (dist > 0) dist.toDistanceString(system) else null
}
// @return bearing to the other position in degrees
fun bearing(o: Node?): Int? = when {
validPosition == null || o?.validPosition == null -> null
else -> com.geeksville.mesh.util.bearing(latitude, longitude, o.latitude, o.longitude).toInt()
}
fun gpsString(gpsFormat: Int): String = when (gpsFormat) {
DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude)
DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude)
DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude)
DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude)
else -> GPSFormat.toDEC(latitude, longitude)
}
private fun EnvironmentMetrics.getDisplayString(isFahrenheit: Boolean): String {
val temp = if (temperature != 0f) {
if (isFahrenheit) {
val fahrenheit = temperature * 1.8F + 32
"%.1f°F".format(fahrenheit)
} else {
"%.1f°C".format(temperature)
}
} else {
null
}
val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null
val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null
val current = if (current != 0f) "%.1fmA".format(current) else null
val iaq = if (iaq != 0) "IAQ: $iaq" else null
return listOfNotNull(
temp,
humidity,
voltage,
current,
iaq,
).joinToString(" ")
}
private fun PaxcountProtos.Paxcount.getDisplayString() =
"PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 && wifi != 0 }
fun getTelemetryString(isFahrenheit: Boolean = false): String {
return listOfNotNull(
paxcounter.getDisplayString(),
environmentMetrics.getDisplayString(isFahrenheit),
).joinToString(" ")
}
}

View file

@ -38,7 +38,6 @@ import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.config
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.moduleConfig
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
@ -56,6 +55,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
@ -73,7 +73,7 @@ data class RadioConfigState(
val isLocal: Boolean = false,
val connected: Boolean = false,
val route: String = "",
val metadata: MeshProtos.DeviceMetadata = MeshProtos.DeviceMetadata.getDefaultInstance(),
val metadata: MeshProtos.DeviceMetadata? = null,
val userConfig: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
val channelList: List<ChannelProtos.ChannelSettings> = emptyList(),
val radioConfig: ConfigProtos.Config = config {},
@ -81,9 +81,7 @@ data class RadioConfigState(
val ringtone: String = "",
val cannedMessageMessages: String = "",
val responseState: ResponseState<Boolean> = ResponseState.Empty,
) {
fun hasMetadata() = metadata != MeshProtos.DeviceMetadata.getDefaultInstance()
}
)
@HiltViewModel
class RadioConfigViewModel @Inject constructor(
@ -94,8 +92,8 @@ class RadioConfigViewModel @Inject constructor(
private val meshService: IMeshService? get() = radioConfigRepository.meshService
private val destNum = savedStateHandle.toRoute<Route.RadioConfig>().destNum
private val _destNode = MutableStateFlow<NodeEntity?>(null)
val destNode: StateFlow<NodeEntity?> get() = _destNode
private val _destNode = MutableStateFlow<Node?>(null)
val destNode: StateFlow<Node?> get() = _destNode
private val requestIds = MutableStateFlow(hashSetOf<Int>())
private val _radioConfigState = MutableStateFlow(RadioConfigState())
@ -106,9 +104,14 @@ class RadioConfigViewModel @Inject constructor(
init {
@OptIn(ExperimentalCoroutinesApi::class)
radioConfigRepository.nodeDBbyNum.mapLatest { nodes ->
nodes[destNum] ?: nodes.values.firstOrNull()
}.onEach { _destNode.value = it }.launchIn(viewModelScope)
radioConfigRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[destNum] ?: nodes.values.firstOrNull() }
.distinctUntilChanged()
.onEach {
_destNode.value = it
_radioConfigState.update { state -> state.copy(metadata = it?.metadata) }
}
.launchIn(viewModelScope)
radioConfigRepository.deviceProfileFlow.onEach {
_currentDeviceProfile.value = it
@ -322,7 +325,7 @@ class RadioConfigViewModel @Inject constructor(
when (route) {
AdminRoute.REBOOT.name -> requestReboot(destNum)
AdminRoute.SHUTDOWN.name -> with(radioConfigState.value) {
if (hasMetadata() && !metadata.canShutdown) {
if (metadata != null && !metadata.canShutdown) {
sendError(R.string.cant_shutdown)
} else {
requestShutdown(destNum)
@ -334,15 +337,6 @@ class RadioConfigViewModel @Inject constructor(
}
}
private fun getSessionPasskey(destNum: Int) {
if (radioConfigState.value.hasMetadata()) {
sendAdminRequest(destNum)
} else {
getConfig(destNum, AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE)
setResponseStateTotal(2)
}
}
fun setFixedPosition(position: Position) {
val destNum = destNode.value?.num ?: return
try {
@ -441,10 +435,15 @@ class RadioConfigViewModel @Inject constructor(
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNode.value?.num ?: return
_radioConfigState.value = RadioConfigState(
route = route.name,
responseState = ResponseState.Loading(),
)
_radioConfigState.update {
RadioConfigState(
isLocal = it.isLocal,
connected = it.connected,
route = route.name,
metadata = it.metadata,
responseState = ResponseState.Loading(),
)
}
when (route) {
ConfigRoute.USER -> getOwner(destNum)
@ -456,7 +455,10 @@ class RadioConfigViewModel @Inject constructor(
setResponseStateTotal(maxChannels + 1)
}
is AdminRoute -> getSessionPasskey(destNum)
is AdminRoute -> {
getConfig(destNum, AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE)
setResponseStateTotal(2)
}
is ConfigRoute -> {
if (route == ConfigRoute.LORA) {

View file

@ -52,7 +52,6 @@ import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.QuickChatActionRepository
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
@ -235,7 +234,7 @@ class UIViewModel @Inject constructor(
)
@OptIn(ExperimentalCoroutinesApi::class)
val nodeList: StateFlow<List<NodeEntity>> = nodesUiState.flatMapLatest { state ->
val nodeList: StateFlow<List<Node>> = nodesUiState.flatMapLatest { state ->
nodeDB.getNodes(state.sort, state.filter, state.includeUnknown)
}.stateIn(
scope = viewModelScope,
@ -245,7 +244,7 @@ class UIViewModel @Inject constructor(
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeEntity?> get() = nodeDB.myNodeInfo
val ourNodeInfo: StateFlow<NodeEntity?> get() = nodeDB.ourNodeInfo
val ourNodeInfo: StateFlow<Node?> get() = nodeDB.ourNodeInfo
val nodesWithPosition get() = nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null }
@ -484,7 +483,7 @@ class UIViewModel @Inject constructor(
updateLoraConfig { it.copy { region = value } }
}
fun ignoreNode(node: NodeEntity) = viewModelScope.launch {
fun ignoreNode(node: Node) = viewModelScope.launch {
try {
radioConfigRepository.onServiceAction(ServiceAction.Ignore(node))
} catch (ex: RemoteException) {