mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(wire): migrate from protobuf -> wire (#4401)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
9dbc8b7fbf
commit
25657e8f8f
239 changed files with 7149 additions and 6144 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', ' ')
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue