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
pull/1517/head
Andre K 2025-01-02 06:38:33 -03:00 zatwierdzone przez GitHub
rodzic bdefbc3ce2
commit 60e7e18116
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
28 zmienionych plików z 1164 dodań i 358 usunięć

Wyświetl plik

@ -0,0 +1,564 @@
{
"formatVersion": 1,
"database": {
"version": 16,
"identityHash": "626fc53854f129654c1007b86d9fdda0",
"entities": [
{
"tableName": "my_node",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, PRIMARY KEY(`myNodeNum`))",
"fields": [
{
"fieldPath": "myNodeNum",
"columnName": "myNodeNum",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "firmwareVersion",
"columnName": "firmwareVersion",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "couldUpdate",
"columnName": "couldUpdate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "shouldUpdate",
"columnName": "shouldUpdate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentPacketId",
"columnName": "currentPacketId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "messageTimeoutMsec",
"columnName": "messageTimeoutMsec",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minAppVersion",
"columnName": "minAppVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "maxChannels",
"columnName": "maxChannels",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasWifi",
"columnName": "hasWifi",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"myNodeNum"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "nodes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, PRIMARY KEY(`num`))",
"fields": [
{
"fieldPath": "num",
"columnName": "num",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "user",
"columnName": "user",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "longName",
"columnName": "long_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "shortName",
"columnName": "short_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "latitude",
"columnName": "latitude",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "longitude",
"columnName": "longitude",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "snr",
"columnName": "snr",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "rssi",
"columnName": "rssi",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastHeard",
"columnName": "last_heard",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deviceTelemetry",
"columnName": "device_metrics",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "channel",
"columnName": "channel",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "viaMqtt",
"columnName": "via_mqtt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hopsAway",
"columnName": "hops_away",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFavorite",
"columnName": "is_favorite",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isIgnored",
"columnName": "is_ignored",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "environmentTelemetry",
"columnName": "environment_metrics",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "powerTelemetry",
"columnName": "power_metrics",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "paxcounter",
"columnName": "paxcounter",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"num"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "packet",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `reply_id` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "uuid",
"columnName": "uuid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "myNodeNum",
"columnName": "myNodeNum",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "port_num",
"columnName": "port_num",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contact_key",
"columnName": "contact_key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "received_time",
"columnName": "received_time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "read",
"columnName": "read",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "data",
"columnName": "data",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packetId",
"columnName": "packet_id",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "routingError",
"columnName": "routing_error",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "-1"
},
{
"fieldPath": "replyId",
"columnName": "reply_id",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uuid"
]
},
"indices": [
{
"name": "index_packet_myNodeNum",
"unique": false,
"columnNames": [
"myNodeNum"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
},
{
"name": "index_packet_port_num",
"unique": false,
"columnNames": [
"port_num"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
},
{
"name": "index_packet_contact_key",
"unique": false,
"columnNames": [
"contact_key"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
}
],
"foreignKeys": []
},
{
"tableName": "contact_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, PRIMARY KEY(`contact_key`))",
"fields": [
{
"fieldPath": "contact_key",
"columnName": "contact_key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "muteUntil",
"columnName": "muteUntil",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"contact_key"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
"fields": [
{
"fieldPath": "uuid",
"columnName": "uuid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message_type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "received_date",
"columnName": "received_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "raw_message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fromNum",
"columnName": "from_num",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "portNum",
"columnName": "port_num",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "fromRadio",
"columnName": "from_radio",
"affinity": "BLOB",
"notNull": true,
"defaultValue": "x''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uuid"
]
},
"indices": [
{
"name": "index_log_from_num",
"unique": false,
"columnNames": [
"from_num"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
},
{
"name": "index_log_port_num",
"unique": false,
"columnNames": [
"port_num"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
}
],
"foreignKeys": []
},
{
"tableName": "quick_chat",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uuid",
"columnName": "uuid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "message",
"columnName": "message",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mode",
"columnName": "mode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uuid"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "reactions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`reply_id`, `user_id`, `emoji`))",
"fields": [
{
"fieldPath": "replyId",
"columnName": "reply_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emoji",
"columnName": "emoji",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"reply_id",
"user_id",
"emoji"
]
},
"indices": [
{
"name": "index_reactions_reply_id",
"unique": false,
"columnNames": [
"reply_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
}
],
"foreignKeys": []
},
{
"tableName": "metadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
"fields": [
{
"fieldPath": "num",
"columnName": "num",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "proto",
"columnName": "proto",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"num"
]
},
"indices": [
{
"name": "index_metadata_num",
"unique": false,
"columnNames": [
"num"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '626fc53854f129654c1007b86d9fdda0')"
]
}
}

Wyświetl plik

@ -24,8 +24,10 @@ import com.geeksville.mesh.database.MeshtasticDatabase
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.NodeSortOption
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
@ -40,6 +42,18 @@ class NodeInfoDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var nodeInfoDao: NodeInfoDao
private val unknownNode = NodeEntity(
num = 7,
user = user {
id = "!a1b2c3d4"
longName = "Meshtastic c3d4"
shortName = "c3d4"
hwModel = MeshProtos.HardwareModel.UNSET
},
longName = "Meshtastic c3d4",
shortName = null // Dao filter for includeUnknown
)
private val ourNode = NodeEntity(
num = 8,
user = user {
@ -79,7 +93,7 @@ class NodeInfoDaoTest {
39.952583 to -75.165222, // Philadelphia
)
private val testNodes = listOf(ourNode) + testPositions.mapIndexed { index, pos ->
private val testNodes = listOf(ourNode, unknownNode) + testPositions.mapIndexed { index, pos ->
NodeEntity(
num = 9 + index,
user = user {
@ -89,7 +103,7 @@ class NodeInfoDaoTest {
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
isLicensed = false
},
longName = "Kevin Mester$index", shortName = if (index == 2) null else "KM$index",
longName = "Kevin Mester$index", shortName = "KM$index",
latitude = pos.first, longitude = pos.second,
lastHeard = 9 + index,
)
@ -124,18 +138,18 @@ class NodeInfoDaoTest {
sort = sort.sqlValue,
filter = filter,
includeUnknown = includeUnknown,
).first().filter { it != ourNode }
).map { list -> list.map { it.toModel() } }.first().filter { it.num != ourNode.num }
@Test // node list size
fun testNodeListSize() = runBlocking {
val nodes = nodeInfoDao.nodeDBbyNum().first()
assertEquals(11, nodes.size)
assertEquals(12, nodes.size)
}
@Test // nodeDBbyNum() re-orders our node at the top of the list
fun testOurNodeInfoIsFirst() = runBlocking {
val nodes = nodeInfoDao.nodeDBbyNum().first()
assertEquals(ourNode, nodes.values.first())
assertEquals(ourNode.num, nodes.values.first().node.num)
}
@Test
@ -155,8 +169,9 @@ class NodeInfoDaoTest {
@Test
fun testSortByDistance() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.DISTANCE)
fun NodeEntity.toNode() = Node(num = num, user = user, position = position)
val sortedNodes = nodes.sortedWith( // nodes with invalid (null) positions at the end
compareBy<NodeEntity> { it.validPosition == null }.thenBy { it.distance(ourNode) }
compareBy<Node> { it.validPosition == null }.thenBy { it.distance(ourNode.toNode()) }
)
assertEquals(sortedNodes, nodes)
}
@ -185,7 +200,7 @@ class NodeInfoDaoTest {
@Test
fun testIncludeUnknownIsTrue() = runBlocking {
val nodes = getNodes(includeUnknown = true)
val containsUnsetNode = nodes.any { it.shortName == null }
val containsUnsetNode = nodes.any { it.isUnknownUser }
assertTrue(containsUnsetNode)
}
}

Wyświetl plik

@ -114,4 +114,19 @@ class Converters : Logging {
fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? {
return value.toByteArray()
}
@TypeConverter
fun bytesToMetadata(bytes: ByteArray): MeshProtos.DeviceMetadata {
return try {
MeshProtos.DeviceMetadata.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToMetadata TypeConverter error:", ex)
MeshProtos.DeviceMetadata.getDefaultInstance()
}
}
@TypeConverter
fun metadataToBytes(value: MeshProtos.DeviceMetadata): ByteArray? {
return value.toByteArray()
}
}

Wyświetl plik

@ -31,6 +31,7 @@ import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.dao.QuickChatActionDao
import com.geeksville.mesh.database.entity.ContactSettings
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.database.entity.MetadataEntity
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.Packet
@ -46,6 +47,7 @@ import com.geeksville.mesh.database.entity.ReactionEntity
MeshLog::class,
QuickChatAction::class,
ReactionEntity::class,
MetadataEntity::class,
],
autoMigrations = [
AutoMigration(from = 3, to = 4),
@ -60,8 +62,9 @@ import com.geeksville.mesh.database.entity.ReactionEntity
AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class),
AutoMigration(from = 13, to = 14),
AutoMigration(from = 14, to = 15),
AutoMigration(from = 15, to = 16),
],
version = 15,
version = 16,
exportSchema = true,
)
@TypeConverters(Converters::class)

Wyświetl plik

@ -23,14 +23,19 @@ import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.entity.MetadataEntity
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.NodeSortOption
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
@ -49,15 +54,20 @@ class NodeRepository @Inject constructor(
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
// our node info
private val _ourNodeInfo = MutableStateFlow<NodeEntity?>(null)
val ourNodeInfo: StateFlow<NodeEntity?> get() = _ourNodeInfo
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
val ourNodeInfo: StateFlow<Node?> get() = _ourNodeInfo
// The unique userId of our node
private val _myId = MutableStateFlow<String?>(null)
val myId: StateFlow<String?> get() = _myId
// A map from nodeNum to NodeEntity
val nodeDBbyNum: StateFlow<Map<Int, NodeEntity>> = nodeInfoDao.nodeDBbyNum()
fun getNodeDBbyNum() = nodeInfoDao.nodeDBbyNum()
.map { map -> map.mapValues { (_, it) -> it.toEntity() } }
// A map from nodeNum to Node
@OptIn(ExperimentalCoroutinesApi::class)
val nodeDBbyNum: StateFlow<Map<Int, Node>> = nodeInfoDao.nodeDBbyNum()
.mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } }
.onEach {
val ourNodeInfo = it.values.firstOrNull()
_ourNodeInfo.value = ourNodeInfo
@ -67,8 +77,8 @@ class NodeRepository @Inject constructor(
.conflate()
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
fun getNode(userId: String): NodeEntity = nodeDBbyNum.value.values.find { it.user.id == userId }
?: NodeEntity(
fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
?: Node(
num = DataPacket.idToDefaultNodeNum(userId) ?: 0,
user = getUser(userId),
)
@ -84,6 +94,7 @@ class NodeRepository @Inject constructor(
.setHwModel(MeshProtos.HardwareModel.UNSET)
.build()
@OptIn(ExperimentalCoroutinesApi::class)
fun getNodes(
sort: NodeSortOption = NodeSortOption.LAST_HEARD,
filter: String = "",
@ -92,7 +103,7 @@ class NodeRepository @Inject constructor(
sort = sort.sqlValue,
filter = filter,
includeUnknown = includeUnknown,
).flowOn(dispatchers.io).conflate()
).mapLatest { list -> list.map { it.toModel() } }.flowOn(dispatchers.io).conflate()
suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) {
nodeInfoDao.upsert(node)
@ -107,5 +118,10 @@ class NodeRepository @Inject constructor(
suspend fun deleteNode(num: Int) = withContext(dispatchers.io) {
nodeInfoDao.deleteNode(num)
nodeInfoDao.deleteMetadata(num)
}
suspend fun insertMetadata(metadata: MetadataEntity) = withContext(dispatchers.io) {
nodeInfoDao.upsert(metadata)
}
}

Wyświetl plik

@ -22,11 +22,15 @@ import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.geeksville.mesh.database.entity.MetadataEntity
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeWithRelations
import com.geeksville.mesh.database.entity.NodeEntity
import kotlinx.coroutines.flow.Flow
@Suppress("TooManyFunctions")
@Dao
interface NodeInfoDao {
@ -49,7 +53,8 @@ interface NodeInfoDao {
last_heard DESC
"""
)
fun nodeDBbyNum(): Flow<Map<@MapColumn(columnName = "num") Int, NodeEntity>>
@Transaction
fun nodeDBbyNum(): Flow<Map<@MapColumn(columnName = "num") Int, NodeWithRelations>>
@Query(
"""
@ -92,11 +97,12 @@ interface NodeInfoDao {
last_heard DESC
"""
)
@Transaction
fun getNodes(
sort: String,
filter: String,
includeUnknown: Boolean,
): Flow<List<NodeEntity>>
): Flow<List<NodeWithRelations>>
@Upsert
fun upsert(node: NodeEntity)
@ -109,4 +115,10 @@ interface NodeInfoDao {
@Query("DELETE FROM nodes WHERE num=:num")
fun deleteNode(num: Int)
@Upsert
fun upsert(meta: MetadataEntity)
@Query("DELETE FROM metadata WHERE num=:num")
fun deleteMetadata(num: Int)
}

Wyświetl plik

@ -17,11 +17,12 @@
package com.geeksville.mesh.database.entity
import android.graphics.Color
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
import androidx.room.Relation
import com.geeksville.mesh.DeviceMetrics
import com.geeksville.mesh.EnvironmentMetrics
import com.geeksville.mesh.MeshProtos
@ -31,16 +32,72 @@ import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.Position
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.copy
import com.geeksville.mesh.util.bearing
import com.geeksville.mesh.util.GPSFormat
import com.geeksville.mesh.util.latLongToMeter
import com.geeksville.mesh.util.toDistanceString
import com.geeksville.mesh.model.Node
import com.google.protobuf.ByteString
data class NodeWithRelations(
@Embedded val node: NodeEntity,
@Relation(entity = MetadataEntity::class, parentColumn = "num", entityColumn = "num")
val metadata: MetadataEntity? = null,
) {
fun toModel() = with(node) {
Node(
num = num,
metadata = metadata?.proto,
user = user,
position = position,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = deviceTelemetry.deviceMetrics,
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
paxcounter = paxcounter,
)
}
fun toEntity() = with(node) {
NodeEntity(
num = num,
user = user,
position = position,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceTelemetry = deviceTelemetry,
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
environmentTelemetry = environmentTelemetry,
powerTelemetry = powerTelemetry,
paxcounter = paxcounter,
)
}
}
@Entity(
tableName = "metadata",
indices = [
Index(value = ["num"]),
],
)
data class MetadataEntity(
@PrimaryKey val num: Int,
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB)
val proto: MeshProtos.DeviceMetadata,
val timestamp: Long = System.currentTimeMillis(),
)
@Suppress("MagicNumber")
@Entity(tableName = "nodes")
data class NodeEntity(
@PrimaryKey(autoGenerate = false)
val num: Int, // This is immutable, and used as a key
@ -92,32 +149,9 @@ data class NodeEntity(
val environmentMetrics: TelemetryProtos.EnvironmentMetrics
get() = environmentTelemetry.environmentMetrics
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != TelemetryProtos.EnvironmentMetrics.getDefaultInstance()
val powerMetrics: TelemetryProtos.PowerMetrics
get() = powerTelemetry.powerMetrics
val hasPowerMetrics: Boolean
get() = powerMetrics != TelemetryProtos.PowerMetrics.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 batteryLevel get() = deviceMetrics.batteryLevel
val voltage get() = deviceMetrics.voltage
val batteryStr get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
fun setPosition(p: MeshProtos.Position, defaultTime: Int = currentTime()) {
position = p.copy { time = if (p.time != 0) p.time else defaultTime }
@ -125,75 +159,6 @@ data class NodeEntity(
longitude = degD(p.longitudeI)
}
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: NodeEntity): 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: NodeEntity, 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: NodeEntity?): Int? = when {
validPosition == null || o?.validPosition == null -> null
else -> 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 TelemetryProtos.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(" ")
}
/**
* true if the device was heard from recently
*/
@ -211,48 +176,48 @@ data class NodeEntity(
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
}
}
fun NodeEntity.toNodeInfo() = NodeInfo(
num = num,
user = MeshUser(
id = user.id,
longName = user.longName,
shortName = user.shortName,
hwModel = user.hwModel,
role = user.roleValue,
).takeIf { user.id.isNotEmpty() },
position = Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude,
time = position.time,
satellitesInView = position.satsInView,
groundSpeed = position.groundSpeed,
groundTrack = position.groundTrack,
precisionBits = position.precisionBits,
).takeIf { it.isValid() },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = DeviceMetrics(
time = deviceTelemetry.time,
batteryLevel = deviceMetrics.batteryLevel,
voltage = deviceMetrics.voltage,
channelUtilization = deviceMetrics.channelUtilization,
airUtilTx = deviceMetrics.airUtilTx,
uptimeSeconds = deviceMetrics.uptimeSeconds,
),
channel = channel,
environmentMetrics = EnvironmentMetrics(
time = environmentTelemetry.time,
temperature = environmentMetrics.temperature,
relativeHumidity = environmentMetrics.relativeHumidity,
barometricPressure = environmentMetrics.barometricPressure,
gasResistance = environmentMetrics.gasResistance,
voltage = environmentMetrics.voltage,
current = environmentMetrics.current,
iaq = environmentMetrics.iaq,
),
hopsAway = hopsAway,
)
fun toNodeInfo() = NodeInfo(
num = num,
user = MeshUser(
id = user.id,
longName = user.longName,
shortName = user.shortName,
hwModel = user.hwModel,
role = user.roleValue,
).takeIf { user.id.isNotEmpty() },
position = Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude,
time = position.time,
satellitesInView = position.satsInView,
groundSpeed = position.groundSpeed,
groundTrack = position.groundTrack,
precisionBits = position.precisionBits,
).takeIf { it.isValid() },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = DeviceMetrics(
time = deviceTelemetry.time,
batteryLevel = deviceMetrics.batteryLevel,
voltage = deviceMetrics.voltage,
channelUtilization = deviceMetrics.channelUtilization,
airUtilTx = deviceMetrics.airUtilTx,
uptimeSeconds = deviceMetrics.uptimeSeconds,
),
channel = channel,
environmentMetrics = EnvironmentMetrics(
time = environmentTelemetry.time,
temperature = environmentMetrics.temperature,
relativeHumidity = environmentMetrics.relativeHumidity,
barometricPressure = environmentMetrics.barometricPressure,
gasResistance = environmentMetrics.gasResistance,
voltage = environmentMetrics.voltage,
current = environmentMetrics.current,
iaq = environmentMetrics.iaq,
),
hopsAway = hopsAway,
)
}

Wyświetl plik

@ -26,6 +26,7 @@ import androidx.room.Relation
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MeshProtos.User
import com.geeksville.mesh.model.Message
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.util.getShortDateTime
data class PacketEntity(
@ -33,7 +34,7 @@ data class PacketEntity(
@Relation(entity = ReactionEntity::class, parentColumn = "packet_id", entityColumn = "reply_id")
val reactions: List<ReactionEntity> = emptyList(),
) {
suspend fun toMessage(getNode: suspend (userId: String?) -> NodeEntity) = with(packet) {
suspend fun toMessage(getNode: suspend (userId: String?) -> Node) = with(packet) {
Message(
uuid = uuid,
receivedTime = received_time,
@ -101,7 +102,7 @@ data class ReactionEntity(
)
private suspend fun ReactionEntity.toReaction(
getNode: suspend (userId: String?) -> NodeEntity
getNode: suspend (userId: String?) -> Node
) = Reaction(
replyId = replyId,
user = getNode(userId).user,
@ -110,5 +111,5 @@ private suspend fun ReactionEntity.toReaction(
)
private suspend fun List<ReactionEntity>.toReaction(
getNode: suspend (userId: String?) -> NodeEntity
getNode: suspend (userId: String?) -> Node
) = this.map { it.toReaction(getNode) }

Wyświetl plik

@ -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,

Wyświetl plik

@ -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(),

Wyświetl plik

@ -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(" ")
}
}

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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) {

Wyświetl plik

@ -25,12 +25,15 @@ import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.IMeshService
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.geeksville.mesh.MeshProtos.DeviceMetadata
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.database.entity.MetadataEntity
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.deviceProfile
import com.geeksville.mesh.database.NodeRepository
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.getChannelUrl
import com.geeksville.mesh.service.MeshService.ConnectionState
import com.geeksville.mesh.service.ServiceAction
@ -40,6 +43,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import javax.inject.Inject
/**
@ -70,16 +74,20 @@ class RadioConfigRepository @Inject constructor(
val myNodeInfo: StateFlow<MyNodeEntity?> get() = nodeDB.myNodeInfo
/**
* Flow representing the [NodeEntity] database.
* Flow representing the [Node] database.
*/
val nodeDBbyNum: StateFlow<Map<Int, NodeEntity>> get() = nodeDB.nodeDBbyNum
val nodeDBbyNum: StateFlow<Map<Int, Node>> get() = nodeDB.nodeDBbyNum
fun getUser(nodeNum: Int) = nodeDB.getUser(nodeNum)
suspend fun getNodeDBbyNum() = nodeDB.getNodeDBbyNum().first()
suspend fun upsert(node: NodeEntity) = nodeDB.upsert(node)
suspend fun installNodeDB(mi: MyNodeEntity, nodes: List<NodeEntity>) {
nodeDB.installNodeDB(mi, nodes)
}
suspend fun insertMetadata(fromNum: Int, metadata: DeviceMetadata) {
nodeDB.insertMetadata(MetadataEntity(fromNum, metadata))
}
/**
* Flow representing the [ChannelSet] data store.
@ -195,7 +203,7 @@ class RadioConfigRepository @Inject constructor(
serviceRepository.emitMeshPacket(packet)
}
val serviceAction: SharedFlow<ServiceAction> get() = serviceRepository.serviceAction
val serviceAction: Flow<ServiceAction> get() = serviceRepository.serviceAction
suspend fun onServiceAction(action: ServiceAction) = coroutineScope {
serviceRepository.onServiceAction(action)

Wyświetl plik

@ -26,17 +26,38 @@ import android.os.IBinder
import android.os.RemoteException
import androidx.core.app.ServiceCompat
import androidx.core.location.LocationCompat
import com.geeksville.mesh.*
import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.BuildConfig
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.CoroutineDispatchers
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.IMeshService
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.MeshProtos.ToRadio
import com.geeksville.mesh.MeshUser
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.ModuleConfigProtos
import com.geeksville.mesh.MyNodeInfo
import com.geeksville.mesh.NodeInfo
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.Position
import com.geeksville.mesh.R
import com.geeksville.mesh.StoreAndForwardProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.TelemetryProtos.LocalStats
import com.geeksville.mesh.analytics.DataPair
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.android.hasLocationPermission
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.entity.MeshLog
@ -44,15 +65,22 @@ 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.ReactionEntity
import com.geeksville.mesh.database.entity.toNodeInfo
import com.geeksville.mesh.fromRadio
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.getTracerouteResponse
import com.geeksville.mesh.position
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.location.LocationRepository
import com.geeksville.mesh.repository.network.MQTTRepository
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.radio.RadioServiceConnectionState
import com.geeksville.mesh.util.*
import com.geeksville.mesh.telemetry
import com.geeksville.mesh.user
import com.geeksville.mesh.util.anonymize
import com.geeksville.mesh.util.toOneLineString
import com.geeksville.mesh.util.toPIIString
import com.geeksville.mesh.util.toRemoteExceptions
import com.google.protobuf.ByteString
import com.google.protobuf.InvalidProtocolBufferException
import dagger.Lazy
@ -77,7 +105,8 @@ import javax.inject.Inject
import kotlin.math.absoluteValue
sealed class ServiceAction {
data class Ignore(val node: NodeEntity) : ServiceAction()
data class GetDeviceMetadata(val destNum: Int) : ServiceAction()
data class Ignore(val node: Node) : ServiceAction()
data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
}
@ -302,12 +331,8 @@ class MeshService : Service(), Logging {
.launchIn(serviceScope)
radioConfigRepository.channelSetFlow.onEach { channelSet = it }
.launchIn(serviceScope)
radioConfigRepository.serviceAction.onEach { action ->
when (action) {
is ServiceAction.Ignore -> ignoreNode(action.node)
is ServiceAction.Reaction -> sendReaction(action)
}
}.launchIn(serviceScope)
radioConfigRepository.serviceAction.onEach(::onServiceAction)
.launchIn(serviceScope)
loadSettings() // Load our last known node DB
@ -375,10 +400,10 @@ class MeshService : Service(), Logging {
// BEGINNING OF MODEL - FIXME, move elsewhere
//
private fun loadSettings() {
private fun loadSettings() = serviceScope.handledLaunch {
discardNodeDB() // Get rid of any old state
myNodeInfo = radioConfigRepository.myNodeInfo.value
nodeDBbyNodeNum.putAll(radioConfigRepository.nodeDBbyNum.value)
nodeDBbyNodeNum.putAll(radioConfigRepository.getNodeDBbyNum())
// Note: we do not haveNodeDB = true because that means we've got a valid db from a real device (rather than this possibly stale hint)
}
@ -808,15 +833,17 @@ class MeshService : Service(), Logging {
}
private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) {
if (fromNodeNum == myNodeNum) {
when (a.payloadVariantCase) {
AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> {
when (a.payloadVariantCase) {
AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> {
if (fromNodeNum == myNodeNum) {
val response = a.getConfigResponse
debug("Admin: received config ${response.payloadVariantCase}")
setLocalConfig(response)
}
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> {
AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> {
if (fromNodeNum == myNodeNum) {
val mi = myNodeInfo
if (mi != null) {
val ch = a.getChannelResponse
@ -827,12 +854,19 @@ class MeshService : Service(), Logging {
}
}
}
else -> warn("No special processing needed for ${a.payloadVariantCase}")
}
} else {
debug("Admin: Received session_passkey from $fromNodeNum")
sessionPasskey = a.sessionPasskey
AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> {
debug("Admin: received DeviceMetadata from $fromNodeNum")
serviceScope.handledLaunch {
radioConfigRepository.insertMetadata(fromNodeNum, a.getDeviceMetadataResponse)
}
}
else -> warn("No special processing needed for ${a.payloadVariantCase}")
}
debug("Admin: Received session_passkey from $fromNodeNum")
sessionPasskey = a.sessionPasskey
}
// Update our DB of users based on someone sending out a User subpacket
@ -1144,13 +1178,6 @@ class MeshService : Service(), Logging {
}
}
private fun clearLocalConfig() {
serviceScope.handledLaunch {
radioConfigRepository.clearLocalConfig()
radioConfigRepository.clearLocalModuleConfig()
}
}
private fun updateChannelSettings(ch: ChannelProtos.Channel) = serviceScope.handledLaunch {
radioConfigRepository.updateChannelSettings(ch)
}
@ -1476,31 +1503,33 @@ class MeshService : Service(), Logging {
}
private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null
private var rawDeviceMetadata: MeshProtos.DeviceMetadata? = null
/** Regenerate the myNodeInfo model. We call this twice. Once after we receive myNodeInfo from the device
* and again after we have the node DB (which might allow us a better notion of our HwModel.
*/
private fun regenMyNodeInfo() {
private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata) {
val myInfo = rawMyNodeInfo
if (myInfo != null) {
val mi = with(myInfo) {
MyNodeEntity(
myNodeNum = myNodeNum,
model = when (val hwModel = rawDeviceMetadata?.hwModel) {
model = when (val hwModel = metadata.hwModel) {
null, MeshProtos.HardwareModel.UNSET -> null
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
},
firmwareVersion = rawDeviceMetadata?.firmwareVersion,
firmwareVersion = metadata.firmwareVersion,
couldUpdate = false,
shouldUpdate = false, // TODO add check after re-implementing firmware updates
currentPacketId = currentPacketId and 0xffffffffL,
messageTimeoutMsec = 5 * 60 * 1000, // constants from current firmware code
minAppVersion = minAppVersion,
maxChannels = 8,
hasWifi = rawDeviceMetadata?.hasWifi ?: false,
hasWifi = metadata.hasWifi,
)
}
serviceScope.handledLaunch {
radioConfigRepository.insertMetadata(mi.myNodeNum, metadata)
}
newMyNodeInfo = mi
}
}
@ -1554,8 +1583,7 @@ class MeshService : Service(), Logging {
)
insertMeshLog(packetToSave)
rawDeviceMetadata = metadata
regenMyNodeInfo()
regenMyNodeInfo(metadata)
}
/**
@ -1760,7 +1788,21 @@ class MeshService : Service(), Logging {
}
}
private fun ignoreNode(node: NodeEntity) = toRemoteExceptions {
private fun onServiceAction(action: ServiceAction) {
when (action) {
is ServiceAction.GetDeviceMetadata -> getDeviceMetadata(action.destNum)
is ServiceAction.Ignore -> ignoreNode(action.node)
is ServiceAction.Reaction -> sendReaction(action)
}
}
private fun getDeviceMetadata(destNum: Int) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(destNum).buildAdminPacket(wantResponse = true) {
getDeviceMetadataRequest = true
})
}
private fun ignoreNode(node: Node) = toRemoteExceptions {
sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket {
if (node.isIgnored) {
debug("removing node ${node.num} from ignore list")

Wyświetl plik

@ -20,10 +20,12 @@ package com.geeksville.mesh.service
import com.geeksville.mesh.IMeshService
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.android.Logging
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import javax.inject.Inject
import javax.inject.Singleton
@ -86,10 +88,10 @@ class ServiceRepository @Inject constructor() : Logging {
setTracerouteResponse(null)
}
private val _serviceAction = MutableSharedFlow<ServiceAction>()
val serviceAction: SharedFlow<ServiceAction> get() = _serviceAction
private val _serviceAction = Channel<ServiceAction>()
val serviceAction = _serviceAction.receiveAsFlow()
suspend fun onServiceAction(action: ServiceAction) {
_serviceAction.emit(action)
_serviceAction.send(action)
}
}

Wyświetl plik

@ -71,6 +71,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.geeksville.mesh.MeshProtos.DeviceMetadata
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.model.MetricsViewModel
@ -235,6 +236,18 @@ enum class ConfigRoute(val title: String, val route: Route, val icon: ImageVecto
LORA("LoRa", Route.LoRa, Icons.Default.CellTower, 5),
BLUETOOTH("Bluetooth", Route.Bluetooth, Icons.Default.Bluetooth, 6),
SECURITY("Security", Route.Security, Icons.Default.Security, type = 7),
;
companion object {
fun filterExcludedFrom(metadata: DeviceMetadata?): List<ConfigRoute> = entries.filter {
when {
metadata == null -> true
it == BLUETOOTH -> metadata.hasBluetooth
it == NETWORK -> metadata.hasWifi || metadata.hasEthernet
else -> true // Include all other routes by default
}
}
}
}
// ModuleConfig (type = AdminProtos.AdminMessage.ModuleConfigType)
@ -252,6 +265,18 @@ enum class ModuleRoute(val title: String, val route: Route, val icon: ImageVecto
AMBIENT_LIGHTING("Ambient Lighting", Route.AmbientLighting, Icons.Default.LightMode, 10),
DETECTION_SENSOR("Detection Sensor", Route.DetectionSensor, Icons.Default.Sensors, 11),
PAXCOUNTER("Paxcounter", Route.Paxcounter, Icons.Default.PermScanWifi, 12),
;
val bitfield: Int get() = 1 shl ordinal
companion object {
fun filterExcludedFrom(metadata: DeviceMetadata?): List<ModuleRoute> = entries.filter {
when (metadata) {
null -> true
else -> metadata.excludedModules and it.bitfield == 0
}
}
}
}
/**

Wyświetl plik

@ -58,6 +58,7 @@ import androidx.compose.material.icons.filled.KeyOff
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Numbers
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Power
@ -91,11 +92,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.R
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.MetricsState
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.ui.components.PreferenceCategory
import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider
import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.util.DistanceUnit
import com.geeksville.mesh.util.formatAgo
@ -132,7 +133,7 @@ fun NodeDetailScreen(
@Composable
private fun NodeDetailList(
modifier: Modifier = Modifier,
node: NodeEntity,
node: Node,
metricsState: MetricsState,
onNavigate: (Any) -> Unit = {},
) {
@ -257,7 +258,7 @@ private fun DeviceDetailsContent(
@Composable
private fun NodeDetailsContent(
node: NodeEntity,
node: Node,
) {
if (node.mismatchKey) {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -304,6 +305,13 @@ private fun NodeDetailsContent(
value = formatUptime(node.deviceMetrics.uptimeSeconds)
)
}
if (node.metadata != null) {
NodeDetailRow(
label = "Firmware version",
icon = Icons.Default.Memory,
value = node.metadata.firmwareVersion.substringBeforeLast(".")
)
}
NodeDetailRow(
label = "Last heard",
icon = Icons.Default.History,
@ -413,7 +421,7 @@ private fun InfoCard(
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun EnvironmentMetrics(
node: NodeEntity,
node: Node,
isFahrenheit: Boolean = false,
) = with(node.environmentMetrics) {
FlowRow(
@ -543,7 +551,7 @@ private fun calculateDewPoint(tempCelsius: Float, humidity: Float): Float {
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PowerMetrics(node: NodeEntity) = with(node.powerMetrics) {
private fun PowerMetrics(node: Node) = with(node.powerMetrics) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@ -597,8 +605,8 @@ private fun PowerMetrics(node: NodeEntity) = with(node.powerMetrics) {
@Preview(showBackground = true)
@Composable
private fun NodeDetailsPreview(
@PreviewParameter(NodeEntityPreviewParameterProvider::class)
node: NodeEntity
@PreviewParameter(NodePreviewParameterProvider::class)
node: Node
) {
AppTheme {
NodeDetailList(

Wyświetl plik

@ -59,14 +59,14 @@ import com.geeksville.mesh.ConfigProtos.Config.DeviceConfig
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.ui.components.NodeMenuAction
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.ui.components.NodeKeyStatusIcon
import com.geeksville.mesh.ui.components.NodeMenu
import com.geeksville.mesh.ui.components.NodeMenuAction
import com.geeksville.mesh.ui.components.SignalInfo
import com.geeksville.mesh.ui.compose.ElevationInfo
import com.geeksville.mesh.ui.compose.SatelliteCountInfo
import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider
import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.util.toDistanceString
@ -74,8 +74,8 @@ import com.geeksville.mesh.util.toDistanceString
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun NodeItem(
thisNode: NodeEntity?,
thatNode: NodeEntity,
thisNode: Node?,
thatNode: Node,
gpsFormat: Int,
distanceUnits: Int,
tempInFahrenheit: Boolean,
@ -293,8 +293,8 @@ fun NodeItem(
@Preview(showBackground = false)
fun NodeInfoSimplePreview() {
AppTheme {
val thisNode = NodeEntityPreviewParameterProvider().values.first()
val thatNode = NodeEntityPreviewParameterProvider().values.last()
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode = NodePreviewParameterProvider().values.last()
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
@ -312,11 +312,11 @@ fun NodeInfoSimplePreview() {
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES,
)
fun NodeInfoPreview(
@PreviewParameter(NodeEntityPreviewParameterProvider::class)
thatNode: NodeEntity
@PreviewParameter(NodePreviewParameterProvider::class)
thatNode: Node
) {
AppTheme {
val thisNode = NodeEntityPreviewParameterProvider().values.first()
val thisNode = NodePreviewParameterProvider().values.first()
Column {
Text(
text = "Details Collapsed",

Wyświetl plik

@ -65,6 +65,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
import com.geeksville.mesh.R
import com.geeksville.mesh.model.RadioConfigState
import com.geeksville.mesh.model.RadioConfigViewModel
import com.geeksville.mesh.ui.components.PreferenceCategory
import com.geeksville.mesh.ui.components.config.EditDeviceProfileDialog
@ -150,8 +151,7 @@ fun RadioConfigScreen(
}
RadioConfigItemList(
enabled = state.connected && !isWaiting,
isLocal = state.isLocal,
state = state,
modifier = modifier,
onRouteClick = { route ->
isWaiting = true
@ -285,28 +285,28 @@ private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Un
@Composable
private fun RadioConfigItemList(
enabled: Boolean = true,
isLocal: Boolean = true,
state: RadioConfigState,
modifier: Modifier = Modifier,
onRouteClick: (Enum<*>) -> Unit = {},
onImport: () -> Unit = {},
onExport: () -> Unit = {},
) {
val enabled = state.connected && !state.responseState.isWaiting()
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(horizontal = 16.dp),
) {
item { PreferenceCategory(stringResource(R.string.device_settings)) }
items(ConfigRoute.entries) {
items(ConfigRoute.filterExcludedFrom(state.metadata)) {
NavCard(title = it.title, icon = it.icon, enabled = enabled) { onRouteClick(it) }
}
item { PreferenceCategory(stringResource(R.string.module_settings)) }
items(ModuleRoute.entries) {
items(ModuleRoute.filterExcludedFrom(state.metadata)) {
NavCard(title = it.title, icon = it.icon, enabled = enabled) { onRouteClick(it) }
}
if (isLocal) {
if (state.isLocal) {
item {
PreferenceCategory("Backup & Restore")
NavCard(
@ -331,5 +331,7 @@ private fun RadioConfigItemList(
@Preview(showBackground = true)
@Composable
private fun RadioSettingsScreenPreview() {
RadioConfigItemList()
RadioConfigItemList(
RadioConfigState(isLocal = true, connected = true)
)
}

Wyświetl plik

@ -39,7 +39,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.components.NodeMenuAction
import com.geeksville.mesh.ui.components.NodeFilterTextField
@ -53,7 +53,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
private val model: UIViewModel by activityViewModels()
private fun navigateToMessages(node: NodeEntity) = node.user.let { user ->
private fun navigateToMessages(node: Node) = node.user.let { user ->
val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC // TODO use meta.hasPKC
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
val contactKey = "$channel${user.id}"
@ -91,7 +91,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
@Suppress("LongMethod")
fun NodesScreen(
model: UIViewModel = hiltViewModel(),
navigateToMessages: (NodeEntity) -> Unit,
navigateToMessages: (Node) -> Unit,
navigateToNodeDetails: (Int) -> Unit,
) {
val state by model.nodesUiState.collectAsStateWithLifecycle()

Wyświetl plik

@ -36,12 +36,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.Node
@Suppress("LongMethod")
@Composable
fun NodeMenu(
node: NodeEntity,
node: Node,
showFullMenu: Boolean = false,
onDismissRequest: () -> Unit,
expanded: Boolean = false,
@ -150,11 +150,11 @@ fun NodeMenu(
}
sealed class NodeMenuAction {
data class Remove(val node: NodeEntity) : NodeMenuAction()
data class Ignore(val node: NodeEntity) : NodeMenuAction()
data class DirectMessage(val node: NodeEntity) : NodeMenuAction()
data class RequestUserInfo(val node: NodeEntity) : NodeMenuAction()
data class RequestPosition(val node: NodeEntity) : NodeMenuAction()
data class TraceRoute(val node: NodeEntity) : NodeMenuAction()
data class MoreDetails(val node: NodeEntity) : NodeMenuAction()
data class Remove(val node: Node) : NodeMenuAction()
data class Ignore(val node: Node) : NodeMenuAction()
data class DirectMessage(val node: Node) : NodeMenuAction()
data class RequestUserInfo(val node: Node) : NodeMenuAction()
data class RequestPosition(val node: Node) : NodeMenuAction()
data class TraceRoute(val node: Node) : NodeMenuAction()
data class MoreDetails(val node: Node) : NodeMenuAction()
}

Wyświetl plik

@ -26,8 +26,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.geeksville.mesh.R
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.theme.AppTheme
const val MAX_VALID_SNR = 100F
@ -36,7 +36,7 @@ const val MAX_VALID_RSSI = 0
@Composable
fun SignalInfo(
modifier: Modifier = Modifier,
node: NodeEntity,
node: Node,
isThisNode: Boolean
) {
val text = if (isThisNode) {
@ -81,7 +81,7 @@ fun SignalInfo(
fun SignalInfoSimplePreview() {
AppTheme {
SignalInfo(
node = NodeEntity(
node = Node(
num = 1,
lastHeard = 0,
channel = 0,
@ -97,8 +97,8 @@ fun SignalInfoSimplePreview() {
@PreviewLightDark
@Composable
fun SignalInfoPreview(
@PreviewParameter(NodeEntityPreviewParameterProvider::class)
node: NodeEntity
@PreviewParameter(NodePreviewParameterProvider::class)
node: Node
) {
AppTheme {
SignalInfo(
@ -111,8 +111,8 @@ fun SignalInfoPreview(
@Composable
@PreviewLightDark
fun SignalInfoSelfPreview(
@PreviewParameter(NodeEntityPreviewParameterProvider::class)
node: NodeEntity
@PreviewParameter(NodePreviewParameterProvider::class)
node: Node
) {
AppTheme {
SignalInfo(

Wyświetl plik

@ -81,6 +81,8 @@ fun NetworkConfigScreen(
}
NetworkConfigItemList(
hasWifi = state.metadata?.hasWifi ?: true,
hasEthernet = state.metadata?.hasEthernet ?: true,
networkConfig = state.radioConfig.network,
enabled = state.connected,
onSaveClicked = { networkInput ->
@ -94,8 +96,11 @@ private fun extractWifiCredentials(qrCode: String) = Regex("""WIFI:S:(.*?);.*?P:
.find(qrCode)?.destructured
?.let { (ssid, password) -> ssid to password } ?: (null to null)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NetworkConfigItemList(
hasWifi: Boolean,
hasEthernet: Boolean,
networkConfig: NetworkConfig,
enabled: Boolean,
onSaveClicked: (NetworkConfig) -> Unit,
@ -137,16 +142,16 @@ fun NetworkConfigItemList(
item {
SwitchPreference(title = "WiFi enabled",
checked = networkInput.wifiEnabled,
enabled = enabled,
enabled = enabled && hasWifi,
onCheckedChange = { networkInput = networkInput.copy { wifiEnabled = it } })
Divider()
}
item { Divider() }
item {
EditTextPreference(title = "SSID",
value = networkInput.wifiSsid,
maxSize = 32, // wifi_ssid max_size:33
enabled = enabled,
enabled = enabled && hasWifi,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
@ -161,7 +166,7 @@ fun NetworkConfigItemList(
EditPasswordPreference(title = "PSK",
value = networkInput.wifiPsk,
maxSize = 64, // wifi_psk max_size:65
enabled = enabled,
enabled = enabled && hasWifi,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { networkInput = networkInput.copy { wifiPsk = it } })
}
@ -173,12 +178,20 @@ fun NetworkConfigItemList(
.fillMaxWidth()
.padding(vertical = 8.dp)
.height(48.dp),
enabled = enabled,
enabled = enabled && hasWifi,
) {
Text(text = stringResource(R.string.wifi_qr_code_scan))
}
}
item {
SwitchPreference(title = "Ethernet enabled",
checked = networkInput.ethEnabled,
enabled = enabled && hasEthernet,
onCheckedChange = { networkInput = networkInput.copy { ethEnabled = it } })
Divider()
}
item {
EditTextPreference(title = "NTP server",
value = networkInput.ntpServer,
@ -209,14 +222,6 @@ fun NetworkConfigItemList(
})
}
item {
SwitchPreference(title = "Ethernet enabled",
checked = networkInput.ethEnabled,
enabled = enabled,
onCheckedChange = { networkInput = networkInput.copy { ethEnabled = it } })
}
item { Divider() }
item {
DropDownPreference(title = "IPv4 mode",
enabled = enabled,
@ -225,8 +230,8 @@ fun NetworkConfigItemList(
.map { it to it.name },
selectedItem = networkInput.addressMode,
onItemSelected = { networkInput = networkInput.copy { addressMode = it } })
Divider()
}
item { Divider() }
item {
EditIPv4Preference(title = "IP",
@ -292,6 +297,8 @@ fun NetworkConfigItemList(
@Composable
private fun NetworkConfigPreview() {
NetworkConfigItemList(
hasWifi = true,
hasEthernet = true,
networkConfig = NetworkConfig.getDefaultInstance(),
enabled = true,
onSaveClicked = { },

Wyświetl plik

@ -65,8 +65,8 @@ import com.geeksville.mesh.android.gpsDisabled
import com.geeksville.mesh.android.hasGps
import com.geeksville.mesh.android.hasLocationPermission
import com.geeksville.mesh.copy
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.map.CustomTileSource
import com.geeksville.mesh.model.map.MarkerWithLabel
@ -311,7 +311,7 @@ fun MapView(
AppCompatResources.getDrawable(context, R.drawable.ic_baseline_location_on_24)
}
fun MapView.onNodesChanged(nodes: Collection<NodeEntity>): List<MarkerWithLabel> {
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
val nodesWithPosition = nodes.filter { it.validPosition != null }
val ourNode = model.ourNodeInfo.value
val gpsFormat = model.config.display.gpsFormat.number

Wyświetl plik

@ -90,8 +90,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.getChannel
import com.geeksville.mesh.ui.components.NodeKeyStatusIcon
@ -116,7 +116,7 @@ internal fun FragmentManager.navigateToMessages(contactKey: String, message: Str
class MessagesFragment : Fragment(), Logging {
private val model: UIViewModel by activityViewModels()
private fun navigateToMessages(node: NodeEntity) = node.user.let { user ->
private fun navigateToMessages(node: Node) = node.user.let { user ->
val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC // TODO use meta.hasPKC
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
val contactKey = "$channel${user.id}"
@ -168,7 +168,7 @@ internal fun MessageScreen(
contactKey: String,
message: String,
viewModel: UIViewModel = hiltViewModel(),
navigateToMessages: (NodeEntity) -> Unit,
navigateToMessages: (Node) -> Unit,
navigateToNodeDetails: (Int) -> Unit,
onNavigateBack: () -> Unit
) {

Wyświetl plik

@ -60,16 +60,16 @@ import androidx.compose.ui.unit.sp
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.R
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.ui.components.AutoLinkText
import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider
import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.theme.AppTheme
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
@Composable
internal fun MessageItem(
node: NodeEntity,
node: Node,
messageText: String?,
messageTime: String,
messageStatus: MessageStatus?,
@ -197,7 +197,7 @@ internal fun MessageItem(
private fun MessageItemPreview() {
AppTheme {
MessageItem(
node = NodeEntityPreviewParameterProvider().values.first(),
node = NodePreviewParameterProvider().values.first(),
messageText = stringResource(R.string.sample_message),
messageTime = "10:00",
messageStatus = MessageStatus.DELIVERED,

Wyświetl plik

@ -20,19 +20,17 @@ package com.geeksville.mesh.ui.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.geeksville.mesh.DeviceMetrics.Companion.currentTime
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.deviceMetrics
import com.geeksville.mesh.environmentMetrics
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.paxcount
import com.geeksville.mesh.position
import com.geeksville.mesh.telemetry
import com.geeksville.mesh.user
import com.google.protobuf.ByteString
import kotlin.random.Random
class NodeEntityPreviewParameterProvider : PreviewParameterProvider<NodeEntity> {
val mickeyMouse = NodeEntity(
class NodePreviewParameterProvider : PreviewParameterProvider<Node> {
val mickeyMouse = Node(
num = 1955,
user = user {
id = "mickeyMouseId"
@ -40,28 +38,22 @@ class NodeEntityPreviewParameterProvider : PreviewParameterProvider<NodeEntity>
shortName = "MM"
hwModel = MeshProtos.HardwareModel.TBEAM
},
longName = "Mickey Mouse",
shortName = "MM",
position = position {
latitudeI = 338125110
longitudeI = -1179189760
altitude = 138
satsInView = 4
},
latitude = 33.812511,
longitude = -117.918976,
lastHeard = currentTime(),
channel = 0,
snr = 12.5F,
rssi = -42,
deviceTelemetry = telemetry {
deviceMetrics = deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
}
deviceMetrics = deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
},
hopsAway = 0
)
@ -74,17 +66,13 @@ class NodeEntityPreviewParameterProvider : PreviewParameterProvider<NodeEntity>
id = "minnieMouseId"
hwModel = MeshProtos.HardwareModel.HELTEC_V3
},
longName = "Minnie Mouse",
shortName = "MiMo",
snr = 12.5F,
rssi = -42,
position = position {},
latitude = 0.0,
longitude = 0.0,
hopsAway = 1
)
private val donaldDuck = NodeEntity(
private val donaldDuck = Node(
num = Random.nextInt(),
position = position {
latitudeI = 338052347
@ -92,20 +80,16 @@ class NodeEntityPreviewParameterProvider : PreviewParameterProvider<NodeEntity>
altitude = 121
satsInView = 66
},
latitude = 33.8052347,
longitude = -117.9208460,
lastHeard = currentTime() - 300,
channel = 0,
snr = 12.5F,
rssi = -42,
deviceTelemetry = telemetry {
deviceMetrics = deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
}
deviceMetrics = deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
},
user = user {
id = "donaldDuckId"
@ -114,18 +98,14 @@ class NodeEntityPreviewParameterProvider : PreviewParameterProvider<NodeEntity>
hwModel = MeshProtos.HardwareModel.HELTEC_V3
publicKey = ByteString.copyFrom(ByteArray(32) { 1 })
},
longName = "Donald Duck, the Grand Duck of the Ducks",
shortName = "DoDu",
environmentTelemetry = telemetry {
environmentMetrics = environmentMetrics {
temperature = 28.0F
relativeHumidity = 50.0F
barometricPressure = 1013.25F
gasResistance = 0.0F
voltage = 3.7F
current = 0.0F
iaq = 100
}
environmentMetrics = environmentMetrics {
temperature = 28.0F
relativeHumidity = 50.0F
barometricPressure = 1013.25F
gasResistance = 0.0F
voltage = 3.7F
current = 0.0F
iaq = 100
},
paxcounter = paxcount {
wifi = 30
@ -142,19 +122,15 @@ class NodeEntityPreviewParameterProvider : PreviewParameterProvider<NodeEntity>
shortName = "myId"
hwModel = MeshProtos.HardwareModel.UNSET
},
longName = "Meshtastic myId",
shortName = null,
environmentTelemetry = telemetry {
environmentMetrics = environmentMetrics {}
},
environmentMetrics = environmentMetrics {},
paxcounter = paxcount {},
)
private val almostNothing = NodeEntity(
private val almostNothing = Node(
num = Random.nextInt(),
)
override val values: Sequence<NodeEntity>
override val values: Sequence<Node>
get() = sequenceOf(
mickeyMouse, // "this" node
unknown,