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

@ -27,10 +27,10 @@ import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import javax.inject.Inject
@Suppress("TooManyFunctions")
@ -55,74 +55,39 @@ constructor(
.conflate()
private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching {
Telemetry.parseFrom(log.fromRadio.packet.decoded.payload)
.toBuilder()
.apply {
if (hasEnvironmentMetrics()) {
// Handle float metrics that default to 0.0f when not explicitly set or when 0.0f means no
// data
if (!environmentMetrics.hasTemperature()) {
environmentMetrics = environmentMetrics.toBuilder().setTemperature(Float.NaN).build()
}
if (!environmentMetrics.hasRelativeHumidity()) {
environmentMetrics =
environmentMetrics.toBuilder().setRelativeHumidity(Float.NaN).build()
}
if (!environmentMetrics.hasSoilTemperature()) {
environmentMetrics =
environmentMetrics.toBuilder().setSoilTemperature(Float.NaN).build()
}
if (!environmentMetrics.hasBarometricPressure()) {
environmentMetrics =
environmentMetrics.toBuilder().setBarometricPressure(Float.NaN).build()
}
if (!environmentMetrics.hasGasResistance()) {
environmentMetrics = environmentMetrics.toBuilder().setGasResistance(Float.NaN).build()
}
if (!environmentMetrics.hasVoltage()) {
environmentMetrics = environmentMetrics.toBuilder().setVoltage(Float.NaN).build()
}
if (!environmentMetrics.hasCurrent()) {
environmentMetrics = environmentMetrics.toBuilder().setCurrent(Float.NaN).build()
}
if (!environmentMetrics.hasLux()) {
environmentMetrics = environmentMetrics.toBuilder().setLux(Float.NaN).build()
}
if (!environmentMetrics.hasUvLux()) {
environmentMetrics = environmentMetrics.toBuilder().setUvLux(Float.NaN).build()
}
// Handle uint32 metrics that default to 0 when not explicitly set or when 0 means no data
if (!environmentMetrics.hasIaq()) {
environmentMetrics = environmentMetrics.toBuilder().setIaq(Int.MIN_VALUE).build()
}
if (!environmentMetrics.hasSoilMoisture()) {
environmentMetrics =
environmentMetrics.toBuilder().setSoilMoisture(Int.MIN_VALUE).build()
}
}
// Leaving in case we have need of nulling any in device metrics.
// if (hasDeviceMetrics()) {
// deviceMetrics =
// deviceMetrics.toBuilder().setBatteryLevel(Int.MIN_VALUE).build()
// }
}
.setTime((log.received_date / MILLIS_TO_SECONDS).toInt())
.build()
val payload = log.fromRadio.packet?.decoded?.payload ?: return@runCatching null
val telemetry = Telemetry.ADAPTER.decode(payload)
telemetry.copy(
time = (log.received_date / MILLIS_TO_SECONDS).toInt(),
environment_metrics =
telemetry.environment_metrics?.let { metrics ->
metrics.copy(
temperature = metrics.temperature ?: Float.NaN,
relative_humidity = metrics.relative_humidity ?: Float.NaN,
soil_temperature = metrics.soil_temperature ?: Float.NaN,
barometric_pressure = metrics.barometric_pressure ?: Float.NaN,
gas_resistance = metrics.gas_resistance ?: Float.NaN,
voltage = metrics.voltage ?: Float.NaN,
current = metrics.current ?: Float.NaN,
lux = metrics.lux ?: Float.NaN,
uv_lux = metrics.uv_lux ?: Float.NaN,
iaq = metrics.iaq ?: Int.MIN_VALUE,
soil_moisture = metrics.soil_moisture ?: Int.MIN_VALUE,
)
},
)
}
.getOrNull()
fun getTelemetryFrom(nodeNum: Int): Flow<List<Telemetry>> = dbManager.currentDb
.flatMapLatest {
it.meshLogDao().getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS)
}
.flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, PortNum.TELEMETRY_APP.value, MAX_MESH_PACKETS) }
.distinctUntilChanged()
.mapLatest { list -> list.mapNotNull(::parseTelemetryLog) }
.flowOn(dispatchers.io)
fun getLogsFrom(
nodeNum: Int,
portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE,
portNum: Int = PortNum.UNKNOWN_APP.value,
maxItem: Int = MAX_MESH_PACKETS,
): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, maxItem) }
@ -133,10 +98,12 @@ constructor(
* Retrieves MeshPackets matching 'nodeNum' and 'portNum'.
* If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'.
*/
fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE): Flow<List<MeshPacket>> =
getLogsFrom(nodeNum, portNum).mapLatest { list -> list.map { it.fromRadio.packet } }.flowOn(dispatchers.io)
fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = PortNum.UNKNOWN_APP.value): Flow<List<MeshPacket>> =
getLogsFrom(nodeNum, portNum)
.mapLatest { list -> list.mapNotNull { it.fromRadio.packet } }
.flowOn(dispatchers.io)
fun getMyNodeInfo(): Flow<MeshProtos.MyNodeInfo?> = getLogsFrom(0, 0)
fun getMyNodeInfo(): Flow<MyNodeInfo?> = getLogsFrom(0, 0)
.mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo }
.flowOn(dispatchers.io)

View file

@ -44,7 +44,8 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.User
import javax.inject.Inject
import javax.inject.Singleton
@ -107,27 +108,25 @@ constructor(
fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId))
fun getUser(nodeNum: Int): MeshProtos.User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
fun getUser(userId: String): MeshProtos.User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
?: MeshProtos.User.newBuilder()
.setId(userId)
.setLongName(
if (userId == DataPacket.ID_LOCAL) {
ourNodeInfo.value?.user?.longName ?: "Local"
} else {
"Meshtastic ${userId.takeLast(n = 4)}"
},
)
.setShortName(
if (userId == DataPacket.ID_LOCAL) {
ourNodeInfo.value?.user?.shortName ?: "Local"
} else {
userId.takeLast(n = 4)
},
)
.setHwModel(MeshProtos.HardwareModel.UNSET)
.build()
fun getUser(userId: String): User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
?: User(
id = userId,
long_name =
if (userId == DataPacket.ID_LOCAL) {
ourNodeInfo.value?.user?.long_name ?: "Local"
} else {
"Meshtastic ${userId.takeLast(n = 4)}"
},
short_name =
if (userId == DataPacket.ID_LOCAL) {
ourNodeInfo.value?.user?.short_name ?: "Local"
} else {
userId.takeLast(n = 4)
},
hw_model = HardwareModel.UNSET,
)
fun getNodes(
sort: NodeSortOption = NodeSortOption.LAST_HEARD,

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.Packet
@ -34,8 +35,8 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.Portnums.PortNum
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
import javax.inject.Inject
class PacketRepository
@ -184,6 +185,8 @@ constructor(
DataPacket.nodeNumToDefaultId(to)
}
val hashByteString = hash.toByteString()
packets.forEach { packet ->
// For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches =
@ -199,8 +202,8 @@ constructor(
return@forEach
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time
val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime))
val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime))
}
}
@ -222,7 +225,8 @@ constructor(
return@forEach
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp
val updatedReaction = reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime)
val updatedReaction =
reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime)
dao.update(updatedReaction)
}
}
@ -234,22 +238,23 @@ constructor(
rxTime: Long = 0,
) = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
dao.findPacketBySfppHash(hash)?.let { packet ->
val hashByteString = hash.toByteString()
dao.findPacketBySfppHash(hashByteString)?.let { packet ->
// If it's already confirmed, don't downgrade it
if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@let
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time
val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime))
val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime))
}
dao.findReactionBySfppHash(hash)?.let { reaction ->
dao.findReactionBySfppHash(hashByteString)?.let { reaction ->
if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@let
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp
val updatedReaction = reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime)
val updatedReaction = reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime)
dao.update(updatedReaction)
}
}
@ -340,7 +345,7 @@ constructor(
}
private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow<List<Packet>> =
getAllPackets(PortNum.WAYPOINT_APP_VALUE)
getAllPackets(PortNum.WAYPOINT_APP.value)
companion object {
private const val CONTACTS_PAGE_SIZE = 30

View file

@ -22,15 +22,14 @@ import org.meshtastic.core.datastore.ChannelSetDataSource
import org.meshtastic.core.datastore.LocalConfigDataSource
import org.meshtastic.core.datastore.ModuleConfigDataSource
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.ChannelProtos.Channel
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig
import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig
import org.meshtastic.proto.deviceProfile
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import javax.inject.Inject
/**
@ -83,7 +82,7 @@ constructor(
*/
suspend fun setLocalConfig(config: Config) {
localConfigDataSource.setLocalConfig(config)
if (config.hasLora()) channelSetDataSource.setLoraConfig(config.lora)
config.lora?.let { channelSetDataSource.setLoraConfig(it) }
}
/** Flow representing the [LocalModuleConfig] data store. */
@ -111,17 +110,18 @@ constructor(
localConfig,
localModuleConfig,
->
deviceProfile {
node?.user?.let {
longName = it.longName
shortName = it.shortName
}
channelUrl = channels.getChannelUrl().toString()
config = localConfig
moduleConfig = localModuleConfig
if (node != null && localConfig.position.fixedPosition) {
fixedPosition = node.position
}
}
DeviceProfile(
long_name = node?.user?.long_name,
short_name = node?.user?.short_name,
channel_url = channels.getChannelUrl().toString(),
config = localConfig,
module_config = localModuleConfig,
fixed_position =
if (node != null && localConfig.position?.fixed_position == true) {
node.position
} else {
null
},
)
}
}

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,7 +14,6 @@
* 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.data.repository
import kotlinx.coroutines.flow.Flow
@ -27,7 +26,7 @@ import kotlinx.coroutines.withContext
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Position
import javax.inject.Inject
class TracerouteSnapshotRepository
@ -37,14 +36,14 @@ constructor(
private val dispatchers: CoroutineDispatchers,
) {
fun getSnapshotPositions(logUuid: String): Flow<Map<Int, MeshProtos.Position>> = dbManager.currentDb
fun getSnapshotPositions(logUuid: String): Flow<Map<Int, Position>> = dbManager.currentDb
.flatMapLatest { it.tracerouteNodePositionDao().getByLogUuid(logUuid) }
.distinctUntilChanged()
.mapLatest { list -> list.associate { it.nodeNum to it.position } }
.flowOn(dispatchers.io)
.conflate()
suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, MeshProtos.Position>) =
suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map<Int, Position>) =
withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.tracerouteNodePositionDao()
dao.deleteByLogUuid(logUuid)

