mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
bdefbc3ce2
commit
60e7e18116
28 changed files with 1164 additions and 358 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
146
app/src/main/java/com/geeksville/mesh/model/Node.kt
Normal file
146
app/src/main/java/com/geeksville/mesh/model/Node.kt
Normal 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(" ")
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue