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

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