feat(wire): migrate from protobuf -> wire (#4401)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-03 18:01:12 -06:00 committed by GitHub
parent 9dbc8b7fbf
commit 25657e8f8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
239 changed files with 7149 additions and 6144 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 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
@ -14,16 +14,15 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@ -37,9 +36,9 @@ import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.copy
import org.meshtastic.proto.user
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
@RunWith(AndroidJUnit4::class)
class NodeInfoDaoTest {
@ -54,12 +53,12 @@ class NodeInfoDaoTest {
NodeEntity(
num = 7,
user =
user {
id = "!a1b2c3d4"
longName = "Meshtastic c3d4"
shortName = "c3d4"
hwModel = MeshProtos.HardwareModel.UNSET
},
User(
id = "!a1b2c3d4",
long_name = "Meshtastic c3d4",
short_name = "c3d4",
hw_model = HardwareModel.UNSET,
),
longName = "Meshtastic c3d4",
shortName = null, // Dao filter for includeUnknown
)
@ -68,13 +67,13 @@ class NodeInfoDaoTest {
NodeEntity(
num = 8,
user =
user {
id = "+16508765308".format(8)
longName = "Kevin Mester"
shortName = "KLO"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
isLicensed = false
},
User(
id = "+16508765308".format(8),
long_name = "Kevin Mester",
short_name = "KLO",
hw_model = HardwareModel.ANDROID_SIM,
is_licensed = false,
),
longName = "Kevin Mester",
shortName = "KLO",
latitude = 30.267153,
@ -86,12 +85,12 @@ class NodeInfoDaoTest {
NodeEntity(
num = 9,
user =
user {
id = "!25060801"
longName = "Meshtastic 0801"
shortName = "0801"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
User(
id = "!25060801",
long_name = "Meshtastic 0801",
short_name = "0801",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0801",
shortName = "0801",
hopsAway = 0,
@ -102,12 +101,12 @@ class NodeInfoDaoTest {
NodeEntity(
num = 10,
user =
user {
id = "!25060802"
longName = "Meshtastic 0802"
shortName = "0802"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
User(
id = "!25060802",
long_name = "Meshtastic 0802",
short_name = "0802",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0802",
shortName = "0802",
hopsAway = 0,
@ -118,12 +117,12 @@ class NodeInfoDaoTest {
NodeEntity(
num = 11,
user =
user {
id = "!25060803"
longName = "Meshtastic 0803"
shortName = "0803"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
User(
id = "!25060803",
long_name = "Meshtastic 0803",
short_name = "0803",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0803",
shortName = "0803",
hopsAway = 0,
@ -134,12 +133,12 @@ class NodeInfoDaoTest {
NodeEntity(
num = 12,
user =
user {
id = "!25060804"
longName = "Meshtastic 0804"
shortName = "0804"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
User(
id = "!25060804",
long_name = "Meshtastic 0804",
short_name = "0804",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0804",
shortName = "0804",
hopsAway = 3,
@ -179,14 +178,14 @@ class NodeInfoDaoTest {
NodeEntity(
num = 1000 + index,
user =
user {
id = "+165087653%02d".format(9 + index)
longName = "Kevin Mester$index"
shortName = "KM$index"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
isLicensed = false
publicKey = ByteString.copyFrom(ByteArray(32) { index.toByte() })
},
User(
id = "+165087653%02d".format(9 + index),
long_name = "Kevin Mester$index",
short_name = "KM$index",
hw_model = HardwareModel.ANDROID_SIM,
is_licensed = false,
public_key = ByteArray(32) { index.toByte() }.toByteString(),
),
longName = "Kevin Mester$index",
shortName = "KM$index",
latitude = pos.first,
@ -256,14 +255,14 @@ class NodeInfoDaoTest {
@Test
fun testSortByAlpha() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.ALPHABETICAL)
val sortedNodes = nodes.sortedBy { it.user.longName.uppercase() }
val sortedNodes = nodes.sortedBy { it.user.long_name?.uppercase() ?: "" }
assertEquals(sortedNodes, nodes)
}
@Test
fun testSortByDistance() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.DISTANCE)
fun NodeEntity.toNode() = Node(num = num, user = user, position = position)
fun NodeEntity.toNode() = Node(num = num, user = user, position = position ?: Position())
val sortedNodes =
nodes.sortedWith( // nodes with invalid (null) positions at the end
compareBy<Node> { it.validPosition == null }.thenBy { it.distance(ourNode.toNode()) },
@ -281,7 +280,7 @@ class NodeInfoDaoTest {
@Test
fun testSortByViaMqtt() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.VIA_MQTT)
val sortedNodes = nodes.sortedBy { it.user.longName.contains("(MQTT)") }
val sortedNodes = nodes.sortedBy { it.user.long_name?.contains("(MQTT)") == true }
assertEquals(sortedNodes, nodes)
}
@ -339,8 +338,7 @@ class NodeInfoDaoTest {
@Test
fun testPkcMismatch() = runBlocking {
val newNode =
testNodes[1].copy(user = testNodes[1].user.copy { publicKey = ByteString.copyFrom(ByteArray(32) { 99 }) })
val newNode = testNodes[1].copy(user = testNodes[1].user.copy(public_key = ByteArray(32) { 99 }.toByteString()))
nodeInfoDao.putAll(listOf(newNode))
val nodes = getNodes()
val containsMismatchNode = nodes.any { it.mismatchKey }

View file

@ -21,6 +21,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@ -36,7 +37,7 @@ import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.PortNum
@RunWith(AndroidJUnit4::class)
class PacketDaoTest {
@ -68,11 +69,11 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis(),
read = false,
DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"),
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"),
)
}
}
@ -104,7 +105,7 @@ class PacketDaoTest {
@Test
fun test_getAllPackets() = runBlocking {
val packets = packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first()
val packets = packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first()
assertEquals(testContactKeys.size * SAMPLE_SIZE, packets.size)
val onlyMyNodeNum = packets.all { it.myNodeNum == myNodeNum }
@ -177,7 +178,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
read = true,
@ -195,58 +196,58 @@ class PacketDaoTest {
@Test
fun test_sfppHashPersistence() = runBlocking {
val hash = byteArrayOf(1, 2, 3, 4)
val hashByteString = hash.toByteString()
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"),
sfpp_hash = hash,
sfpp_hash = hashByteString,
)
packetDao.insert(packet)
val retrieved =
packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first().find {
it.sfpp_hash?.contentEquals(hash) == true
}
packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first().find { it.sfpp_hash == hashByteString }
assertNotNull(retrieved)
assertTrue(retrieved?.sfpp_hash?.contentEquals(hash) == true)
assertEquals(hashByteString, retrieved?.sfpp_hash)
}
@Test
fun test_findPacketBySfppHash() = runBlocking {
val hash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
val hashByteString = hash.toByteString()
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"),
sfpp_hash = hash,
sfpp_hash = hashByteString,
)
packetDao.insert(packet)
// Exact match
val found = packetDao.findPacketBySfppHash(hash)
val found = packetDao.findPacketBySfppHash(hashByteString)
assertNotNull(found)
assertTrue(found?.sfpp_hash?.contentEquals(hash) == true)
assertEquals(hashByteString, found?.sfpp_hash)
// Substring match (first 8 bytes)
val shortHash = hash.copyOf(8)
val shortHash = hash.copyOf(8).toByteString()
val foundShort = packetDao.findPacketBySfppHash(shortHash)
assertNotNull(foundShort)
assertTrue(foundShort?.sfpp_hash?.contentEquals(hash) == true)
assertEquals(hashByteString, foundShort?.sfpp_hash)
// No match
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0)
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0).toByteString()
val notFound = packetDao.findPacketBySfppHash(wrongHash)
assertNull(notFound)
}
@ -254,6 +255,7 @@ class PacketDaoTest {
@Test
fun test_findReactionBySfppHash() = runBlocking {
val hash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
val hashByteString = hash.toByteString()
val reaction =
ReactionEntity(
myNodeNum = myNodeNum,
@ -261,20 +263,20 @@ class PacketDaoTest {
userId = "sender",
emoji = "👍",
timestamp = System.currentTimeMillis(),
sfpp_hash = hash,
sfpp_hash = hashByteString,
)
packetDao.insert(reaction)
val found = packetDao.findReactionBySfppHash(hash)
val found = packetDao.findReactionBySfppHash(hashByteString)
assertNotNull(found)
assertTrue(found?.sfpp_hash?.contentEquals(hash) == true)
assertEquals(hashByteString, found?.sfpp_hash)
val shortHash = hash.copyOf(8)
val shortHash = hash.copyOf(8).toByteString()
val foundShort = packetDao.findReactionBySfppHash(shortHash)
assertNotNull(foundShort)
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0)
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0).toByteString()
assertNull(packetDao.findReactionBySfppHash(wrongHash))
}
@ -286,7 +288,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
read = true,
@ -309,7 +311,7 @@ class PacketDaoTest {
val packetId = 999
val fromNum = 123
val toNum = 456
val hash = byteArrayOf(9, 8, 7, 6)
val hash = byteArrayOf(9, 8, 7, 6).toByteString()
val fromId = DataPacket.nodeNumToDefaultId(fromNum)
val toId = DataPacket.nodeNumToDefaultId(toNum)
@ -318,7 +320,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
read = true,
@ -339,8 +341,8 @@ class PacketDaoTest {
val updated = packetDao.findPacketsWithId(packetId)[0]
assertEquals(MessageStatus.SFPP_CONFIRMED, updated.data.status)
assertTrue(updated.data.sfppHash?.contentEquals(hash) == true)
assertTrue(updated.sfpp_hash?.contentEquals(hash) == true)
assertEquals(hash, updated.data.sfppHash)
assertEquals(hash, updated.sfpp_hash)
}
@Test
@ -352,7 +354,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = filteredContactKey,
received_time = System.currentTimeMillis(),
read = false,
@ -376,7 +378,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis() + i,
read = false,
@ -424,7 +426,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis() + index,
read = false,
@ -439,7 +441,7 @@ class PacketDaoTest {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis() + normalMessages.size + index,
read = true, // Filtered messages are marked as read

View file

@ -18,14 +18,18 @@ package org.meshtastic.core.database
import androidx.room.TypeConverter
import co.touchlab.kermit.Logger
import com.google.protobuf.ByteString
import com.google.protobuf.InvalidProtocolBufferException
import kotlinx.serialization.json.Json
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Position
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
@Suppress("TooManyFunctions")
class Converters {
@ -40,64 +44,34 @@ class Converters {
@TypeConverter fun dataToString(value: DataPacket): String = json.encodeToString(DataPacket.serializer(), value)
@TypeConverter
fun bytesToFromRadio(bytes: ByteArray): MeshProtos.FromRadio = try {
MeshProtos.FromRadio.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToFromRadio TypeConverter error" }
MeshProtos.FromRadio.getDefaultInstance()
}
fun bytesToFromRadio(bytes: ByteArray): FromRadio = FromRadio.ADAPTER.decodeOrNull(bytes, Logger) ?: FromRadio()
@TypeConverter fun fromRadioToBytes(value: MeshProtos.FromRadio): ByteArray? = value.toByteArray()
@TypeConverter fun fromRadioToBytes(value: FromRadio): ByteArray = FromRadio.ADAPTER.encode(value)
@TypeConverter fun bytesToUser(bytes: ByteArray): User = User.ADAPTER.decodeOrNull(bytes, Logger) ?: User()
@TypeConverter fun userToBytes(value: User): ByteArray = User.ADAPTER.encode(value)
@TypeConverter
fun bytesToUser(bytes: ByteArray): MeshProtos.User = try {
MeshProtos.User.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToUser TypeConverter error" }
MeshProtos.User.getDefaultInstance()
}
fun bytesToPosition(bytes: ByteArray): Position = Position.ADAPTER.decodeOrNull(bytes, Logger) ?: Position()
@TypeConverter fun userToBytes(value: MeshProtos.User): ByteArray? = value.toByteArray()
@TypeConverter fun positionToBytes(value: Position): ByteArray = Position.ADAPTER.encode(value)
@TypeConverter
fun bytesToPosition(bytes: ByteArray): MeshProtos.Position = try {
MeshProtos.Position.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToPosition TypeConverter error" }
MeshProtos.Position.getDefaultInstance()
}
fun bytesToTelemetry(bytes: ByteArray): Telemetry = Telemetry.ADAPTER.decodeOrNull(bytes, Logger) ?: Telemetry()
@TypeConverter fun positionToBytes(value: MeshProtos.Position): ByteArray? = value.toByteArray()
@TypeConverter fun telemetryToBytes(value: Telemetry): ByteArray = Telemetry.ADAPTER.encode(value)
@TypeConverter
fun bytesToTelemetry(bytes: ByteArray): TelemetryProtos.Telemetry = try {
TelemetryProtos.Telemetry.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToTelemetry TypeConverter error" }
TelemetryProtos.Telemetry.newBuilder().build() // Return an empty Telemetry object
}
fun bytesToPaxcounter(bytes: ByteArray): Paxcount = Paxcount.ADAPTER.decodeOrNull(bytes, Logger) ?: Paxcount()
@TypeConverter fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? = value.toByteArray()
@TypeConverter fun paxCounterToBytes(value: Paxcount): ByteArray = Paxcount.ADAPTER.encode(value)
@TypeConverter
fun bytesToPaxcounter(bytes: ByteArray): PaxcountProtos.Paxcount = try {
PaxcountProtos.Paxcount.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToPaxcounter TypeConverter error" }
PaxcountProtos.Paxcount.getDefaultInstance()
}
fun bytesToMetadata(bytes: ByteArray): DeviceMetadata =
DeviceMetadata.ADAPTER.decodeOrNull(bytes, Logger) ?: DeviceMetadata()
@TypeConverter fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToMetadata(bytes: ByteArray): MeshProtos.DeviceMetadata = try {
MeshProtos.DeviceMetadata.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
Logger.e(ex) { "bytesToMetadata TypeConverter error" }
MeshProtos.DeviceMetadata.getDefaultInstance()
}
@TypeConverter fun metadataToBytes(value: MeshProtos.DeviceMetadata): ByteArray? = value.toByteArray()
@TypeConverter fun metadataToBytes(value: DeviceMetadata): ByteArray = DeviceMetadata.ADAPTER.encode(value)
@TypeConverter
fun fromStringList(value: String?): List<String>? {
@ -115,8 +89,7 @@ class Converters {
return Json.encodeToString(list)
}
@TypeConverter
fun bytesToByteString(bytes: ByteArray?): ByteString? = if (bytes == null) null else ByteString.copyFrom(bytes)
@TypeConverter fun bytesToByteString(bytes: ByteArray?): ByteString? = bytes?.toByteString()
@TypeConverter fun byteStringToBytes(value: ByteString?): ByteArray? = value?.toByteArray()

View file

@ -23,13 +23,13 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.Flow
import okio.ByteString
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.entity.NodeWithRelations
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.HardwareModel
@Suppress("TooManyFunctions")
@Dao
@ -47,13 +47,13 @@ interface NodeInfoDao {
private suspend fun getVerifiedNodeForUpsert(incomingNode: NodeEntity): NodeEntity {
// Populate the NodeEntity.publicKey field from the User.publicKey for consistency
// and to support lazy migration.
incomingNode.publicKey = incomingNode.user.publicKey
incomingNode.publicKey = incomingNode.user.public_key
// Populate denormalized name columns from the User protobuf for search functionality
// Only populate if the user is not a placeholder (hwModel != UNSET); otherwise keep them null
if (incomingNode.user.hwModel != MeshProtos.HardwareModel.UNSET) {
incomingNode.longName = incomingNode.user.longName
incomingNode.shortName = incomingNode.user.shortName
if (incomingNode.user.hw_model != HardwareModel.UNSET) {
incomingNode.longName = incomingNode.user.long_name
incomingNode.shortName = incomingNode.user.short_name
} else {
incomingNode.longName = null
incomingNode.shortName = null
@ -72,7 +72,7 @@ interface NodeInfoDao {
private suspend fun handleNewNodeUpsertValidation(newNode: NodeEntity): NodeEntity {
// Check if the new node's public key (if present and not empty)
// is already claimed by another existing node.
if (newNode.publicKey?.isEmpty == false) {
if ((newNode.publicKey?.size ?: 0) > 0) {
val nodeWithSamePK = findNodeByPublicKey(newNode.publicKey)
if (nodeWithSamePK != null && nodeWithSamePK.num != newNode.num) {
// This is a potential impersonation attempt.
@ -85,9 +85,9 @@ interface NodeInfoDao {
}
private fun handleExistingNodeUpsertValidation(existingNode: NodeEntity, incomingNode: NodeEntity): NodeEntity {
val isPlaceholder = incomingNode.user.hwModel == MeshProtos.HardwareModel.UNSET
val hasExistingUser = existingNode.user.hwModel != MeshProtos.HardwareModel.UNSET
val isDefaultName = incomingNode.user.longName.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
val isPlaceholder = incomingNode.user.hw_model == HardwareModel.UNSET
val hasExistingUser = existingNode.user.hw_model != HardwareModel.UNSET
val isDefaultName = incomingNode.user.long_name?.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) == true
val shouldPreserve = hasExistingUser && isPlaceholder && isDefaultName
@ -115,7 +115,7 @@ interface NodeInfoDao {
// A public key is considered matching if the incoming key equals the existing key,
// OR if the existing key is empty (allowing a new key to be set or an update to proceed).
val existingResolvedKey = existingNode.publicKey ?: existingNode.user.publicKey
val existingResolvedKey = existingNode.publicKey ?: existingNode.user.public_key
val isPublicKeyMatchingOrExistingIsEmpty = existingResolvedKey == incomingNode.publicKey || !existingNode.hasPKC
val resolvedNotes = if (incomingNode.notes.isBlank()) existingNode.notes else incomingNode.notes
@ -129,7 +129,7 @@ interface NodeInfoDao {
// We allow the name and user info to update, but we clear the public key
// to indicate that this node is no longer "verified" against the previous key.
incomingNode.copy(
user = incomingNode.user.toBuilder().setPublicKey(NodeEntity.ERROR_BYTE_STRING).build(),
user = incomingNode.user.copy(public_key = NodeEntity.ERROR_BYTE_STRING),
publicKey = NodeEntity.ERROR_BYTE_STRING,
notes = resolvedNotes,
)
@ -289,10 +289,9 @@ interface NodeInfoDao {
nodes
.filter { node ->
// Only backfill if columns are NULL AND the user is not a placeholder (hwModel != UNSET)
(node.longName == null || node.shortName == null) &&
node.user.hwModel != MeshProtos.HardwareModel.UNSET
(node.longName == null || node.shortName == null) && node.user.hw_model != HardwareModel.UNSET
}
.map { node -> node.copy(longName = node.user.longName, shortName = node.user.shortName) }
.map { node -> node.copy(longName = node.user.long_name, shortName = node.user.short_name) }
if (nodesToUpdate.isNotEmpty()) {
putAll(nodesToUpdate)
}

View file

@ -24,13 +24,14 @@ import androidx.room.Transaction
import androidx.room.Update
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import okio.ByteString
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.PacketEntity
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.ChannelSettings
@Suppress("TooManyFunctions")
@Dao
@ -300,7 +301,7 @@ interface PacketDao {
AND substr(sfpp_hash, 1, 8) = substr(:hash, 1, 8)
""",
)
suspend fun findPacketBySfppHash(hash: ByteArray): Packet?
suspend fun findPacketBySfppHash(hash: ByteString): Packet?
@Transaction
suspend fun getQueuedPackets(): List<DataPacket>? = getDataPackets().filter { it.status == MessageStatus.QUEUED }
@ -386,7 +387,7 @@ interface PacketDao {
AND substr(sfpp_hash, 1, 8) = substr(:hash, 1, 8)
""",
)
suspend fun findReactionBySfppHash(hash: ByteArray): ReactionEntity?
suspend fun findReactionBySfppHash(hash: ByteString): ReactionEntity?
@Query(
"""

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 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
@ -14,18 +14,19 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.google.protobuf.TextFormat
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.FromRadio
import org.meshtastic.proto.Portnums
import java.io.IOException
import co.touchlab.kermit.Logger
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.Position
@Suppress("EmptyCatchBlock", "SwallowedException", "ConstructorParameterNaming")
@Entity(tableName = "log", indices = [Index(value = ["from_num"]), Index(value = ["port_num"])])
@ -37,52 +38,25 @@ data class MeshLog(
@ColumnInfo(name = "from_num", defaultValue = "0") val fromNum: Int = 0,
@ColumnInfo(name = "port_num", defaultValue = "0") val portNum: Int = 0,
@ColumnInfo(name = "from_radio", typeAffinity = ColumnInfo.BLOB, defaultValue = "x''")
val fromRadio: FromRadio = FromRadio.getDefaultInstance(),
val fromRadio: FromRadio = FromRadio(),
) {
val meshPacket: MeshProtos.MeshPacket?
get() {
if (message_type == "Packet") {
val builder = MeshProtos.MeshPacket.newBuilder()
try {
TextFormat.getParser().merge(raw_message, builder)
return builder.build()
} catch (e: IOException) {}
}
return null
}
val meshPacket: MeshPacket?
get() = fromRadio.packet
val nodeInfo: MeshProtos.NodeInfo?
get() {
if (message_type == "NodeInfo") {
val builder = MeshProtos.NodeInfo.newBuilder()
try {
TextFormat.getParser().merge(raw_message, builder)
return builder.build()
} catch (e: IOException) {}
}
return null
}
val nodeInfo: NodeInfo?
get() = fromRadio.node_info
val myNodeInfo: MeshProtos.MyNodeInfo?
get() {
if (message_type == "MyNodeInfo") {
val builder = MeshProtos.MyNodeInfo.newBuilder()
try {
TextFormat.getParser().merge(raw_message, builder)
return builder.build()
} catch (e: IOException) {}
}
return null
}
val myNodeInfo: MyNodeInfo?
get() = fromRadio.my_info
val position: MeshProtos.Position?
get() {
return meshPacket?.run {
if (hasDecoded() && decoded.portnumValue == Portnums.PortNum.POSITION_APP_VALUE) {
return MeshProtos.Position.parseFrom(decoded.payload)
val position: Position?
get() =
fromRadio.packet?.decoded?.payload?.let {
if (fromRadio.packet?.decoded?.portnum == org.meshtastic.proto.PortNum.POSITION_APP) {
Position.ADAPTER.decodeOrNull(it, Logger)
} else {
null
}
return null
} ?: nodeInfo?.position
}
}

View file

@ -22,8 +22,8 @@ import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import com.google.protobuf.ByteString
import com.google.protobuf.kotlin.isNotEmpty
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
@ -31,10 +31,12 @@ import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.copy
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Position as WirePosition
data class NodeWithRelations(
@Embedded val node: NodeEntity,
@ -50,15 +52,15 @@ data class NodeWithRelations(
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = deviceTelemetry.deviceMetrics,
deviceMetrics = deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(),
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(),
powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(),
paxcounter = paxcounter,
notes = notes,
manuallyVerified = manuallyVerified,
@ -94,7 +96,7 @@ data class NodeWithRelations(
@Entity(tableName = "metadata", indices = [Index(value = ["num"])])
data class MetadataEntity(
@PrimaryKey val num: Int,
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: MeshProtos.DeviceMetadata,
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: DeviceMetadata,
val timestamp: Long = System.currentTimeMillis(),
)
@ -113,18 +115,16 @@ data class MetadataEntity(
)
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(typeAffinity = ColumnInfo.BLOB) var user: User = User(),
@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(),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) var position: WirePosition = WirePosition(),
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(),
@ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB) var deviceTelemetry: Telemetry = Telemetry(),
var channel: Int = 0,
@ColumnInfo(name = "via_mqtt") var viaMqtt: Boolean = false,
@ColumnInfo(name = "hops_away") var hopsAway: Int = -1,
@ -132,33 +132,34 @@ data class NodeEntity(
@ColumnInfo(name = "is_ignored", defaultValue = "0") var isIgnored: Boolean = false,
@ColumnInfo(name = "is_muted", defaultValue = "0") var isMuted: Boolean = false,
@ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB)
var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.newBuilder().build(),
@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(),
var environmentTelemetry: Telemetry = Telemetry(),
@ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB) var powerTelemetry: Telemetry = Telemetry(),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) var paxcounter: Paxcount = Paxcount(),
@ColumnInfo(name = "public_key") var publicKey: ByteString? = null,
@ColumnInfo(name = "notes", defaultValue = "") var notes: String = "",
@ColumnInfo(name = "manually_verified", defaultValue = "0")
var manuallyVerified: Boolean = false, // ONLY set true when scanned/imported manually
@ColumnInfo(name = "node_status") var nodeStatus: String? = null,
) {
val deviceMetrics: TelemetryProtos.DeviceMetrics
get() = deviceTelemetry.deviceMetrics
val deviceMetrics: org.meshtastic.proto.DeviceMetrics?
get() = deviceTelemetry.device_metrics
val environmentMetrics: TelemetryProtos.EnvironmentMetrics
get() = environmentTelemetry.environmentMetrics
val environmentMetrics: org.meshtastic.proto.EnvironmentMetrics?
get() = environmentTelemetry.environment_metrics
val powerMetrics: org.meshtastic.proto.PowerMetrics?
get() = powerTelemetry.power_metrics
val isUnknownUser
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
get() = user.hw_model == HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.publicKey).isNotEmpty()
get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true
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)
fun setPosition(p: WirePosition, defaultTime: Int = currentTime()) {
position = p.copy(time = if (p.time != 0) p.time else defaultTime)
latitude = degD(p.latitude_i ?: 0)
longitude = degD(p.longitude_i ?: 0)
}
/** true if the device was heard from recently */
@ -173,7 +174,7 @@ data class NodeEntity(
fun degI(d: Double) = (d * 1e7).toInt()
val ERROR_BYTE_STRING: ByteString = ByteString.copyFrom(ByteArray(32) { 0 })
val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString()
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
}
@ -185,17 +186,17 @@ data class NodeEntity(
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = deviceTelemetry.deviceMetrics,
deviceMetrics = deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(),
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(),
powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(),
paxcounter = paxcounter,
publicKey = publicKey ?: user.publicKey,
publicKey = publicKey ?: user.public_key,
notes = notes,
nodeStatus = nodeStatus,
)
@ -205,22 +206,22 @@ data class NodeEntity(
user =
MeshUser(
id = user.id,
longName = user.longName,
shortName = user.shortName,
hwModel = user.hwModel,
role = user.roleValue,
longName = user.long_name ?: "",
shortName = user.short_name ?: "",
hwModel = user.hw_model,
role = user.role.value,
)
.takeIf { user.id.isNotEmpty() },
position =
Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.satsInView,
groundSpeed = position.groundSpeed,
groundTrack = position.groundTrack,
precisionBits = position.precisionBits,
satellitesInView = position.sats_in_view ?: 0,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits ?: 0,
)
.takeIf { it.isValid() },
snr = snr,
@ -229,16 +230,16 @@ data class NodeEntity(
deviceMetrics =
DeviceMetrics(
time = deviceTelemetry.time,
batteryLevel = deviceMetrics.batteryLevel,
voltage = deviceMetrics.voltage,
channelUtilization = deviceMetrics.channelUtilization,
airUtilTx = deviceMetrics.airUtilTx,
uptimeSeconds = deviceMetrics.uptimeSeconds,
batteryLevel = deviceMetrics?.battery_level ?: 0,
voltage = deviceMetrics?.voltage ?: 0f,
channelUtilization = deviceMetrics?.channel_utilization ?: 0f,
airUtilTx = deviceMetrics?.air_util_tx ?: 0f,
uptimeSeconds = deviceMetrics?.uptime_seconds ?: 0,
),
channel = channel,
environmentMetrics =
EnvironmentMetrics.fromTelemetryProto(
environmentTelemetry.environmentMetrics,
environmentTelemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics(),
environmentTelemetry.time,
),
hopsAway = hopsAway,

View file

@ -22,12 +22,13 @@ import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import okio.ByteString
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.getShortDateTime
import org.meshtastic.proto.MeshProtos.User
import org.meshtastic.proto.User
data class PacketEntity(
@Embedded val packet: Packet,
@ -87,7 +88,7 @@ data class Packet(
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
@ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteArray? = null,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
@ColumnInfo(name = "filtered", defaultValue = "0") val filtered: Boolean = false,
) {
companion object {
@ -144,7 +145,7 @@ data class Reaction(
val relayNode: Int? = null,
val to: String? = null,
val channel: Int = 0,
val sfppHash: ByteArray? = null,
val sfppHash: ByteString? = null,
)
@Suppress("ConstructorParameterNaming")
@ -170,7 +171,7 @@ data class ReactionEntity(
@ColumnInfo(name = "relay_node") val relayNode: Int? = null,
@ColumnInfo(name = "to") val to: String? = null,
@ColumnInfo(name = "channel", defaultValue = "0") val channel: Int = 0,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteArray? = null,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
)
private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node): Reaction {

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 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
@ -14,14 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Position
@Entity(
tableName = "traceroute_node_position",
@ -41,5 +40,5 @@ data class TracerouteNodePositionEntity(
@ColumnInfo(name = "log_uuid") val logUuid: String,
@ColumnInfo(name = "request_id") val requestId: Int,
@ColumnInfo(name = "node_num") val nodeNum: Int,
@ColumnInfo(name = "position", typeAffinity = ColumnInfo.BLOB) val position: MeshProtos.Position,
@ColumnInfo(name = "position", typeAffinity = ColumnInfo.BLOB) val position: Position,
)

View file

@ -46,28 +46,28 @@ import org.meshtastic.core.strings.routing_error_rate_limit_exceeded
import org.meshtastic.core.strings.routing_error_timeout
import org.meshtastic.core.strings.routing_error_too_large
import org.meshtastic.core.strings.unrecognized
import org.meshtastic.proto.MeshProtos.Routing
import org.meshtastic.proto.Routing
@Suppress("CyclomaticComplexMethod")
fun getStringResFrom(routingError: Int): StringResource = when (routingError) {
Routing.Error.NONE_VALUE -> Res.string.routing_error_none
Routing.Error.NO_ROUTE_VALUE -> Res.string.routing_error_no_route
Routing.Error.GOT_NAK_VALUE -> Res.string.routing_error_got_nak
Routing.Error.TIMEOUT_VALUE -> Res.string.routing_error_timeout
Routing.Error.NO_INTERFACE_VALUE -> Res.string.routing_error_no_interface
Routing.Error.MAX_RETRANSMIT_VALUE -> Res.string.routing_error_max_retransmit
Routing.Error.NO_CHANNEL_VALUE -> Res.string.routing_error_no_channel
Routing.Error.TOO_LARGE_VALUE -> Res.string.routing_error_too_large
Routing.Error.NO_RESPONSE_VALUE -> Res.string.routing_error_no_response
Routing.Error.DUTY_CYCLE_LIMIT_VALUE -> Res.string.routing_error_duty_cycle_limit
Routing.Error.BAD_REQUEST_VALUE -> Res.string.routing_error_bad_request
Routing.Error.NOT_AUTHORIZED_VALUE -> Res.string.routing_error_not_authorized
Routing.Error.PKI_FAILED_VALUE -> Res.string.routing_error_pki_failed
Routing.Error.PKI_UNKNOWN_PUBKEY_VALUE -> Res.string.routing_error_pki_unknown_pubkey
Routing.Error.ADMIN_BAD_SESSION_KEY_VALUE -> Res.string.routing_error_admin_bad_session_key
Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED_VALUE -> Res.string.routing_error_admin_public_key_unauthorized
Routing.Error.RATE_LIMIT_EXCEEDED_VALUE -> Res.string.routing_error_rate_limit_exceeded
Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY_VALUE -> Res.string.routing_error_pki_send_fail_public_key
Routing.Error.NONE.value -> Res.string.routing_error_none
Routing.Error.NO_ROUTE.value -> Res.string.routing_error_no_route
Routing.Error.GOT_NAK.value -> Res.string.routing_error_got_nak
Routing.Error.TIMEOUT.value -> Res.string.routing_error_timeout
Routing.Error.NO_INTERFACE.value -> Res.string.routing_error_no_interface
Routing.Error.MAX_RETRANSMIT.value -> Res.string.routing_error_max_retransmit
Routing.Error.NO_CHANNEL.value -> Res.string.routing_error_no_channel
Routing.Error.TOO_LARGE.value -> Res.string.routing_error_too_large
Routing.Error.NO_RESPONSE.value -> Res.string.routing_error_no_response
Routing.Error.DUTY_CYCLE_LIMIT.value -> Res.string.routing_error_duty_cycle_limit
Routing.Error.BAD_REQUEST.value -> Res.string.routing_error_bad_request
Routing.Error.NOT_AUTHORIZED.value -> Res.string.routing_error_not_authorized
Routing.Error.PKI_FAILED.value -> Res.string.routing_error_pki_failed
Routing.Error.PKI_UNKNOWN_PUBKEY.value -> Res.string.routing_error_pki_unknown_pubkey
Routing.Error.ADMIN_BAD_SESSION_KEY.value -> Res.string.routing_error_admin_bad_session_key
Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value -> Res.string.routing_error_admin_public_key_unauthorized
Routing.Error.RATE_LIMIT_EXCEEDED.value -> Res.string.routing_error_rate_limit_exceeded
Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value -> Res.string.routing_error_pki_send_fail_public_key
else -> Res.string.unrecognized
}

View file

@ -17,47 +17,48 @@
package org.meshtastic.core.database.model
import android.graphics.Color
import com.google.protobuf.ByteString
import com.google.protobuf.kotlin.isNotEmpty
import okio.ByteString
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.TelemetryProtos.DeviceMetrics
import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
import org.meshtastic.proto.TelemetryProtos.PowerMetrics
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Position
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.User
@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 metadata: DeviceMetadata? = null,
val user: User = User(),
val position: Position = Position(),
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 deviceMetrics: DeviceMetrics = DeviceMetrics(),
val channel: Int = 0,
val viaMqtt: Boolean = false,
val hopsAway: Int = -1,
val isFavorite: Boolean = false,
val isIgnored: Boolean = false,
val isMuted: Boolean = false,
val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics.getDefaultInstance(),
val powerMetrics: PowerMetrics = PowerMetrics.getDefaultInstance(),
val paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics(),
val powerMetrics: PowerMetrics = PowerMetrics(),
val paxcounter: Paxcount = Paxcount(),
val publicKey: ByteString? = null,
val notes: String = "",
val manuallyVerified: Boolean = false,
val nodeStatus: String? = null,
) {
val capabilities: Capabilities by lazy { Capabilities(metadata?.firmwareVersion) }
val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) }
val colors: Pair<Int, Int>
get() { // returns foreground and background @ColorInt for each 'num'
@ -69,41 +70,41 @@ data class Node(
}
val isUnknownUser
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
get() = user.hw_model == HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.publicKey).isNotEmpty()
get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true
val mismatchKey
get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING
get() = (publicKey ?: user.public_key) == NodeEntity.ERROR_BYTE_STRING
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance()
get() = environmentMetrics != EnvironmentMetrics()
val hasPowerMetrics: Boolean
get() = powerMetrics != PowerMetrics.getDefaultInstance()
get() = powerMetrics != PowerMetrics()
val batteryLevel
get() = deviceMetrics.batteryLevel
get() = deviceMetrics.battery_level
val voltage
get() = deviceMetrics.voltage
val batteryStr
get() = if (batteryLevel in 1..100) "$batteryLevel%" else ""
get() = if ((batteryLevel ?: 0) in 1..100) "$batteryLevel%" else ""
val latitude
get() = position.latitudeI * 1e-7
get() = (position.latitude_i ?: 0) * 1e-7
val longitude
get() = position.longitudeI * 1e-7
get() = (position.longitude_i ?: 0) * 1e-7
private fun hasValidPosition(): Boolean = latitude != 0.0 &&
longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
val validPosition: MeshProtos.Position?
val validPosition: Position?
get() = position.takeIf { hasValidPosition() }
// @return distance in meters to some other node (or null if unknown)
@ -113,7 +114,7 @@ data class Node(
}
// @return formatted distance string to another node, using the given display units
fun distanceStr(o: Node, displayUnits: DisplayConfig.DisplayUnits): String? =
fun distanceStr(o: Node, displayUnits: Config.DisplayConfig.DisplayUnits): String? =
distance(o)?.toDistanceString(displayUnits)
// @return bearing to the other position in degrees
@ -126,36 +127,36 @@ data class Node(
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List<String> {
val temp =
if (temperature != 0f) {
if ((temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(temperature))
"%.1f°F".format(celsiusToFahrenheit(temperature ?: 0f))
} else {
"%.1f°C".format(temperature)
}
} else {
null
}
val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null
val humidity = if ((relative_humidity ?: 0f) != 0f) "%.0f%%".format(relative_humidity) else null
val soilTemperatureStr =
if (soilTemperature != 0f) {
if ((soil_temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(soilTemperature))
"%.1f°F".format(celsiusToFahrenheit(soil_temperature ?: 0f))
} else {
"%.1f°C".format(soilTemperature)
"%.1f°C".format(soil_temperature)
}
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if (soilMoisture in soilMoistureRange && soilTemperature != 0f) {
"%d%%".format(soilMoisture)
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
"%d%%".format(soil_moisture)
} 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
val voltage = if ((this.voltage ?: 0f) != 0f) "%.2fV".format(this.voltage) else null
val current = if ((current ?: 0f) != 0f) "%.1fmA".format(current) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(
paxcounter.getDisplayString(),
@ -169,19 +170,19 @@ data class Node(
)
}
private fun PaxcountProtos.Paxcount.getDisplayString() =
"PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 || wifi != 0 }
private fun Paxcount.getDisplayString() =
"PAX: ${(ble ?: 0) + (wifi ?: 0)} (B:${ble ?: 0}/W:${wifi ?: 0})".takeIf { (ble ?: 0) != 0 || (wifi ?: 0) != 0 }
fun getTelemetryStrings(isFahrenheit: Boolean = false): List<String> =
environmentMetrics.getDisplayStrings(isFahrenheit)
}
fun ConfigProtos.Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
fun Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
listOf(
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
ConfigProtos.Config.DeviceConfig.Role.SENSOR,
ConfigProtos.Config.DeviceConfig.Role.TRACKER,
ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER,
Config.DeviceConfig.Role.REPEATER,
Config.DeviceConfig.Role.ROUTER,
Config.DeviceConfig.Role.ROUTER_LATE,
Config.DeviceConfig.Role.SENSOR,
Config.DeviceConfig.Role.TRACKER,
Config.DeviceConfig.Role.TAK_TRACKER,
)

View file

@ -19,9 +19,9 @@ package org.meshtastic.core.database.dao
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
@ -31,8 +31,8 @@ import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.channelSettings
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
@RunWith(AndroidJUnit4::class)
class MigrationTest {
@ -69,32 +69,20 @@ class MigrationTest {
@Test
fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking {
// PSK "AQ==" is base64 for single byte 0x01
val pskBytes = ByteString.copyFrom(byteArrayOf(0x01))
// PSK \"AQ==\" is base64 for single byte 0x01
val pskBytes = byteArrayOf(0x01).toByteString()
// Create packets for Channel 0
insertPacket(channel = 0, text = "Message Ch0")
// Old settings: Channel 0 has PSK_A
val oldSettings =
listOf(
channelSettings {
psk = pskBytes
name = "LongFast"
},
)
val oldSettings = listOf(ChannelSettings(psk = pskBytes, name = "LongFast"))
// New settings: Channel 0 has PSK_A, Channel 1 has PSK_A
val newSettings =
listOf(
channelSettings {
psk = pskBytes
name = "LongFast"
},
channelSettings {
psk = pskBytes
name = "NewChan"
},
ChannelSettings(psk = pskBytes, name = "LongFast"),
ChannelSettings(psk = pskBytes, name = "NewChan"),
)
// Perform migration
@ -107,35 +95,15 @@ class MigrationTest {
@Test
fun testMigrateChannelsByPSK_reorder() = runBlocking {
val pskA = ByteString.copyFrom(byteArrayOf(0x01))
val pskB = ByteString.copyFrom(byteArrayOf(0x02))
val pskA = byteArrayOf(0x01).toByteString()
val pskB = byteArrayOf(0x02).toByteString()
insertPacket(channel = 0, text = "Msg A")
insertPacket(channel = 1, text = "Msg B")
val oldSettings =
listOf(
channelSettings {
psk = pskA
name = "A"
},
channelSettings {
psk = pskB
name = "B"
},
)
val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A"), ChannelSettings(psk = pskB, name = "B"))
val newSettings =
listOf(
channelSettings {
psk = pskB
name = "B"
},
channelSettings {
psk = pskA
name = "A"
},
)
val newSettings = listOf(ChannelSettings(psk = pskB, name = "B"), ChannelSettings(psk = pskA, name = "A"))
packetDao.migrateChannelsByPSK(oldSettings, newSettings)
@ -146,35 +114,15 @@ class MigrationTest {
@Test
fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking {
val pskA = ByteString.copyFrom(byteArrayOf(0x01))
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A1")
insertPacket(channel = 1, text = "Msg A2")
val oldSettings =
listOf(
channelSettings {
psk = pskA
name = "A1"
},
channelSettings {
psk = pskA
name = "A2"
},
)
val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A1"), ChannelSettings(psk = pskA, name = "A2"))
// Swap positions but keep names and PSKs
val newSettings =
listOf(
channelSettings {
psk = pskA
name = "A2"
},
channelSettings {
psk = pskA
name = "A1"
},
)
val newSettings = listOf(ChannelSettings(psk = pskA, name = "A2"), ChannelSettings(psk = pskA, name = "A1"))
packetDao.migrateChannelsByPSK(oldSettings, newSettings)
@ -185,30 +133,14 @@ class MigrationTest {
@Test
fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking {
val pskA = ByteString.copyFrom(byteArrayOf(0x01))
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A")
val oldSettings =
listOf(
channelSettings {
psk = pskA
name = "A"
},
)
val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A"))
// New settings has two identical channels (same PSK, same Name)
val newSettings =
listOf(
channelSettings {
psk = pskA
name = "A"
},
channelSettings {
psk = pskA
name = "A"
},
)
val newSettings = listOf(ChannelSettings(psk = pskA, name = "A"), ChannelSettings(psk = pskA, name = "A"))
packetDao.migrateChannelsByPSK(oldSettings, newSettings)
@ -221,7 +153,7 @@ class MigrationTest {
Packet(
uuid = 0L,
myNodeNum = 42424242,
port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "$channel!broadcast",
received_time = System.currentTimeMillis(),
read = false,
@ -230,7 +162,7 @@ class MigrationTest {
)
}
private suspend fun getAllPackets() = packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first()
private suspend fun getAllPackets() = packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first()
private suspend fun getFirstPacket() = getAllPackets().first()
}