View file

@ -21,6 +21,7 @@ import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
@ -30,12 +31,12 @@ import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.proto.MeshProtos.Data
import org.meshtastic.proto.MeshProtos.FromRadio
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums.PortNum
import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.Data
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.util.UUID
class MeshLogRepositoryTest {
@ -57,15 +58,10 @@ class MeshLogRepositoryTest {
@Test
fun `parseTelemetryLog preserves zero temperature`() = runTest(testDispatcher) {
val zeroTemp = 0.0f
val envMetrics = EnvironmentMetrics.newBuilder().setTemperature(zeroTemp).build()
val telemetry = Telemetry.newBuilder().setEnvironmentMetrics(envMetrics).build()
val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = zeroTemp))
val meshPacket =
MeshPacket.newBuilder()
.setDecoded(
Data.newBuilder().setPayload(telemetry.toByteString()).setPortnum(PortNum.TELEMETRY_APP),
)
.build()
MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP))
val meshLog =
MeshLog(
@ -73,7 +69,7 @@ class MeshLogRepositoryTest {
message_type = "telemetry",
received_date = System.currentTimeMillis(),
raw_message = "",
fromRadio = FromRadio.newBuilder().setPacket(meshPacket).build(),
fromRadio = FromRadio(packet = meshPacket),
)
// Using reflection to test private method parseTelemetryLog
@ -82,22 +78,17 @@ class MeshLogRepositoryTest {
val result = method.invoke(repository, meshLog) as Telemetry?
assertNotNull(result)
val resultMetrics = result?.environmentMetrics
val resultMetrics = result?.environment_metrics
assertNotNull(resultMetrics)
assertEquals(zeroTemp, resultMetrics?.temperature!!, 0.01f)
assertEquals(zeroTemp, resultMetrics?.temperature ?: 0f, 0.01f)
}
@Test
fun `parseTelemetryLog maps missing temperature to NaN`() = runTest(testDispatcher) {
val envMetrics = EnvironmentMetrics.newBuilder().build() // Temperature not set
val telemetry = Telemetry.newBuilder().setEnvironmentMetrics(envMetrics).build()
val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = null))
val meshPacket =
MeshPacket.newBuilder()
.setDecoded(
Data.newBuilder().setPayload(telemetry.toByteString()).setPortnum(PortNum.TELEMETRY_APP),
)
.build()
MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP))
val meshLog =
MeshLog(
@ -105,7 +96,7 @@ class MeshLogRepositoryTest {
message_type = "telemetry",
received_date = System.currentTimeMillis(),
raw_message = "",
fromRadio = FromRadio.newBuilder().setPacket(meshPacket).build(),
fromRadio = FromRadio(packet = meshPacket),
)
val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
@ -113,9 +104,9 @@ class MeshLogRepositoryTest {
val result = method.invoke(repository, meshLog) as Telemetry?
assertNotNull(result)
val resultMetrics = result?.environmentMetrics
val resultMetrics = result?.environment_metrics
// Should be NaN as per repository logic for missing fields
assertEquals(Float.NaN, resultMetrics?.temperature!!, 0.01f)
assertEquals(Float.NaN, resultMetrics?.temperature ?: 0f, 0.01f)
}
}