From 396195a1b8b469ba11d8eee1c25b9b53c9ad4cb3 Mon Sep 17 00:00:00 2001 From: Andre K Date: Mon, 16 Sep 2024 17:57:30 -0300 Subject: [PATCH] refactor: introduce `NodeEntity` protobuf-based database entity (#1250) --- .../10.json | 642 ++++++++++++++++++ .../com/geeksville/mesh/NodeInfoDaoTest.kt | 85 +-- .../geeksville/mesh/database/Converters.kt | 69 +- .../mesh/database/MeshtasticDatabase.kt | 5 +- .../mesh/database/dao/NodeInfoDao.kt | 61 +- .../mesh/database/entity/NodeEntity.kt | 156 +++++ .../java/com/geeksville/mesh/model/NodeDB.kt | 21 +- .../mesh/model/RadioConfigViewModel.kt | 3 +- .../java/com/geeksville/mesh/model/UIState.kt | 21 +- .../datastore/RadioConfigRepository.kt | 7 +- .../geeksville/mesh/service/MeshService.kt | 108 +-- .../com/geeksville/mesh/ui/map/MapFragment.kt | 2 +- 12 files changed, 1029 insertions(+), 151 deletions(-) create mode 100644 app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/10.json create mode 100644 app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt diff --git a/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/10.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/10.json new file mode 100644 index 000000000..fa19fdb3c --- /dev/null +++ b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/10.json @@ -0,0 +1,642 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "d3d6934fdce32237a4c8f2cb24455a59", + "entities": [ + { + "tableName": "MyNodeInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `hasGPS` 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, `channelUtilization` REAL NOT NULL, `airUtilTx` REAL NOT NULL, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasGPS", + "columnName": "hasGPS", + "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 + }, + { + "fieldPath": "channelUtilization", + "columnName": "channelUtilization", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "airUtilTx", + "columnName": "airUtilTx", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NodeInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `lastHeard` INTEGER NOT NULL, `channel` INTEGER NOT NULL, `hopsAway` INTEGER NOT NULL DEFAULT 0, `user_id` TEXT, `user_longName` TEXT, `user_shortName` TEXT, `user_hwModel` TEXT, `user_isLicensed` INTEGER, `user_role` INTEGER DEFAULT 0, `position_latitude` REAL, `position_longitude` REAL, `position_altitude` INTEGER, `position_time` INTEGER, `position_satellitesInView` INTEGER, `position_groundSpeed` INTEGER, `position_groundTrack` INTEGER, `position_precisionBits` INTEGER, `devMetrics_time` INTEGER, `devMetrics_batteryLevel` INTEGER, `devMetrics_voltage` REAL, `devMetrics_channelUtilization` REAL, `devMetrics_airUtilTx` REAL, `devMetrics_uptimeSeconds` INTEGER, `envMetrics_time` INTEGER, `envMetrics_temperature` REAL, `envMetrics_relativeHumidity` REAL, `envMetrics_barometricPressure` REAL, `envMetrics_gasResistance` REAL, `envMetrics_voltage` REAL, `envMetrics_current` REAL, `envMetrics_iaq` INTEGER, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "lastHeard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "user.id", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.longName", + "columnName": "user_longName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.shortName", + "columnName": "user_shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.hwModel", + "columnName": "user_hwModel", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.isLicensed", + "columnName": "user_isLicensed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "user.role", + "columnName": "user_role", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "position.latitude", + "columnName": "position_latitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "position.longitude", + "columnName": "position_longitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "position.altitude", + "columnName": "position_altitude", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.time", + "columnName": "position_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.satellitesInView", + "columnName": "position_satellitesInView", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.groundSpeed", + "columnName": "position_groundSpeed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.groundTrack", + "columnName": "position_groundTrack", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.precisionBits", + "columnName": "position_precisionBits", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.time", + "columnName": "devMetrics_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.batteryLevel", + "columnName": "devMetrics_batteryLevel", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.voltage", + "columnName": "devMetrics_voltage", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.channelUtilization", + "columnName": "devMetrics_channelUtilization", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.airUtilTx", + "columnName": "devMetrics_airUtilTx", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.uptimeSeconds", + "columnName": "devMetrics_uptimeSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.time", + "columnName": "envMetrics_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.temperature", + "columnName": "envMetrics_temperature", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.relativeHumidity", + "columnName": "envMetrics_relativeHumidity", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.barometricPressure", + "columnName": "envMetrics_barometricPressure", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.gasResistance", + "columnName": "envMetrics_gasResistance", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.voltage", + "columnName": "envMetrics_voltage", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.current", + "columnName": "envMetrics_current", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.iaq", + "columnName": "envMetrics_iaq", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "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, `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": "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)", + "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 + } + ], + "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, 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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [], + "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": [] + } + ], + "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, 'd3d6934fdce32237a4c8f2cb24455a59')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt b/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt index a5e87b4c8..c6da0d415 100644 --- a/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt +++ b/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt @@ -5,6 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.geeksville.mesh.database.MeshtasticDatabase import com.geeksville.mesh.database.dao.NodeInfoDao +import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.model.NodeSortOption import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -17,24 +18,25 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class NodeDBTest { +class NodeInfoDaoTest { private lateinit var database: MeshtasticDatabase private lateinit var nodeInfoDao: NodeInfoDao - private val ourNodeInfo = NodeInfo( + private val ourNode = NodeEntity( num = 8, - user = MeshUser( - "+16508765308".format(8), - "Kevin Mester", - "KLO", - MeshProtos.HardwareModel.ANDROID_SIM, - false - ), - position = Position(30.267153, -97.743057, 35, 123), // Austin + user = user { + id = "+16508765308".format(8) + longName = "Kevin Mester" + shortName = "KLO" + hwModel = MeshProtos.HardwareModel.ANDROID_SIM + isLicensed = false + }, + longName = "Kevin Mester", shortName = "KLO", + latitude = 30.267153, longitude = -97.743057 // Austin ) private val myNodeInfo: MyNodeInfo = MyNodeInfo( - myNodeNum = ourNodeInfo.num, + myNodeNum = ourNode.num, hasGPS = false, model = null, firmwareVersion = null, @@ -50,28 +52,30 @@ class NodeDBTest { ) private val testPositions = arrayOf( - Position(32.776665, -96.796989, 35, 123), // Dallas - Position(32.960758, -96.733521, 35, 456), // Richardson - Position(32.912901, -96.781776, 35, 789), // North Dallas - Position(29.760427, -95.369804, 35, 123), // Houston - Position(33.748997, -84.387985, 35, 456), // Atlanta - Position(34.052235, -118.243683, 35, 789), // Los Angeles - Position(40.712776, -74.005974, 35, 123), // New York City - Position(41.878113, -87.629799, 35, 456), // Chicago - Position(39.952583, -75.165222, 35, 789), // Philadelphia + 0.0 to 0.0, + 32.776665 to -96.796989, // Dallas + 32.960758 to -96.733521, // Richardson + 32.912901 to -96.781776, // North Dallas + 29.760427 to -95.369804, // Houston + 33.748997 to -84.387985, // Atlanta + 34.052235 to -118.243683, // Los Angeles + 40.712776 to -74.005974, // New York City + 41.878113 to -87.629799, // Chicago + 39.952583 to -75.165222, // Philadelphia ) - private val testNodes = listOf(ourNodeInfo) + testPositions.mapIndexed { index, it -> - NodeInfo( + private val testNodes = listOf(ourNode) + testPositions.mapIndexed { index, pos -> + NodeEntity( num = 9 + index, - user = MeshUser( - "+165087653%02d".format(9 + index), - "Kevin Mester$index", - "KM$index", - if (index == 2) MeshProtos.HardwareModel.UNSET else MeshProtos.HardwareModel.ANDROID_SIM, - false - ), - position = it, + user = user { + id = "+165087653%02d".format(9 + index) + longName = "Kevin Mester$index" + shortName = "KM$index" + hwModel = MeshProtos.HardwareModel.ANDROID_SIM + isLicensed = false + }, + longName = "Kevin Mester$index", shortName = if (index == 2) null else "KM$index", + latitude = pos.first, longitude = pos.second, lastHeard = 9 + index, ) } @@ -95,7 +99,7 @@ class NodeDBTest { /** * Retrieves a list of nodes based on [sort], [filter] and [includeUnknown] parameters. - * The list excludes [ourNodeInfo] (our NodeInfo) to ensure consistency in the results. + * The list excludes [ourNode] to ensure consistency in the results. */ private suspend fun getNodes( sort: NodeSortOption = NodeSortOption.LAST_HEARD, @@ -105,19 +109,18 @@ class NodeDBTest { sort = sort.sqlValue, filter = filter, includeUnknown = includeUnknown, - unknownHwModel = MeshProtos.HardwareModel.UNSET - ).first().filter { it != ourNodeInfo } + ).first().filter { it != ourNode } @Test // node list size fun testNodeListSize() = runBlocking { val nodes = nodeInfoDao.nodeDBbyNum().first() - assertEquals(10, nodes.size) + assertEquals(11, nodes.size) } @Test // nodeDBbyNum() re-orders our node at the top of the list fun testOurNodeInfoIsFirst() = runBlocking { val nodes = nodeInfoDao.nodeDBbyNum().first() - assertEquals(ourNodeInfo, nodes.values.first()) + assertEquals(ourNode, nodes.values.first()) } @Test @@ -130,14 +133,14 @@ class NodeDBTest { @Test fun testSortByAlpha() = runBlocking { val nodes = getNodes(sort = NodeSortOption.ALPHABETICAL) - val sortedNodes = nodes.sortedBy { it.user?.longName?.uppercase() } + val sortedNodes = nodes.sortedBy { it.user.longName.uppercase() } assertEquals(sortedNodes, nodes) } @Test fun testSortByDistance() = runBlocking { val nodes = getNodes(sort = NodeSortOption.DISTANCE) - val sortedNodes = nodes.sortedBy { it.distance(ourNodeInfo) } + val sortedNodes = nodes.sortedBy { it.distance(ourNode) } assertEquals(sortedNodes, nodes) } @@ -151,7 +154,7 @@ class NodeDBTest { @Test fun testSortByViaMqtt() = runBlocking { val nodes = getNodes(sort = NodeSortOption.VIA_MQTT) - val sortedNodes = nodes.sortedBy { it.user?.longName?.contains("(MQTT)") == true } + val sortedNodes = nodes.sortedBy { it.user.longName.contains("(MQTT)") } assertEquals(sortedNodes, nodes) } @@ -159,7 +162,7 @@ class NodeDBTest { fun testIncludeUnknownIsFalse() = runBlocking { val nodes = getNodes(includeUnknown = false) val containsUnsetNode = nodes.any { node -> - node.user?.hwModel == MeshProtos.HardwareModel.UNSET + node.user.hwModel == MeshProtos.HardwareModel.UNSET } assertFalse(containsUnsetNode) } @@ -167,9 +170,7 @@ class NodeDBTest { @Test fun testIncludeUnknownIsTrue() = runBlocking { val nodes = getNodes(includeUnknown = true) - val containsUnsetNode = nodes.any { node -> - node.user?.hwModel == MeshProtos.HardwareModel.UNSET - } + val containsUnsetNode = nodes.any { it.shortName == null } assertTrue(containsUnsetNode) } } diff --git a/app/src/main/java/com/geeksville/mesh/database/Converters.kt b/app/src/main/java/com/geeksville/mesh/database/Converters.kt index 9639bcf3d..fd9fff474 100644 --- a/app/src/main/java/com/geeksville/mesh/database/Converters.kt +++ b/app/src/main/java/com/geeksville/mesh/database/Converters.kt @@ -2,11 +2,14 @@ package com.geeksville.mesh.database import androidx.room.TypeConverter import com.geeksville.mesh.DataPacket -import com.geeksville.mesh.MeshProtos.MeshPacket -import com.google.protobuf.TextFormat +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.PaxcountProtos +import com.geeksville.mesh.TelemetryProtos +import com.geeksville.mesh.android.Logging +import com.google.protobuf.InvalidProtocolBufferException import kotlinx.serialization.json.Json -class Converters { +class Converters : Logging { @TypeConverter fun dataFromString(value: String): DataPacket { val json = Json { isLenient = true } @@ -20,14 +23,62 @@ class Converters { } @TypeConverter - fun protoFromString(value: String): MeshPacket { - val builder = MeshPacket.newBuilder() - TextFormat.getParser().merge(value, builder) - return builder.build() + fun bytesToUser(bytes: ByteArray): MeshProtos.User { + return try { + MeshProtos.User.parseFrom(bytes) + } catch (ex: InvalidProtocolBufferException) { + errormsg("bytesToUser TypeConverter error:", ex) + MeshProtos.User.getDefaultInstance() + } } @TypeConverter - fun protoToString(value: MeshPacket): String { - return value.toString() + fun userToBytes(value: MeshProtos.User): ByteArray? { + return value.toByteArray() + } + + @TypeConverter + fun bytesToPosition(bytes: ByteArray): MeshProtos.Position { + return try { + MeshProtos.Position.parseFrom(bytes) + } catch (ex: InvalidProtocolBufferException) { + errormsg("bytesToPosition TypeConverter error:", ex) + MeshProtos.Position.getDefaultInstance() + } + } + + @TypeConverter + fun positionToBytes(value: MeshProtos.Position): ByteArray? { + return value.toByteArray() + } + + @TypeConverter + fun bytesToTelemetry(bytes: ByteArray): TelemetryProtos.Telemetry { + return try { + TelemetryProtos.Telemetry.parseFrom(bytes) + } catch (ex: InvalidProtocolBufferException) { + errormsg("bytesToTelemetry TypeConverter error:", ex) + TelemetryProtos.Telemetry.getDefaultInstance() + } + } + + @TypeConverter + fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? { + return value.toByteArray() + } + + @TypeConverter + fun bytesToPaxcounter(bytes: ByteArray): PaxcountProtos.Paxcount { + return try { + PaxcountProtos.Paxcount.parseFrom(bytes) + } catch (ex: InvalidProtocolBufferException) { + errormsg("bytesToPaxcounter TypeConverter error:", ex) + PaxcountProtos.Paxcount.getDefaultInstance() + } + } + + @TypeConverter + fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? { + return value.toByteArray() } } diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt index c2dfe7b3e..9188540b7 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt @@ -14,6 +14,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.NodeEntity import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.database.entity.QuickChatAction @@ -21,6 +22,7 @@ import com.geeksville.mesh.database.entity.QuickChatAction entities = [ MyNodeInfo::class, NodeInfo::class, + NodeEntity::class, Packet::class, ContactSettings::class, MeshLog::class, @@ -33,8 +35,9 @@ import com.geeksville.mesh.database.entity.QuickChatAction AutoMigration (from = 6, to = 7), AutoMigration (from = 7, to = 8), AutoMigration (from = 8, to = 9), + AutoMigration (from = 9, to = 10), ], - version = 9, + version = 10, exportSchema = true, ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt index eda4a116e..77f01d897 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt @@ -6,9 +6,8 @@ import androidx.room.MapColumn import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Upsert -import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.MyNodeInfo -import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.database.entity.NodeEntity import kotlinx.coroutines.flow.Flow @Dao @@ -23,62 +22,70 @@ interface NodeInfoDao { @Query("DELETE FROM MyNodeInfo") fun clearMyNodeInfo() - @Query("SELECT * FROM NodeInfo ORDER BY CASE WHEN num = (SELECT myNodeNum FROM MyNodeInfo LIMIT 1) THEN 0 ELSE 1 END, lastHeard DESC") - fun nodeDBbyNum(): Flow> + @Query( + """ + SELECT * FROM nodes + ORDER BY CASE + WHEN num = (SELECT myNodeNum FROM MyNodeInfo LIMIT 1) THEN 0 + ELSE 1 + END, + last_heard DESC + """ + ) + fun nodeDBbyNum(): Flow> @Query( """ WITH OurNode AS ( - SELECT position_latitude, position_longitude - FROM NodeInfo + SELECT latitude, longitude + FROM nodes WHERE num = (SELECT myNodeNum FROM MyNodeInfo LIMIT 1) ) - SELECT * FROM NodeInfo - WHERE (:includeUnknown = 1 OR user_hwModel != :unknownHwModel) + SELECT * FROM nodes + WHERE (:includeUnknown = 1 OR short_name IS NOT NULL) AND (:filter = '' - OR (user_longName LIKE '%' || :filter || '%' - OR user_shortName LIKE '%' || :filter || '%')) + OR (long_name LIKE '%' || :filter || '%' + OR short_name LIKE '%' || :filter || '%')) ORDER BY CASE WHEN num = (SELECT myNodeNum FROM MyNodeInfo LIMIT 1) THEN 0 ELSE 1 END, CASE - WHEN :sort = 'last_heard' THEN lastHeard * -1 - WHEN :sort = 'alpha' THEN UPPER(user_longName) + WHEN :sort = 'last_heard' THEN last_heard * -1 + WHEN :sort = 'alpha' THEN UPPER(long_name) WHEN :sort = 'distance' THEN CASE - WHEN position_latitude IS NULL OR position_longitude IS NULL OR - (position_latitude = 0 AND position_longitude = 0) THEN 999999999 + WHEN latitude IS NULL OR longitude IS NULL OR + (latitude = 0.0 AND longitude = 0.0) THEN 999999999 ELSE - (position_latitude - (SELECT position_latitude FROM OurNode)) * - (position_latitude - (SELECT position_latitude FROM OurNode)) + - (position_longitude - (SELECT position_longitude FROM OurNode)) * - (position_longitude - (SELECT position_longitude FROM OurNode)) + (latitude - (SELECT latitude FROM OurNode)) * + (latitude - (SELECT latitude FROM OurNode)) + + (longitude - (SELECT longitude FROM OurNode)) * + (longitude - (SELECT longitude FROM OurNode)) END - WHEN :sort = 'hops_away' THEN hopsAway + WHEN :sort = 'hops_away' THEN hops_away WHEN :sort = 'channel' THEN channel - WHEN :sort = 'via_mqtt' THEN user_longName LIKE '%(MQTT)' -- viaMqtt + WHEN :sort = 'via_mqtt' THEN long_name LIKE '%(MQTT)' -- viaMqtt ELSE 0 END ASC, - lastHeard DESC + last_heard DESC """ ) fun getNodes( sort: String, filter: String, includeUnknown: Boolean, - unknownHwModel: MeshProtos.HardwareModel - ): Flow> + ): Flow> @Upsert - fun upsert(node: NodeInfo) + fun upsert(node: NodeEntity) @Insert(onConflict = OnConflictStrategy.REPLACE) - fun putAll(nodes: List) + fun putAll(nodes: List) - @Query("DELETE FROM NodeInfo") + @Query("DELETE FROM nodes") fun clearNodeInfo() - @Query("DELETE FROM NodeInfo WHERE num=:num") + @Query("DELETE FROM nodes WHERE num=:num") fun deleteNode(num: Int) } diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt new file mode 100644 index 000000000..2570031d3 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt @@ -0,0 +1,156 @@ +package com.geeksville.mesh.database.entity + +import android.graphics.Color +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.geeksville.mesh.DeviceMetrics +import com.geeksville.mesh.EnvironmentMetrics +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.MeshUser +import com.geeksville.mesh.NodeInfo +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.latLongToMeter + +@Suppress("MagicNumber") +@Entity(tableName = "nodes") +data class NodeEntity( + + @PrimaryKey(autoGenerate = false) + val num: Int, // This is immutable, and used as a key + + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + var user: MeshProtos.User = MeshProtos.User.getDefaultInstance(), + @ColumnInfo(name = "long_name") var longName: String? = null, + @ColumnInfo(name = "short_name") var shortName: String? = null, // used in includeUnknown filter + + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + var position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(), + var latitude: Double = 0.0, + var longitude: Double = 0.0, + + var snr: Float = Float.MAX_VALUE, + var rssi: Int = Int.MAX_VALUE, + + @ColumnInfo(name = "last_heard") + var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970 + + @ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB) + var deviceTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(), + + var channel: Int = 0, + + @ColumnInfo(name = "via_mqtt") + var viaMqtt: Boolean = false, + + @ColumnInfo(name = "hops_away") + var hopsAway: Int = 0, + + @ColumnInfo(name = "is_favorite") + var isFavorite: Boolean = false, + + @ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB) + var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(), + + @ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB) + var powerTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(), + + @ColumnInfo(typeAffinity = ColumnInfo.BLOB) + var paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(), +) { + val deviceMetrics: TelemetryProtos.DeviceMetrics + get() = deviceTelemetry.deviceMetrics + + val environmentMetrics: TelemetryProtos.EnvironmentMetrics + get() = environmentTelemetry.environmentMetrics + + val powerMetrics: TelemetryProtos.PowerMetrics + get() = powerTelemetry.powerMetrics + + val colors: Pair + 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 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 } + latitude = degD(p.latitudeI) + longitude = degD(p.longitudeI) + } + + // @return distance in meters to some other node (or null if unknown) + fun distance(o: NodeEntity) = latLongToMeter(latitude, longitude, o.latitude, o.longitude) + + /** + * true if the device was heard from recently + */ + val isOnline: Boolean + get() { + val now = System.currentTimeMillis() / 1000 + val timeout = 15 * 60 + return (now - lastHeard <= timeout) + } + + companion object { + /// Convert to a double representation of degrees + fun degD(i: Int) = i * 1e-7 + fun degI(d: Double) = (d * 1e7).toInt() + + 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, +) diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt index e38c720e4..3234c0c96 100644 --- a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt @@ -2,12 +2,12 @@ package com.geeksville.mesh.model import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope -import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.MyNodeInfo import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.database.dao.NodeInfoDao +import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.database.entity.toNodeInfo import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn @@ -33,12 +33,12 @@ class NodeDB @Inject constructor( private val _myId = MutableStateFlow(null) val myId: StateFlow get() = _myId - // A map from nodeNum to NodeInfo - private val _nodeDBbyNum = MutableStateFlow>(mapOf()) - val nodeDBbyNum: StateFlow> get() = _nodeDBbyNum + // A map from nodeNum to NodeEntity + private val _nodeDBbyNum = MutableStateFlow>(mapOf()) + val nodeDBbyNum: StateFlow> get() = _nodeDBbyNum fun getUser(userId: String?) = userId?.let { id -> - nodeDBbyNum.value.values.find { it.user?.id == id }?.user + nodeDBbyNum.value.values.find { it.user.id == id }?.user } init { @@ -47,7 +47,7 @@ class NodeDB @Inject constructor( nodeInfoDao.nodeDBbyNum().onEach { _nodeDBbyNum.value = it - val ourNodeInfo = it.values.firstOrNull() + val ourNodeInfo = it.values.firstOrNull()?.toNodeInfo() _ourNodeInfo.value = ourNodeInfo _myId.value = ourNodeInfo?.user?.id }.launchIn(processLifecycle.coroutineScope) @@ -61,16 +61,13 @@ class NodeDB @Inject constructor( sort = sort.sqlValue, filter = filter, includeUnknown = includeUnknown, - unknownHwModel = MeshProtos.HardwareModel.UNSET ) - fun myNodeInfoFlow(): Flow = nodeInfoDao.getMyNodeInfo() - - suspend fun upsert(node: NodeInfo) = withContext(Dispatchers.IO) { + suspend fun upsert(node: NodeEntity) = withContext(Dispatchers.IO) { nodeInfoDao.upsert(node) } - suspend fun installNodeDB(mi: MyNodeInfo, nodes: List) = withContext(Dispatchers.IO) { + suspend fun installNodeDB(mi: MyNodeInfo, nodes: List) = withContext(Dispatchers.IO) { nodeInfoDao.clearMyNodeInfo() nodeInfoDao.setMyNodeInfo(mi) // set MyNodeInfo first nodeInfoDao.clearNodeInfo() diff --git a/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt index 8cdcd494a..61f7ff9db 100644 --- a/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt @@ -18,6 +18,7 @@ import com.geeksville.mesh.Portnums import com.geeksville.mesh.Position import com.geeksville.mesh.android.Logging import com.geeksville.mesh.config +import com.geeksville.mesh.database.entity.toNodeInfo import com.geeksville.mesh.deviceProfile import com.geeksville.mesh.moduleConfig import com.geeksville.mesh.repository.datastore.RadioConfigRepository @@ -85,7 +86,7 @@ class RadioConfigViewModel @Inject constructor( init { combine(_destNum, radioConfigRepository.nodeDBbyNum) { destNum, nodes -> nodes[destNum] ?: nodes.values.firstOrNull() - }.onEach { _destNode.value = it }.launchIn(viewModelScope) + }.onEach { _destNode.value = it?.toNodeInfo() }.launchIn(viewModelScope) radioConfigRepository.deviceProfileFlow.onEach { _currentDeviceProfile.value = it diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 6e4b79a7f..8b4cd4337 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -23,6 +23,7 @@ import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.QuickChatActionRepository import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.database.entity.QuickChatAction +import com.geeksville.mesh.database.entity.toNodeInfo import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.service.MeshService @@ -230,7 +231,7 @@ class UIViewModel @Inject constructor( @OptIn(ExperimentalCoroutinesApi::class) val nodeList: StateFlow> = nodesUiState.flatMapLatest { state -> nodeDB.getNodes(state.sort, state.filter, state.includeUnknown) - }.stateIn( + }.mapLatest { list -> list.map { it.toNodeInfo() } }.stateIn( scope = viewModelScope, started = Eagerly, initialValue = emptyList(), @@ -239,14 +240,16 @@ class UIViewModel @Inject constructor( // hardware info about our local device (can be null) val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo - val nodesByNum get() = nodeDB.nodeDBbyNum.value // FIXME only used in MapFragment - fun getUser(userId: String?) = nodeDB.getUser(userId) ?: MeshUser( - userId ?: DataPacket.ID_LOCAL, - app.getString(R.string.unknown_username), - app.getString(R.string.unknown_node_short_name), - MeshProtos.HardwareModel.UNSET, - ) + // FIXME only used in MapFragment + val initialNodes get() = nodeDB.nodeDBbyNum.value.values.map { it.toNodeInfo() } + + fun getUser(userId: String?) = nodeDB.getUser(userId) ?: user { + id = userId.orEmpty() + longName = app.getString(R.string.unknown_username) + shortName = app.getString(R.string.unknown_node_short_name) + hwModel = MeshProtos.HardwareModel.UNSET + } private val _snackbarText = MutableLiveData(null) val snackbarText: LiveData get() = _snackbarText @@ -330,7 +333,7 @@ class UIViewModel @Inject constructor( Message( uuid = it.uuid, receivedTime = it.received_time, - user = getUser(it.data.from), + user = MeshUser(getUser(it.data.from)), // FIXME convert to proto User text = it.data.text.orEmpty(), time = it.data.time, read = it.read, diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt index 80a4553aa..567bac3e5 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt @@ -12,6 +12,7 @@ import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig import com.geeksville.mesh.MyNodeInfo import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.deviceProfile import com.geeksville.mesh.model.NodeDB import com.geeksville.mesh.model.getChannelUrl @@ -54,10 +55,10 @@ class RadioConfigRepository @Inject constructor( /** * Flow representing the [NodeInfo] database. */ - val nodeDBbyNum: StateFlow> get() = nodeDB.nodeDBbyNum + val nodeDBbyNum: StateFlow> get() = nodeDB.nodeDBbyNum - suspend fun upsert(node: NodeInfo) = nodeDB.upsert(node) - suspend fun installNodeDB(mi: MyNodeInfo, nodes: List) { + suspend fun upsert(node: NodeEntity) = nodeDB.upsert(node) + suspend fun installNodeDB(mi: MyNodeInfo, nodes: List) { nodeDB.installNodeDB(mi, nodes) } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 059bcf213..785fd44a8 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -23,7 +23,9 @@ import com.geeksville.mesh.android.hasLocationPermission import com.geeksville.mesh.database.MeshLogRepository import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.entity.MeshLog +import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.database.entity.Packet +import com.geeksville.mesh.database.entity.toNodeInfo import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.location.LocationRepository @@ -347,7 +349,7 @@ class MeshService : Service(), Logging { // The database of active nodes, index is the node user ID string // NOTE: some NodeInfos might be in only nodeDBbyNodeNum (because we don't yet know an ID). - private val nodeDBbyID get() = nodeDBbyNodeNum.mapKeys { it.value.user?.id } + private val nodeDBbyID get() = nodeDBbyNodeNum.mapKeys { it.value.user.id } /// /// END OF MODEL @@ -368,22 +370,23 @@ class MeshService : Service(), Logging { if (n == DataPacket.NODENUM_BROADCAST) DataPacket.ID_BROADCAST else nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n) - private fun defaultUser(num: Int) = MeshUser( - id = DataPacket.nodeNumToDefaultId(num), - longName = getString(R.string.unknown_username), - shortName = getString(R.string.unknown_node_short_name), - hwModel = MeshProtos.HardwareModel.UNSET, - ) + private fun defaultUser(num: Int) = user { + val userId = DataPacket.nodeNumToDefaultId(num) + id = userId + longName = "Meshtastic ${userId.takeLast(n = 4)}" + shortName = userId.takeLast(n = 4) + hwModel = MeshProtos.HardwareModel.UNSET + } // given a nodeNum, return a db entry - creating if necessary - private fun getOrCreateNodeInfo(n: Int) = nodeDBbyNodeNum[n] ?: NodeInfo(n, defaultUser(n)) + private fun getOrCreateNodeInfo(n: Int) = nodeDBbyNodeNum[n] ?: NodeEntity(n, defaultUser(n)) private val hexIdRegex = """\!([0-9A-Fa-f]+)""".toRegex() private val rangeTestRegex = Regex("seq (\\d{1,10})") /// Map a userid to a node/ node num, or throw an exception if not found /// We prefer to find nodes based on their assigned IDs, but if no ID has been assigned to a node, we can also find it based on node number - private fun toNodeInfo(id: String): NodeInfo { + private fun toNodeInfo(id: String): NodeEntity { // If this is a valid hexaddr will be !null val hexStr = hexIdRegex.matchEntire(id)?.groups?.get(1)?.value @@ -421,22 +424,19 @@ class MeshService : Service(), Logging { private inline fun updateNodeInfo( nodeNum: Int, withBroadcast: Boolean = true, - crossinline updateFn: (NodeInfo) -> Unit, + crossinline updateFn: (NodeEntity) -> Unit, ) { val info = getOrCreateNodeInfo(nodeNum) updateFn(info) - // This might have been the first time we know an ID for this node, so also update the by ID map - val userId = info.user?.id.orEmpty() - if (userId.isNotEmpty()) { + if (info.user.id.isNotEmpty()) { if (haveNodeDB) serviceScope.handledLaunch { radioConfigRepository.upsert(info) } } - // parcelable is busted if (withBroadcast) - serviceBroadcasts.broadcastNodeChange(info) + serviceBroadcasts.broadcastNodeChange(info.toNodeInfo()) } /// My node num @@ -488,6 +488,12 @@ class MeshService : Service(), Logging { decoded = MeshProtos.Data.newBuilder().also { initFn(it) }.build() + if (decoded.portnum in setOf(Portnums.PortNum.TEXT_MESSAGE_APP, Portnums.PortNum.ADMIN_APP)) { + nodeDBbyNodeNum[to]?.user?.publicKey?.let { publicKey -> + pkiEncrypted = !publicKey.isEmpty + this.publicKey = publicKey + } + } return build() } @@ -642,7 +648,8 @@ class MeshService : Service(), Logging { // Handle new telemetry info Portnums.PortNum.TELEMETRY_APP_VALUE -> { val u = TelemetryProtos.Telemetry.parseFrom(data.payload) - handleReceivedTelemetry(packet.from, u, dataPacket.time) + .copy { if (time == 0) time = (dataPacket.time / 1000L).toInt() } + handleReceivedTelemetry(packet.from, u) } Portnums.PortNum.ROUTING_APP_VALUE -> { @@ -665,6 +672,12 @@ class MeshService : Service(), Logging { shouldBroadcast = false } + Portnums.PortNum.PAXCOUNTER_APP_VALUE -> { + val p = PaxcountProtos.Paxcount.parseFrom(data.payload) + handleReceivedPaxcounter(packet.from, p) + shouldBroadcast = false + } + Portnums.PortNum.STORE_FORWARD_APP_VALUE -> { val u = StoreAndForwardProtos.StoreAndForward.parseFrom(data.payload) handleReceivedStoreAndForward(dataPacket, u) @@ -742,7 +755,9 @@ class MeshService : Service(), Logging { /// Update our DB of users based on someone sending out a User subpacket private fun handleReceivedUser(fromNum: Int, p: MeshProtos.User, channel: Int = 0) { updateNodeInfo(fromNum) { - it.user = MeshUser(p) + it.user = p + it.longName = p.longName + it.shortName = p.shortName it.channel = channel } } @@ -762,8 +777,8 @@ class MeshService : Service(), Logging { debug("Ignoring nop position update for the local node") } else { updateNodeInfo(fromNum) { - debug("update position: ${it.user?.longName?.toPIIString()} with ${p.toPIIString()}") - it.position = Position(p, (defaultTime / 1000L).toInt()) + debug("update position: ${it.longName?.toPIIString()} with ${p.toPIIString()}") + it.setPosition(p, (defaultTime / 1000L).toInt()) } } } @@ -772,18 +787,20 @@ class MeshService : Service(), Logging { private fun handleReceivedTelemetry( fromNum: Int, t: TelemetryProtos.Telemetry, - defaultTime: Long = System.currentTimeMillis() ) { updateNodeInfo(fromNum) { - if (t.hasDeviceMetrics()) it.deviceMetrics = DeviceMetrics( - t.deviceMetrics, if (t.time != 0) t.time else (defaultTime / 1000L).toInt() - ) - if (t.hasEnvironmentMetrics()) it.environmentMetrics = EnvironmentMetrics( - t.environmentMetrics, if (t.time != 0) t.time else (defaultTime / 1000L).toInt() - ) + when { + t.hasDeviceMetrics() -> it.deviceTelemetry = t + t.hasEnvironmentMetrics() -> it.environmentTelemetry = t + t.hasPowerMetrics() -> it.powerTelemetry = t + } } } + private fun handleReceivedPaxcounter(fromNum: Int, p: PaxcountProtos.Paxcount) { + updateNodeInfo(fromNum) { it.paxcounter = p } + } + private fun handleReceivedStoreAndForward( dataPacket: DataPacket, s: StoreAndForwardProtos.StoreAndForward, @@ -1311,27 +1328,21 @@ class MeshService : Service(), Logging { radioConfigRepository.setStatusMessage("Channels (${ch.index + 1} / $maxChannels)") } - private fun MeshProtos.NodeInfo.toEntity() = NodeInfo( + private fun MeshProtos.NodeInfo.toEntity() = NodeEntity( num = num, - user = if (hasUser()) { - MeshUser(user.copy { if (viaMqtt) longName = "$longName (MQTT)" }) - } else { - defaultUser(num) - }, - position = if (hasPosition()) { - Position(position) - } else { - null - }, - snr = snr, + user = if (hasUser()) user else defaultUser(num) + .copy { if (viaMqtt) longName = "$longName (MQTT)" }, + longName = user.longName, + shortName = user.shortName.takeIf { hasUser() }, + position = position, + latitude = position.latitudeI * 1e-7, + longitude = position.longitudeI * 1e-7, lastHeard = lastHeard, - deviceMetrics = if(hasDeviceMetrics()) { - DeviceMetrics(deviceMetrics) - } else { - null - }, + deviceTelemetry = telemetry { deviceMetrics = deviceMetrics }, channel = channel, + viaMqtt = viaMqtt, hopsAway = hopsAway, + isFavorite = isFavorite, ) private fun handleNodeInfo(info: MeshProtos.NodeInfo) { @@ -1589,7 +1600,7 @@ class MeshService : Service(), Logging { private fun setOwner(packetId: Int, user: MeshProtos.User) = with(user) { val dest = nodeDBbyID[id] ?: throw Exception("Can't set user without a NodeInfo") // this shouldn't happen - val old = dest.user!! + val old = dest.user if (longName == old.longName && shortName == old.shortName && isLicensed == old.isLicensed) { debug("Ignoring nop owner change") } else { @@ -1665,7 +1676,12 @@ class MeshService : Service(), Logging { override fun getPacketId() = toRemoteExceptions { generatePacketId() } override fun setOwner(user: MeshUser) = toRemoteExceptions { - setOwner(generatePacketId(), user.toProto()) + setOwner(generatePacketId(), user { + id = user.id + longName = user.longName + shortName = user.shortName + isLicensed = user.isLicensed + }) } override fun setRemoteOwner(id: Int, payload: ByteArray) = toRemoteExceptions { @@ -1813,7 +1829,7 @@ class MeshService : Service(), Logging { } override fun getNodes(): MutableList = toRemoteExceptions { - val r = nodeDBbyNodeNum.values.toMutableList() + val r = nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList() info("in getOnline, count=${r.size}") // return arrayOf("+16508675309") r diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt index c45964531..56c5968a9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt @@ -467,7 +467,7 @@ fun MapView( } fun MapView.zoomToNodes() { - val nodeMarkers = onNodesChanged(model.nodesByNum.values) + val nodeMarkers = onNodesChanged(model.initialNodes) if (nodeMarkers.isNotEmpty()) { val box = BoundingBox.fromGeoPoints(nodeMarkers.map { it.position }) val center = GeoPoint(box.centerLatitude, box.centerLongitude)