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

@ -23,18 +23,14 @@ import org.junit.runner.RunWith
import org.meshtastic.core.model.util.URL_PREFIX
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.copy
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
@RunWith(AndroidJUnit4::class)
class ChannelTest {
@Test
fun channelUrlGood() {
val ch = channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
}
val ch = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig)
val channelUrl = ch.getChannelUrl()
Assert.assertTrue(channelUrl.toString().startsWith(URL_PREFIX))
@ -71,16 +67,11 @@ class ChannelTest {
@Test
fun allModemPresetsHaveValidNames() {
ConfigProtos.Config.LoRaConfig.ModemPreset.values().forEach { preset ->
Config.LoRaConfig.ModemPreset.entries.forEach { preset ->
// Skip UNRECOGNIZED if it exists (Wire generates it sometimes) or generic UNSET values if applicable
// In this specific enum, assuming all valid defined presets should map.
if (preset.name == "UNSET" || preset.name == "UNRECOGNIZED") return@forEach
val loraConfig =
Channel.default.loraConfig.copy {
usePreset = true
modemPreset = preset
}
val loraConfig = Channel.default.loraConfig.copy(use_preset = true, modem_preset = preset)
val channel = Channel(loraConfig = loraConfig)
// We want to ensure it is NOT "Invalid"

View file

@ -16,20 +16,16 @@
*/
package org.meshtastic.core.model
import com.google.protobuf.ByteString
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.util.byteArrayOfInts
import org.meshtastic.core.model.util.xorHash
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ConfigKt.loRaConfig
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.channelSettings
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import java.security.SecureRandom
data class Channel(
val settings: ChannelProtos.ChannelSettings = default.settings,
val loraConfig: ConfigProtos.Config.LoRaConfig = default.loraConfig,
) {
data class Channel(val settings: ChannelSettings = default.settings, val loraConfig: LoRaConfig = default.loraConfig) {
companion object {
// These bytes must match the well known and not secret bytes used the default channel AES128 key device code
private val channelDefaultKey =
@ -58,21 +54,16 @@ data class Channel(
// The default channel that devices ship with
val default =
Channel(
channelSettings { psk = ByteString.copyFrom(defaultPSK) },
ChannelSettings(psk = defaultPSK.toByteString()),
// references: NodeDB::installDefaultConfig / Channels::initDefaultChannel
loRaConfig {
usePreset = true
modemPreset = ModemPreset.LONG_FAST
hopLimit = 3
txEnabled = true
},
LoRaConfig(use_preset = true, modem_preset = ModemPreset.LONG_FAST, hop_limit = 3, tx_enabled = true),
)
fun getRandomKey(size: Int = 32): ByteString {
val bytes = ByteArray(size)
val random = SecureRandom()
random.nextBytes(bytes)
return ByteString.copyFrom(bytes)
return bytes.toByteString()
}
}
@ -82,8 +73,8 @@ data class Channel(
settings.name.ifEmpty {
// We have a new style 'empty' channel name. Use the same logic from the device to convert that to a
// human readable name
if (loraConfig.usePreset) {
when (loraConfig.modemPreset) {
if (loraConfig.use_preset) {
when (loraConfig.modem_preset) {
ModemPreset.SHORT_TURBO -> "ShortTurbo"
ModemPreset.SHORT_FAST -> "ShortFast"
ModemPreset.SHORT_SLOW -> "ShortSlow"
@ -103,11 +94,11 @@ data class Channel(
val psk: ByteString
get() =
if (settings.psk.size() != 1) {
if (settings.psk.size != 1) {
settings.psk // A standard PSK
} else {
// One of our special 1 byte PSKs, see mesh.proto for docs.
val pskIndex = settings.psk.byteAt(0).toInt()
val pskIndex = settings.psk[0].toInt()
if (pskIndex == 0) {
cleartextPSK
@ -115,7 +106,7 @@ data class Channel(
// Treat an index of 1 as the old channelDefaultKey and work up from there
val bytes = channelDefaultKey.clone()
bytes[bytes.size - 1] = (0xff and (bytes[bytes.size - 1] + pskIndex - 1)).toByte()
ByteString.copyFrom(bytes)
bytes.toByteString()
}
}

View file

@ -18,9 +18,9 @@
package org.meshtastic.core.model
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.RegionCode
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
import kotlin.math.floor
/** hash a string into an integer using the djb2 algorithm by Dan Bernstein http://www.cse.yorku.ca/~oz/hash.html */
@ -40,8 +40,8 @@ private val ModemPreset.bandwidth: Float
return 0f
}
private fun LoRaConfig.bandwidth(regionInfo: RegionInfo?) = if (usePreset) {
modemPreset.bandwidth * if (regionInfo?.wideLora == true) 3.25f else 1f
private fun LoRaConfig.bandwidth(regionInfo: RegionInfo?) = if (use_preset) {
modem_preset.bandwidth * if (regionInfo?.wideLora == true) 3.25f else 1f
} else {
when (bandwidth) {
31 -> .03125f
@ -69,13 +69,13 @@ val LoRaConfig.numChannels: Int
}
internal fun LoRaConfig.channelNum(primaryName: String): Int = when {
channelNum != 0 -> channelNum
channel_num != 0 -> channel_num
numChannels == 0 -> 0
else -> (hash(primaryName) % numChannels.toUInt()).toInt() + 1
}
internal fun LoRaConfig.radioFreq(channelNum: Int): Float {
if (overrideFrequency != 0f) return overrideFrequency + frequencyOffset
if ((override_frequency ?: 0f) != 0f) return (override_frequency ?: 0f) + (frequency_offset ?: 0f)
val regionInfo = RegionInfo.fromRegionCode(region)
return if (regionInfo != null) {
(regionInfo.freqStart + bandwidth(regionInfo) / 2) + (channelNum - 1) * bandwidth(regionInfo)

View file

@ -18,19 +18,17 @@ package org.meshtastic.core.model
import android.os.Parcel
import android.os.Parcelable
import co.touchlab.kermit.Logger
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import kotlinx.serialization.Serializable
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Portnums
/** Generic [Parcel.readParcelable] Android 13 compatibility extension. */
private inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? =
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
@Suppress("DEPRECATION")
readParcelable(loader)
} else {
readParcelable(loader, T::class.java)
}
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.util.ByteStringParceler
import org.meshtastic.core.model.util.ByteStringSerializer
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Waypoint
@Parcelize
enum class MessageStatus : Parcelable {
@ -46,10 +44,13 @@ enum class MessageStatus : Parcelable {
/** A parcelable version of the protobuf MeshPacket + Data subpacket. */
@Serializable
@Parcelize
data class DataPacket(
var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast
var bytes: ByteArray?,
// A port number for this packet (formerly called DataType, see portnums.proto for new usage instructions)
@Serializable(with = ByteStringSerializer::class)
@TypeParceler<ByteString?, ByteStringParceler>
var bytes: ByteString?,
// A port number for this packet
var dataType: Int,
var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost
var time: Long = System.currentTimeMillis(), // msecs since 1970
@ -67,11 +68,52 @@ data class DataPacket(
var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path
var retryCount: Int = 0, // Number of automatic retry attempts
var emoji: Int = 0,
var sfppHash: ByteArray? = null,
@Serializable(with = ByteStringSerializer::class)
@TypeParceler<ByteString?, ByteStringParceler>
var sfppHash: ByteString? = null,
) : Parcelable {
fun readFromParcel(parcel: Parcel) {
to = parcel.readString()
bytes = ByteStringParceler.create(parcel)
dataType = parcel.readInt()
from = parcel.readString()
time = parcel.readLong()
id = parcel.readInt()
// MessageStatus is a known Parcelable type (enum), so Parcelize writes it optimized:
// 1. Presence flag (Int: 1 or 0)
// 2. Content (Enum Name as String)
status =
if (parcel.readInt() != 0) {
val name = parcel.readString()
try {
if (name != null) MessageStatus.valueOf(name) else MessageStatus.UNKNOWN
} catch (e: IllegalArgumentException) {
Logger.w(e) { "Unknown MessageStatus: $name" }
MessageStatus.UNKNOWN
}
} else {
null
}
hopLimit = parcel.readInt()
channel = parcel.readInt()
wantAck = parcel.readInt() != 0
hopStart = parcel.readInt()
snr = parcel.readFloat()
rssi = parcel.readInt()
replyId = if (parcel.readInt() == 0) null else parcel.readInt()
relayNode = if (parcel.readInt() == 0) null else parcel.readInt()
relays = parcel.readInt()
viaMqtt = parcel.readInt() != 0
retryCount = parcel.readInt()
emoji = parcel.readInt()
sfppHash = ByteStringParceler.create(parcel)
}
/** If there was an error with this message, this string describes what was wrong. */
var errorMessage: String? = null
@IgnoredOnParcel var errorMessage: String? = null
/** Syntactic sugar to make it easy to create text messages */
constructor(
@ -81,8 +123,8 @@ data class DataPacket(
replyId: Int? = null,
) : this(
to = to,
bytes = text.encodeToByteArray(),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
channel = channel,
replyId = replyId ?: 0,
)
@ -90,17 +132,16 @@ data class DataPacket(
/** If this is a text message, return the string, otherwise null */
val text: String?
get() =
when (dataType) {
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> bytes?.decodeToString()
// Portnums.PortNum.NODE_STATUS_APP_VALUE ->
// MeshProtos.StatusMessage.parseFrom(bytes).status
else -> null
if (dataType == PortNum.TEXT_MESSAGE_APP.value) {
bytes?.utf8()
} else {
null
}
val alert: String?
get() =
if (dataType == Portnums.PortNum.ALERT_APP_VALUE) {
bytes?.decodeToString()
if (dataType == PortNum.ALERT_APP.value) {
bytes?.utf8()
} else {
null
}
@ -108,13 +149,22 @@ data class DataPacket(
constructor(
to: String?,
channel: Int,
waypoint: MeshProtos.Waypoint,
) : this(to = to, bytes = waypoint.toByteArray(), dataType = Portnums.PortNum.WAYPOINT_APP_VALUE, channel = channel)
waypoint: Waypoint,
) : this(
to = to,
bytes = Waypoint.ADAPTER.encode(waypoint).toByteString(),
dataType = PortNum.WAYPOINT_APP.value,
channel = channel,
)
val waypoint: MeshProtos.Waypoint?
val waypoint: Waypoint?
get() =
if (dataType == Portnums.PortNum.WAYPOINT_APP_VALUE) {
MeshProtos.Waypoint.parseFrom(bytes)
if (dataType == PortNum.WAYPOINT_APP.value) {
try {
bytes?.let { Waypoint.ADAPTER.decode(it) }
} catch (e: Exception) {
null
}
} else {
null
}
@ -122,138 +172,7 @@ data class DataPacket(
val hopsAway: Int
get() = if (hopStart == 0 || hopLimit > hopStart) -1 else hopStart - hopLimit
// Autogenerated comparision, because we have a byte array
constructor(
parcel: Parcel,
) : this(
parcel.readString(),
parcel.createByteArray(),
parcel.readInt(),
parcel.readString(),
parcel.readLong(),
parcel.readInt(),
parcel.readParcelableCompat(MessageStatus::class.java.classLoader),
parcel.readInt(),
parcel.readInt(),
parcel.readInt() == 1,
parcel.readInt(),
parcel.readFloat(),
parcel.readInt(),
parcel.readInt().let { if (it == 0) null else it },
parcel.readInt().let { if (it == -1) null else it },
parcel.readInt(), // relays
parcel.readInt() == 1, // viaMqtt
parcel.readInt(), // retryCount
parcel.readInt(), // emoji
parcel.createByteArray(), // sfppHash
)
@Suppress("CyclomaticComplexMethod")
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DataPacket
if (from != other.from) return false
if (to != other.to) return false
if (channel != other.channel) return false
if (time != other.time) return false
if (id != other.id) return false
if (dataType != other.dataType) return false
if (!bytes.contentEquals(other.bytes)) return false
if (status != other.status) return false
if (hopLimit != other.hopLimit) return false
if (wantAck != other.wantAck) return false
if (hopStart != other.hopStart) return false
if (snr != other.snr) return false
if (rssi != other.rssi) return false
if (replyId != other.replyId) return false
if (relayNode != other.relayNode) return false
if (relays != other.relays) return false
if (viaMqtt != other.viaMqtt) return false
if (retryCount != other.retryCount) return false
if (emoji != other.emoji) return false
if (!sfppHash.contentEquals(other.sfppHash)) return false
return true
}
override fun hashCode(): Int {
var result = from?.hashCode() ?: 0
result = 31 * result + (to?.hashCode() ?: 0)
result = 31 * result + time.hashCode()
result = 31 * result + id
result = 31 * result + dataType
result = 31 * result + (bytes?.contentHashCode() ?: 0)
result = 31 * result + (status?.hashCode() ?: 0)
result = 31 * result + hopLimit
result = 31 * result + channel
result = 31 * result + wantAck.hashCode()
result = 31 * result + hopStart
result = 31 * result + snr.hashCode()
result = 31 * result + rssi
result = 31 * result + (replyId ?: 0)
result = 31 * result + (relayNode ?: -1)
result = 31 * result + relays
result = 31 * result + viaMqtt.hashCode()
result = 31 * result + retryCount
result = 31 * result + emoji
result = 31 * result + (sfppHash?.contentHashCode() ?: 0)
return result
}
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(to)
parcel.writeByteArray(bytes)
parcel.writeInt(dataType)
parcel.writeString(from)
parcel.writeLong(time)
parcel.writeInt(id)
parcel.writeParcelable(status, flags)
parcel.writeInt(hopLimit)
parcel.writeInt(channel)
parcel.writeInt(if (wantAck) 1 else 0)
parcel.writeInt(hopStart)
parcel.writeFloat(snr)
parcel.writeInt(rssi)
parcel.writeInt(replyId ?: 0)
parcel.writeInt(relayNode ?: -1)
parcel.writeInt(relays)
parcel.writeInt(if (viaMqtt) 1 else 0)
parcel.writeInt(retryCount)
parcel.writeInt(emoji)
parcel.writeByteArray(sfppHash)
}
override fun describeContents(): Int = 0
/** Update our object from our parcel (used for inout parameters) */
fun readFromParcel(parcel: Parcel) {
to = parcel.readString()
bytes = parcel.createByteArray()
dataType = parcel.readInt()
from = parcel.readString()
time = parcel.readLong()
id = parcel.readInt()
status = parcel.readParcelableCompat(MessageStatus::class.java.classLoader)
hopLimit = parcel.readInt()
channel = parcel.readInt()
wantAck = parcel.readInt() == 1
hopStart = parcel.readInt()
snr = parcel.readFloat()
rssi = parcel.readInt()
replyId = parcel.readInt().let { if (it == 0) null else it }
relayNode = parcel.readInt().let { if (it == -1) null else it }
relays = parcel.readInt()
viaMqtt = parcel.readInt() == 1
retryCount = parcel.readInt()
emoji = parcel.readInt()
sfppHash = parcel.createByteArray()
}
companion object CREATOR : Parcelable.Creator<DataPacket> {
companion object {
// Special node IDs that can be used for sending messages
/** the Node ID for broadcast destinations */
@ -272,9 +191,5 @@ data class DataPacket(
@Suppress("MagicNumber")
fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull()
override fun createFromParcel(parcel: Parcel): DataPacket = DataPacket(parcel)
override fun newArray(size: Int): Array<DataPacket?> = arrayOfNulls(size)
}
}

View file

@ -16,36 +16,38 @@
*/
package org.meshtastic.core.model
import org.meshtastic.proto.MeshProtos
import co.touchlab.kermit.Logger
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
val MeshProtos.MeshPacket.neighborInfo: MeshProtos.NeighborInfo?
get() =
if (hasDecoded() && decoded.portnumValue == 71) { // NEIGHBORINFO_APP_VALUE = 71
runCatching { MeshProtos.NeighborInfo.parseFrom(decoded.payload) }.getOrNull()
val MeshPacket.neighborInfo: NeighborInfo?
get() {
val decoded = this.decoded
return if (decoded != null && decoded.portnum == PortNum.NEIGHBORINFO_APP) {
NeighborInfo.ADAPTER.decodeOrNull(decoded.payload, Logger)
} else {
null
}
}
fun MeshProtos.NeighborInfo.getNeighborInfoResponse(
getUser: (nodeNum: Int) -> String,
header: String = "Neighbors:",
): String = buildString {
append(header)
append("\n\n")
if (neighborsList.isEmpty()) {
append("No neighbors reported.")
} else {
neighborsList.forEach { n ->
append("")
append(getUser(n.nodeId))
append(" (SNR: ")
append(n.snr)
append(")\n")
fun NeighborInfo.getNeighborInfoResponse(getUser: (nodeNum: Int) -> String, header: String = "Neighbors:"): String =
buildString {
append(header)
append("\n\n")
if (neighbors.isEmpty()) {
append("No neighbors reported.")
} else {
neighbors.forEach { n ->
append("")
append(getUser(n.node_id))
append(" (SNR: ")
append(n.snr)
append(")\n")
}
}
}
}
fun MeshProtos.MeshPacket.getNeighborInfoResponse(
getUser: (nodeNum: Int) -> String,
header: String = "Neighbors:",
): String? = neighborInfo?.getNeighborInfoResponse(getUser, header)
fun MeshPacket.getNeighborInfoResponse(getUser: (nodeNum: Int) -> String, header: String = "Neighbors:"): String? =
neighborInfo?.getNeighborInfoResponse(getUser, header)

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.model
import android.graphics.Color
@ -24,9 +23,8 @@ import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.bearing
import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
//
// model objects that directly map to the corresponding protobufs
@ -37,7 +35,7 @@ data class MeshUser(
val id: String,
val longName: String,
val shortName: String,
val hwModel: MeshProtos.HardwareModel,
val hwModel: HardwareModel,
val isLicensed: Boolean = false,
val role: Int = 0,
) : Parcelable {
@ -50,7 +48,9 @@ data class MeshUser(
"role=$role)"
/** Create our model object from a protobuf. */
constructor(p: MeshProtos.User) : this(p.id, p.longName, p.shortName, p.hwModel, p.isLicensed, p.roleValue)
constructor(
p: org.meshtastic.proto.User,
) : this(p.id, p.long_name ?: "", p.short_name ?: "", p.hw_model, p.is_licensed, p.role.value)
/**
* a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot or null
@ -58,7 +58,7 @@ data class MeshUser(
*/
val hwModelString: String?
get() =
if (hwModel == MeshProtos.HardwareModel.UNSET) {
if (hwModel == HardwareModel.UNSET) {
null
} else {
hwModel.name.replace('_', '-').replace('p', '.').lowercase()
@ -92,18 +92,18 @@ data class Position(
* be used.
*/
constructor(
position: MeshProtos.Position,
position: org.meshtastic.proto.Position,
defaultTime: Int = currentTime(),
) : this(
// We prefer the int version of lat/lon but if not available use the depreciated legacy version
degD(position.latitudeI),
degD(position.longitudeI),
position.altitude,
degD(position.latitude_i ?: 0),
degD(position.longitude_i ?: 0),
position.altitude ?: 0,
if (position.time != 0) position.time else defaultTime,
position.satsInView,
position.groundSpeed,
position.groundTrack,
position.precisionBits,
position.sats_in_view ?: 0,
position.ground_speed ?: 0,
position.ground_track ?: 0,
position.precision_bits ?: 0,
)
// / @return distance in meters to some other node (or null if unknown)
@ -139,9 +139,16 @@ data class DeviceMetrics(
/** Create our model object from a protobuf. */
constructor(
p: TelemetryProtos.DeviceMetrics,
p: org.meshtastic.proto.DeviceMetrics,
telemetryTime: Int = currentTime(),
) : this(telemetryTime, p.batteryLevel, p.voltage, p.channelUtilization, p.airUtilTx, p.uptimeSeconds)
) : this(
telemetryTime,
p.battery_level ?: 0,
p.voltage ?: 0f,
p.channel_utilization ?: 0f,
p.air_util_tx ?: 0f,
p.uptime_seconds ?: 0,
)
}
@Parcelize
@ -163,20 +170,19 @@ data class EnvironmentMetrics(
companion object {
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
fun fromTelemetryProto(proto: TelemetryProtos.EnvironmentMetrics, time: Int): EnvironmentMetrics =
fun fromTelemetryProto(proto: org.meshtastic.proto.EnvironmentMetrics, time: Int): EnvironmentMetrics =
EnvironmentMetrics(
temperature = proto.temperature.takeIf { proto.hasTemperature() && !it.isNaN() },
relativeHumidity =
proto.relativeHumidity.takeIf { proto.hasRelativeHumidity() && !it.isNaN() && it != 0.0f },
soilTemperature = proto.soilTemperature.takeIf { proto.hasSoilTemperature() && !it.isNaN() },
soilMoisture = proto.soilMoisture.takeIf { proto.hasSoilMoisture() && it != Int.MIN_VALUE },
barometricPressure = proto.barometricPressure.takeIf { proto.hasBarometricPressure() && !it.isNaN() },
gasResistance = proto.gasResistance.takeIf { proto.hasGasResistance() && !it.isNaN() },
voltage = proto.voltage.takeIf { proto.hasVoltage() && !it.isNaN() },
current = proto.current.takeIf { proto.hasCurrent() && !it.isNaN() },
iaq = proto.iaq.takeIf { proto.hasIaq() && it != Int.MIN_VALUE },
lux = proto.lux.takeIf { proto.hasLux() && !it.isNaN() },
uvLux = proto.uvLux.takeIf { proto.hasUvLux() && !it.isNaN() },
temperature = proto.temperature?.takeIf { !it.isNaN() },
relativeHumidity = proto.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f },
soilTemperature = proto.soil_temperature?.takeIf { !it.isNaN() },
soilMoisture = proto.soil_moisture?.takeIf { it != Int.MIN_VALUE },
barometricPressure = proto.barometric_pressure?.takeIf { !it.isNaN() },
gasResistance = proto.gas_resistance?.takeIf { !it.isNaN() },
voltage = proto.voltage?.takeIf { !it.isNaN() },
current = proto.current?.takeIf { !it.isNaN() },
iaq = proto.iaq?.takeIf { it != Int.MIN_VALUE },
lux = proto.lux?.takeIf { !it.isNaN() },
uvLux = proto.uv_lux?.takeIf { !it.isNaN() },
time = time,
)
}
@ -247,13 +253,13 @@ data class NodeInfo(
fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist ->
when {
dist == 0 -> null // same point
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist < 1000 ->
prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist < 1000 ->
"%.0f m".format(dist.toDouble())
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist >= 1000 ->
prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist >= 1000 ->
"%.1f km".format(dist / 1000.0)
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist < 1609 ->
prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist < 1609 ->
"%.0f ft".format(dist.toDouble() * 3.281)
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist >= 1609 ->
prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist >= 1609 ->
"%.1f mi".format(dist / 1609.34)
else -> null
}

View file

@ -16,36 +16,43 @@
*/
package org.meshtastic.core.model
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.RouteDiscovery
import org.meshtastic.proto.Portnums
import co.touchlab.kermit.Logger
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.RouteDiscovery
val MeshProtos.MeshPacket.fullRouteDiscovery: RouteDiscovery?
get() =
with(decoded) {
if (hasDecoded() && !wantResponse && portnum == Portnums.PortNum.TRACEROUTE_APP) {
runCatching { RouteDiscovery.parseFrom(payload).toBuilder() }
.getOrNull()
?.apply {
val destinationId = dest.takeIf { it != 0 } ?: this@fullRouteDiscovery.to
val sourceId = source.takeIf { it != 0 } ?: this@fullRouteDiscovery.from
val fullRoute = listOf(destinationId) + routeList + sourceId
clearRoute()
addAllRoute(fullRoute)
val MeshPacket.fullRouteDiscovery: RouteDiscovery?
get() {
val d = decoded
if (d != null && !d.want_response && d.portnum == PortNum.TRACEROUTE_APP) {
val originalRd = RouteDiscovery.ADAPTER.decodeOrNull(d.payload, Logger) ?: return null
val fullRouteBack = listOf(sourceId) + routeBackList + destinationId
clearRouteBack()
// hopStart was not populated prior to 2.3.0. The bitfield was added in 2.5.0 and
// is used to detect versions where hopStart can be trusted to have been set.
if ((hopStart > 0 || hasBitfield()) && snrBackCount > 0) { // otherwise back route is invalid
addAllRouteBack(fullRouteBack)
}
}
?.build()
} else {
null
}
val destinationId = if (d.dest != 0) d.dest else this.to
val sourceId = if (d.source != 0) d.source else this.from
// Note: Wire lists are immutable
val fullRoute = listOf(destinationId) + originalRd.route + sourceId
val fullRouteBack = listOf(sourceId) + originalRd.route_back + destinationId
// hopStart was not populated prior to 2.3.0. The bitfield was added in 2.5.0 and
// is used to detect versions where hopStart can be trusted to have been set.
// Assuming default integer values of 0 for hop_start and snr_back_count if unset.
val hopStartVal = hop_start
val hasBitfield = (d.bitfield ?: 0) != 0
return originalRd.copy(
route = fullRoute,
route_back =
if ((hopStartVal > 0 || hasBitfield) && originalRd.snr_back.isNotEmpty()) {
fullRouteBack
} else {
originalRd.route_back
},
)
}
return null
}
@Suppress("MagicNumber")
private fun formatTraceroutePath(nodesList: List<String>, snrList: List<Int>): String {
@ -74,30 +81,30 @@ private fun RouteDiscovery.getTracerouteResponse(
headerTowards: String = "Route traced toward destination:\n\n",
headerBack: String = "Route traced back to us:\n\n",
): String = buildString {
if (routeList.isNotEmpty()) {
if (route.isNotEmpty()) {
append(headerTowards)
append(formatTraceroutePath(routeList.map(getUser), snrTowardsList))
append(formatTraceroutePath(route.map(getUser), snr_towards))
}
if (routeBackList.isNotEmpty()) {
if (route_back.isNotEmpty()) {
append("\n\n")
append(headerBack)
append(formatTraceroutePath(routeBackList.map(getUser), snrBackList))
append(formatTraceroutePath(route_back.map(getUser), snr_back))
}
}
fun MeshProtos.MeshPacket.getTracerouteResponse(
fun MeshPacket.getTracerouteResponse(
getUser: (nodeNum: Int) -> String,
headerTowards: String = "Route traced toward destination:\n\n",
headerBack: String = "Route traced back to us:\n\n",
): String? = fullRouteDiscovery?.getTracerouteResponse(getUser, headerTowards, headerBack)
/** Returns a traceroute response string only when the result is complete (both directions). */
fun MeshProtos.MeshPacket.getFullTracerouteResponse(
fun MeshPacket.getFullTracerouteResponse(
getUser: (nodeNum: Int) -> String,
headerTowards: String = "Route traced toward destination:\n\n",
headerBack: String = "Route traced back to us:\n\n",
): String? = fullRouteDiscovery
?.takeIf { it.routeList.isNotEmpty() && it.routeBackList.isNotEmpty() }
?.takeIf { it.route.isNotEmpty() && it.route_back.isNotEmpty() }
?.getTracerouteResponse(getUser, headerTowards, headerBack)
enum class TracerouteMapAvailability {

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,13 +14,17 @@
* 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.model.util
import android.util.Base64
import com.google.protobuf.ByteString
import com.google.protobuf.kotlin.toByteString
import okio.ByteString
import okio.ByteString.Companion.toByteString
fun ByteString.encodeToString() = Base64.encodeToString(this.toByteArray(), Base64.NO_WRAP)
fun ByteString.encodeToString(): String = Base64.encodeToString(this.toByteArray(), Base64.NO_WRAP)
fun String.toByteString() = Base64.decode(this, Base64.NO_WRAP).toByteString()
/**
* Decodes a Base64 string into a [ByteString].
*
* @throws IllegalArgumentException if the string is not valid Base64.
*/
fun String.base64ToByteString(): ByteString = Base64.decode(this, Base64.NO_WRAP).toByteString()

View file

@ -0,0 +1,49 @@
/*
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.os.Parcel
import kotlinx.parcelize.Parceler
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ByteArraySerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import okio.ByteString
import okio.ByteString.Companion.toByteString
/** Serializer for Okio [ByteString] using kotlinx.serialization */
object ByteStringSerializer : KSerializer<ByteString> {
private val byteArraySerializer = ByteArraySerializer()
override val descriptor: SerialDescriptor = byteArraySerializer.descriptor
override fun serialize(encoder: Encoder, value: ByteString) {
byteArraySerializer.serialize(encoder, value.toByteArray())
}
override fun deserialize(decoder: Decoder): ByteString = byteArraySerializer.deserialize(decoder).toByteString()
}
/** Parceler for Okio [ByteString] for Android Parcelable support */
object ByteStringParceler : Parceler<ByteString?> {
override fun create(parcel: Parcel): ByteString? = parcel.createByteArray()?.toByteString()
override fun ByteString?.write(parcel: Parcel, flags: Int) {
parcel.writeByteArray(this?.toByteArray())
}
}

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.model.util
import android.graphics.Bitmap
@ -24,8 +23,10 @@ import co.touchlab.kermit.Logger
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.journeyapps.barcodescanner.BarcodeEncoder
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.Channel
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.LoRaConfig
import java.net.MalformedURLException
private const val MESHTASTIC_HOST = "meshtastic.org"
@ -46,32 +47,42 @@ fun Uri.toChannelSet(): ChannelSet {
// Older versions of Meshtastic clients (Apple/web) included `?add=true` within the URL fragment.
// This gracefully handles those cases until the newer version are generally available/used.
val url = ChannelSet.parseFrom(Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS))
val fragmentBytes = Base64.decode(fragment!!.substringBefore('?'), BASE64FLAGS)
val url = ChannelSet.ADAPTER.decode(fragmentBytes.toByteString())
val shouldAdd =
fragment?.substringAfter('?', "")?.takeUnless { it.isBlank() }?.equals("add=true")
?: getBooleanQueryParameter("add", false)
return url.toBuilder().apply { if (shouldAdd) clearLoraConfig() }.build()
return if (shouldAdd) url.copy(lora_config = null) else url
}
/** @return A list of globally unique channel IDs usable with MQTT subscribe() */
val ChannelSet.subscribeList: List<String>
get() = settingsList.filter { it.downlinkEnabled }.map { Channel(it, loraConfig).name }
get() {
val loraConfig = this.lora_config ?: LoRaConfig()
return settings.filter { it.downlink_enabled }.map { Channel(it, loraConfig).name }
}
fun ChannelSet.getChannel(index: Int): Channel? =
if (settingsCount > index) Channel(getSettings(index), loraConfig) else null
fun ChannelSet.getChannel(index: Int): Channel? = if (settings.size > index) {
val s = settings[index]
Channel(s, lora_config ?: LoRaConfig())
} else {
null
}
/** Return the primary channel info */
val ChannelSet.primaryChannel: Channel?
get() = getChannel(0)
fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null
/**
* Return a URL that represents the [ChannelSet]
*
* @param upperCasePrefix portions of the URL can be upper case to make for more efficient QR codes
*/
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): Uri {
val channelBytes = this.toByteArray() ?: ByteArray(0) // if unset just use empty
val channelBytes = ChannelSet.ADAPTER.encode(this)
val enc = Base64.encodeToString(channelBytes, BASE64FLAGS)
val p = if (upperCasePrefix) URL_PREFIX.uppercase() else URL_PREFIX
val query = if (shouldAdd) "?add=true" else ""

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,21 +14,20 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MatchingDeclarationName")
package org.meshtastic.core.model.util
import android.icu.util.LocaleData
import android.icu.util.ULocale
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import java.util.Locale
enum class DistanceUnit(val symbol: String, val multiplier: Float, val system: Int) {
METER("m", multiplier = 1F, DisplayUnits.METRIC_VALUE),
KILOMETER("km", multiplier = 0.001F, DisplayUnits.METRIC_VALUE),
FOOT("ft", multiplier = 3.28084F, DisplayUnits.IMPERIAL_VALUE),
MILE("mi", multiplier = 0.000621371F, DisplayUnits.IMPERIAL_VALUE),
METER("m", multiplier = 1F, DisplayUnits.METRIC.value),
KILOMETER("km", multiplier = 0.001F, DisplayUnits.METRIC.value),
FOOT("ft", multiplier = 3.28084F, DisplayUnits.IMPERIAL.value),
MILE("mi", multiplier = 0.000621371F, DisplayUnits.IMPERIAL.value),
;
companion object {
@ -55,8 +54,8 @@ fun Int.metersIn(unit: DistanceUnit): Float = this * unit.multiplier
fun Int.metersIn(system: DisplayUnits): Float {
val unit =
when (system.number) {
DisplayUnits.IMPERIAL_VALUE -> DistanceUnit.FOOT
when (system.value) {
DisplayUnits.IMPERIAL.value -> DistanceUnit.FOOT
else -> DistanceUnit.METER
}
return this.metersIn(unit)
@ -71,8 +70,8 @@ fun Float.toString(unit: DistanceUnit): String = if (unit in setOf(DistanceUnit.
fun Float.toString(system: DisplayUnits): String {
val unit =
when (system.number) {
DisplayUnits.IMPERIAL_VALUE -> DistanceUnit.FOOT
when (system.value) {
DisplayUnits.IMPERIAL.value -> DistanceUnit.FOOT
else -> DistanceUnit.METER
}
return this.toString(unit)
@ -83,7 +82,7 @@ private const val MILE_THRESHOLD = 1609
fun Int.toDistanceString(system: DisplayUnits): String {
val unit =
if (system.number == DisplayUnits.METRIC_VALUE) {
if (system.value == DisplayUnits.METRIC.value) {
if (this < KILOMETER_THRESHOLD) DistanceUnit.METER else DistanceUnit.KILOMETER
} else {
if (this < MILE_THRESHOLD) DistanceUnit.FOOT else DistanceUnit.MILE

View file

@ -17,8 +17,8 @@
package org.meshtastic.core.model.util
import org.meshtastic.core.model.BuildConfig
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
/**
* When printing strings to logs sometimes we want to print useful debugging information about users or positions. But
@ -35,28 +35,17 @@ fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString()
// A toString that makes sure all newlines are removed (for nice logging).
fun Any.toOneLineString() = this.toString().replace('\n', ' ')
fun ConfigProtos.Config.toOneLineString(): String {
val redactedFields = """(wifi_psk:|public_key:|private_key:|admin_key:)\s*".*"""
return this.toString()
.replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" }
.replace('\n', ' ')
fun Config.toOneLineString(): String {
// Wire toString uses field=value format
val redactedFields = """(wifi_psk|public_key|private_key|admin_key)=[^,}]+"""
return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
}
fun MeshProtos.MeshPacket.toOneLineString(): String {
val redactedFields = """(public_key:|private_key:|admin_key:)\s*".*""" // Redact keys
return this.toString()
.replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" }
.replace('\n', ' ')
fun MeshPacket.toOneLineString(): String {
val redactedFields = """(public_key|private_key|admin_key)=[^,}]+""" // Redact keys
return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
}
fun MeshProtos.toOneLineString(): String {
val redactedFields = """(public_key:|private_key:|admin_key:)\s*".*""" // Redact keys
return this.toString()
.replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" }
.replace('\n', ' ')
}
// Return a one line string version of an object (but if a release build, just say 'might be PII)
fun Any.toPIIString() = if (!BuildConfig.DEBUG) {
"<PII?>"
} else {

View file

@ -0,0 +1,128 @@
/*
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import co.touchlab.kermit.Logger
import com.squareup.wire.Message
import com.squareup.wire.ProtoAdapter
import okio.ByteString
import okio.ByteString.Companion.toByteString
@Suppress("unused") // These are extension functions meant to be imported elsewhere
fun <T : Message<T, *>> ProtoAdapter<T>.decodeOrNull(bytes: ByteString?, logger: Logger? = null): T? {
if (bytes == null || bytes.size == 0) return null
return runCatching { decode(bytes) }
.onFailure { exception -> logger?.e(exception) { "Failed to decode proto message" } }
.getOrNull()
}
/**
* Safely decode a proto message from [ByteArray], returning null on error.
*
* Convenience overload for ByteArray inputs, automatically converting to ByteString.
*
* @param bytes The ByteArray to decode, or null
* @param logger Optional logger for error reporting
* @return The decoded message, or null if bytes is null or decoding fails
*/
fun <T : Message<T, *>> ProtoAdapter<T>.decodeOrNull(bytes: ByteArray?, logger: Logger? = null): T? {
if (bytes == null || bytes.isEmpty()) return null
return decodeOrNull(bytes.toByteString(), logger)
}
/**
* Check if an encoded message would fit within a size limit.
*
* More accurate than checking ByteArray.size() as it uses Wire's actual encoding size calculation, which accounts for
* variable-length encoding.
*
* Useful for:
* - Validating packet sizes before transmission
* - Enforcing payload limits
* - Better error messages with actual vs expected sizes
*
* Example:
* ```
* val data = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = bytes)
* if (!Data.ADAPTER.isWithinSizeLimit(data, MAX_PAYLOAD)) {
* throw RemoteException("Payload too large")
* }
* ```
*
* @param message The message to check
* @param maxBytes Maximum allowed bytes
* @return true if encodedSize(message) <= maxBytes
*/
fun <T : Message<T, *>> ProtoAdapter<T>.isWithinSizeLimit(message: T, maxBytes: Int): Boolean =
encodedSize(message) <= maxBytes
/**
* Get the estimated encoded size of a message in bytes.
*
* This accounts for variable-length encoding and is more accurate than just using ByteArray.size(). Useful for size
* validation and logging.
*
* @param message The message to measure
* @return Size in bytes when encoded
*/
fun <T : Message<T, *>> ProtoAdapter<T>.sizeInBytes(message: T): Int = encodedSize(message)
/**
* Convert a proto message to a pretty-printed string representation.
*
* This uses Wire's built-in toString() which provides a human-readable format with field names and values. Useful for
* debugging and logging.
*
* Example output:
* ```
* Position{latitude_i=371234567, longitude_i=-1220987654, altitude=15}
* ```
*
* @param message The message to format
* @return String representation of the message
*/
fun <T : Message<T, *>> ProtoAdapter<T>.toReadableString(message: T): String = message.toString()
/**
* Log a proto message with readable formatting.
*
* Useful for debugging packet contents during development.
*
* Example:
* ```
* Position.ADAPTER.logMessage(position, Logger, "Received position update")
* ```
*
* @param message The message to log
* @param logger The logger instance
* @param prefix Optional prefix message
*/
fun <T : Message<T, *>> ProtoAdapter<T>.logMessage(message: T, logger: Logger, prefix: String = "") {
val prefixStr = if (prefix.isNotEmpty()) "$prefix: " else ""
logger.d { "$prefixStr${toReadableString(message)}" }
}
/**
* Get a compact single-line string representation for JSON/API serialization.
*
* Converts the proto message to a single-line format by replacing newlines. Useful for compact logging and API
* payloads.
*
* @param message The message to format
* @return Single-line string representation
*/
fun <T : Message<T, *>> ProtoAdapter<T>.toOneLiner(message: T): String = message.toString().replace('\n', ' ')

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,13 +14,12 @@
* 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.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.Config
class ChannelOptionTest {
@ -33,12 +32,9 @@ class ChannelOptionTest {
*/
@Test
fun `ensure every ModemPreset is mapped in ChannelOption`() {
// Get all possible ModemPreset values, excluding the ones we expect to ignore.
// Get all possible ModemPreset values.
val unmappedPresets =
ModemPreset.entries.filter {
// UNRECOGNIZED is a system-generated value for forward compatibility.
it != ModemPreset.UNRECOGNIZED
}
Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }
unmappedPresets.forEach { preset ->
// Attempt to find the corresponding ChannelOption
@ -62,7 +58,8 @@ class ChannelOptionTest {
*/
@Test
fun `ensure no extra mappings exist in ChannelOption`() {
val protoPresets = ModemPreset.entries.filter { it != ModemPreset.UNRECOGNIZED }.toSet()
val protoPresets =
Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet()
val mappedPresets = ChannelOption.entries.map { it.modemPreset }.toSet()
assertEquals(

View file

@ -0,0 +1,144 @@
/*
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model
import android.os.Parcel
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DataPacketParcelTest {
@Test
fun `DataPacket parcelization round trip via writeToParcel and readParcelable`() {
val original = createFullDataPacket()
val parcel = Parcel.obtain()
// Use writeParcelable to include class information/nullability flag needed by readParcelable
parcel.writeParcelable(original, 0)
parcel.setDataPosition(0)
@Suppress("DEPRECATION")
val created = parcel.readParcelable<DataPacket>(DataPacket::class.java.classLoader)
parcel.recycle()
assertNotNull(created)
assertDataPacketsEqual(original, created!!)
}
@Test
fun `DataPacket manual readFromParcel matches writeToParcel`() {
val original = createFullDataPacket()
// Write using generated writeToParcel (writes content only)
val parcel = Parcel.obtain()
original.writeToParcel(parcel, 0)
parcel.setDataPosition(0)
// Read using manual readFromParcel
// We start with an empty packet and populate it
val restored = DataPacket(to = "dummy", channel = 0, text = "dummy")
// Reset fields to ensure they are overwritten
restored.to = null
restored.from = null
restored.bytes = null
restored.sfppHash = null
restored.readFromParcel(parcel)
parcel.recycle()
assertDataPacketsEqual(original, restored)
}
@Test
fun `DataPacket with nulls handles parcelization correctly`() {
val original =
DataPacket(
to = null,
bytes = null,
dataType = 99,
from = null,
time = 123L,
status = null,
replyId = null,
relayNode = null,
sfppHash = null,
)
val parcel = Parcel.obtain()
original.writeToParcel(parcel, 0)
parcel.setDataPosition(0)
val restored = DataPacket(to = "dummy", channel = 0, text = "dummy")
restored.readFromParcel(parcel)
parcel.recycle()
assertDataPacketsEqual(original, restored)
}
private fun createFullDataPacket(): DataPacket = DataPacket(
to = "destNode",
bytes = "Hello World".toByteArray().toByteString(),
dataType = 1,
from = "srcNode",
time = 1234567890L,
id = 42,
status = MessageStatus.DELIVERED,
hopLimit = 3,
channel = 5,
wantAck = true,
hopStart = 7,
snr = 12.5f,
rssi = -80,
replyId = 101,
relayNode = 202,
relays = 1,
viaMqtt = true,
retryCount = 2,
emoji = 0x1F600,
sfppHash = "sfpp".toByteArray().toByteString(),
)
private fun assertDataPacketsEqual(expected: DataPacket, actual: DataPacket) {
assertEquals("to", expected.to, actual.to)
assertEquals("bytes", expected.bytes, actual.bytes)
assertEquals("dataType", expected.dataType, actual.dataType)
assertEquals("from", expected.from, actual.from)
assertEquals("time", expected.time, actual.time)
assertEquals("id", expected.id, actual.id)
assertEquals("status", expected.status, actual.status)
assertEquals("hopLimit", expected.hopLimit, actual.hopLimit)
assertEquals("channel", expected.channel, actual.channel)
assertEquals("wantAck", expected.wantAck, actual.wantAck)
assertEquals("hopStart", expected.hopStart, actual.hopStart)
assertEquals("snr", expected.snr, actual.snr, 0.001f)
assertEquals("rssi", expected.rssi, actual.rssi)
assertEquals("replyId", expected.replyId, actual.replyId)
assertEquals("relayNode", expected.relayNode, actual.relayNode)
assertEquals("relays", expected.relays, actual.relays)
assertEquals("viaMqtt", expected.viaMqtt, actual.viaMqtt)
assertEquals("retryCount", expected.retryCount, actual.retryCount)
assertEquals("emoji", expected.emoji, actual.emoji)
assertEquals("sfppHash", expected.sfppHash, actual.sfppHash)
}
}

View file

@ -18,7 +18,7 @@ package org.meshtastic.core.model
import android.os.Parcel
import kotlinx.serialization.json.Json
import org.junit.Assert.assertArrayEquals
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
@ -31,9 +31,9 @@ import org.robolectric.annotation.Config
class DataPacketTest {
@Test
fun `DataPacket sfppHash is nullable and correctly set`() {
val hash = byteArrayOf(1, 2, 3, 4)
val hash = byteArrayOf(1, 2, 3, 4).toByteString()
val packet = DataPacket(to = "to", channel = 0, text = "hello").copy(sfppHash = hash)
assertArrayEquals(hash, packet.sfppHash)
assertEquals(hash, packet.sfppHash)
val packetNoHash = DataPacket(to = "to", channel = 0, text = "hello")
assertEquals(null, packetNoHash.sfppHash)
@ -47,7 +47,7 @@ class DataPacketTest {
@Test
fun `DataPacket serialization preserves sfppHash`() {
val hash = byteArrayOf(5, 6, 7, 8)
val hash = byteArrayOf(5, 6, 7, 8).toByteString()
val packet =
DataPacket(to = "to", channel = 0, text = "test")
.copy(sfppHash = hash, status = MessageStatus.SFPP_CONFIRMED)
@ -57,17 +57,17 @@ class DataPacketTest {
val decoded = json.decodeFromString(DataPacket.serializer(), encoded)
assertEquals(packet.status, decoded.status)
assertArrayEquals(hash, decoded.sfppHash)
assertEquals(hash, decoded.sfppHash)
}
@Test
fun `DataPacket equals and hashCode include sfppHash`() {
val hash1 = byteArrayOf(1, 2, 3)
val hash2 = byteArrayOf(4, 5, 6)
val hash1 = byteArrayOf(1, 2, 3).toByteString()
val hash2 = byteArrayOf(4, 5, 6).toByteString()
val fixedTime = 1000L
val base = DataPacket(to = "to", channel = 0, text = "text").copy(time = fixedTime)
val p1 = base.copy(sfppHash = hash1)
val p2 = base.copy(sfppHash = hash1.copyOf()) // same content, different array instance
val p2 = base.copy(sfppHash = byteArrayOf(1, 2, 3).toByteString()) // same content
val p3 = base.copy(sfppHash = hash2)
val p4 = base.copy(sfppHash = null)
@ -81,10 +81,12 @@ class DataPacketTest {
@Test
fun `readFromParcel maintains alignment and updates all fields including bytes and dataType`() {
val bytes = byteArrayOf(1, 2, 3).toByteString()
val sfppHash = byteArrayOf(4, 5, 6).toByteString()
val original =
DataPacket(
to = "recipient",
bytes = byteArrayOf(1, 2, 3),
bytes = bytes,
dataType = 42,
from = "sender",
time = 123456789L,
@ -102,7 +104,7 @@ class DataPacketTest {
viaMqtt = true,
retryCount = 1,
emoji = 10,
sfppHash = byteArrayOf(4, 5, 6),
sfppHash = sfppHash,
)
val parcel = Parcel.obtain()
@ -114,7 +116,7 @@ class DataPacketTest {
// Verify that all fields were updated correctly
assertEquals("recipient", packetToUpdate.to)
assertArrayEquals(byteArrayOf(1, 2, 3), packetToUpdate.bytes)
assertEquals(bytes, packetToUpdate.bytes)
assertEquals(42, packetToUpdate.dataType)
assertEquals("sender", packetToUpdate.from)
assertEquals(123456789L, packetToUpdate.time)
@ -132,7 +134,7 @@ class DataPacketTest {
assertEquals(true, packetToUpdate.viaMqtt)
assertEquals(1, packetToUpdate.retryCount)
assertEquals(10, packetToUpdate.emoji)
assertArrayEquals(byteArrayOf(4, 5, 6), packetToUpdate.sfppHash)
assertEquals(sfppHash, packetToUpdate.sfppHash)
parcel.recycle()
}

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.model
import androidx.core.os.LocaleListCompat
@ -22,12 +21,12 @@ import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
import java.util.Locale
class NodeInfoTest {
private val model = MeshProtos.HardwareModel.ANDROID_SIM
private val model = HardwareModel.ANDROID_SIM
private val node =
listOf(
NodeInfo(4, MeshUser("+zero", "User Zero", "U0", model)),
@ -58,9 +57,9 @@ class NodeInfoTest {
@Test
fun distanceStrGood() {
Assert.assertEquals(node[1].distanceStr(node[2], DisplayUnits.METRIC_VALUE), "1.1 km")
Assert.assertEquals(node[1].distanceStr(node[3], DisplayUnits.METRIC_VALUE), "111 m")
Assert.assertEquals(node[1].distanceStr(node[4], DisplayUnits.IMPERIAL_VALUE), "1.1 mi")
Assert.assertEquals(node[1].distanceStr(node[3], DisplayUnits.IMPERIAL_VALUE), "364 ft")
Assert.assertEquals(node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value), "1.1 km")
Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value), "111 m")
Assert.assertEquals(node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "1.1 mi")
Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "364 ft")
}
}

View file

@ -0,0 +1,332 @@
/*
* 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import co.touchlab.kermit.Logger
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.Position
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
/**
* Unit tests for Wire extension functions.
*
* Tests safe decoding, size validation, and JSON marshalling extensions to ensure proper error handling and
* functionality.
*/
class WireExtensionsTest {
private val testLogger = Logger
@Before
fun setUp() {
// Setup test logger if needed
}
// ===== decodeOrNull() Tests =====
@Test
fun `decodeOrNull with valid ByteString returns decoded message`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15)
val encoded = Position.ADAPTER.encode(position)
val byteString = encoded.toByteString()
// Act
val decoded = Position.ADAPTER.decodeOrNull(byteString, testLogger)
// Assert
assertNotNull(decoded)
assertEquals(position.latitude_i, decoded!!.latitude_i)
assertEquals(position.longitude_i, decoded.longitude_i)
assertEquals(position.altitude, decoded.altitude)
}
@Test
fun `decodeOrNull with null ByteString returns null`() {
// Act
val result = Position.ADAPTER.decodeOrNull(null as ByteString?, testLogger)
// Assert
assertNull(result)
}
@Test
fun `decodeOrNull with empty ByteString returns null`() {
// Act
val result = Position.ADAPTER.decodeOrNull(ByteString.EMPTY, testLogger)
// Assert
assertNull(result)
}
@Test
fun `decodeOrNull with valid ByteArray returns decoded message`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
val encoded = Position.ADAPTER.encode(position)
// Act
val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger)
// Assert
assertNotNull(decoded)
assertEquals(position.latitude_i, decoded!!.latitude_i)
assertEquals(position.longitude_i, decoded.longitude_i)
}
@Test
fun `decodeOrNull with null ByteArray returns null`() {
// Act
val result = Position.ADAPTER.decodeOrNull(null as ByteArray?, testLogger)
// Assert
assertNull(result)
}
@Test
fun `decodeOrNull with empty ByteArray returns null`() {
// Act
val result = Position.ADAPTER.decodeOrNull(ByteArray(0), testLogger)
// Assert
assertNull(result)
}
@Test
fun `decodeOrNull with invalid data returns null`() {
// Arrange
val invalidBytes = byteArrayOfInts(0xFF, 0xFF, 0xFF, 0xFF).toByteString()
// Act - should not throw, should return null
val result = Position.ADAPTER.decodeOrNull(invalidBytes, testLogger)
// Assert
assertNull(result)
}
// ===== Size Validation Tests =====
@Test
fun `isWithinSizeLimit returns true for message under limit`() {
// Arrange
val position = Position(latitude_i = 371234567)
val limit = 1000
// Act
val isValid = Position.ADAPTER.isWithinSizeLimit(position, limit)
// Assert
assertTrue(isValid)
}
@Test
fun `isWithinSizeLimit returns false for message over limit`() {
// Arrange
val telemetry =
Telemetry(
device_metrics =
DeviceMetrics(voltage = 4.2f, battery_level = 85, air_util_tx = 5.0f, channel_utilization = 15.0f),
)
val limit = 1 // Artificially low limit
// Act
val isValid = Telemetry.ADAPTER.isWithinSizeLimit(telemetry, limit)
// Assert
assertEquals(false, isValid)
}
@Test
fun `sizeInBytes returns accurate encoded size`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
// Act
val size = Position.ADAPTER.sizeInBytes(position)
val actualEncoded = Position.ADAPTER.encode(position)
// Assert
assertEquals(actualEncoded.size, size)
assertTrue(size > 0)
}
@Test
fun `sizeInBytes for empty message`() {
// Arrange
val emptyPosition = Position()
// Act
val size = Position.ADAPTER.sizeInBytes(emptyPosition)
// Assert
assertTrue(size >= 0)
}
@Test
fun `sizeInBytes matches wire encoding size`() {
// Arrange
val user = User(id = "12345", long_name = "Test User", short_name = "TU")
// Act
val extensionSize = User.ADAPTER.sizeInBytes(user)
val actualEncoded = User.ADAPTER.encode(user)
// Assert
assertEquals(extensionSize, actualEncoded.size)
}
// ===== JSON Marshalling Tests =====
@Test
fun `toReadableString returns non-empty string`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
// Act
val readable = Position.ADAPTER.toReadableString(position)
// Assert
assertNotNull(readable)
assertTrue(readable.isNotEmpty())
assertTrue(readable.contains("Position"))
}
@Test
fun `toReadableString contains field values`() {
// Arrange
val position = Position(latitude_i = 12345, longitude_i = 67890)
// Act
val readable = Position.ADAPTER.toReadableString(position)
// Assert
assertTrue(readable.contains("12345"))
assertTrue(readable.contains("67890"))
}
@Test
fun `toOneLiner returns single line string`() {
// Arrange
val telemetry = Telemetry(device_metrics = DeviceMetrics(voltage = 4.2f))
// Act
val oneLiner = Telemetry.ADAPTER.toOneLiner(telemetry)
// Assert
assertNotNull(oneLiner)
assertEquals(false, oneLiner.contains("\n"))
assertTrue(oneLiner.isNotEmpty())
}
@Test
fun `toOneLiner contains essential data`() {
// Arrange
val user = User(long_name = "Test User")
// Act
val oneLiner = User.ADAPTER.toOneLiner(user)
// Assert
assertTrue(oneLiner.contains("Test User"))
}
// ===== Integration Tests =====
@Test
fun `decode and encode roundtrip maintains data`() {
// Arrange
val originalPosition =
Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15, precision_bits = 5)
val encoded = Position.ADAPTER.encode(originalPosition)
// Act
val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger)
// Assert
assertNotNull(decoded)
assertEquals(originalPosition.latitude_i, decoded!!.latitude_i)
assertEquals(originalPosition.longitude_i, decoded.longitude_i)
assertEquals(originalPosition.altitude, decoded.altitude)
assertEquals(originalPosition.precision_bits, decoded.precision_bits)
}
@Test
fun `size checking prevents oversized messages`() {
// Arrange
val position = Position(latitude_i = 123456789, longitude_i = 987654321, altitude = 100)
val maxSize = 5 // Very small limit
// Act
val isValid = Position.ADAPTER.isWithinSizeLimit(position, maxSize)
val actualSize = Position.ADAPTER.sizeInBytes(position)
// Assert
assertEquals(false, isValid)
assertTrue(actualSize > maxSize)
}
@Test
fun `multiple messages with different sizes`() {
// Arrange
val smallUser = User(short_name = "A")
val largeUser = User(long_name = "Very Long Name " + "X".repeat(100))
// Act
val smallSize = User.ADAPTER.sizeInBytes(smallUser)
val largeSize = User.ADAPTER.sizeInBytes(largeUser)
// Assert
assertTrue(smallSize < largeSize)
assertTrue(largeSize > smallSize)
}
@Test
fun `readable string format consistency`() {
// Arrange
val position = Position(latitude_i = 123456)
// Act
val readable1 = Position.ADAPTER.toReadableString(position)
val readable2 = Position.ADAPTER.toReadableString(position)
// Assert
assertEquals(readable1, readable2)
}
@Test
fun `oneLiner format consistency`() {
// Arrange
val user = User(long_name = "Test")
// Act
val line1 = User.ADAPTER.toOneLiner(user)
val line2 = User.ADAPTER.toOneLiner(user)
// Assert
assertEquals(line1, line2)
assertEquals(false, line1.contains("\n"))
}
}