feat/decoupling (#4685)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-03 07:15:28 -06:00 committed by GitHub
parent 40244f8337
commit 2c49db8041
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
254 changed files with 5132 additions and 2666 deletions

View file

@ -31,7 +31,7 @@ class ChannelSetTest {
val url = Uri.parse("https://meshtastic.org/e/#CgMSAQESBggBQANIAQ")
val cs = url.toChannelSet()
Assert.assertEquals("LongFast", cs.primaryChannel!!.name)
Assert.assertEquals(url, cs.getChannelUrl(false))
Assert.assertEquals(url.toString(), cs.getChannelUrl(false).toString())
}
/** validate against the host or path in a case-insensitive way */

View file

@ -56,11 +56,43 @@ class SharedContactTest {
assertEquals("Suzume", contact.user?.long_name)
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidHostThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com")
val url = Uri.parse(urlStr)
url.toSharedContact()
}
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidPathThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/wrong/")
val url = Uri.parse(urlStr)
url.toSharedContact()
}
@Test(expected = MalformedMeshtasticUrlException::class)
fun testMissingFragmentThrows() {
val urlStr = "https://meshtastic.org/v/"
val url = Uri.parse(urlStr)
url.toSharedContact()
}
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidBase64Throws() {
val urlStr = "https://meshtastic.org/v/#InvalidBase64!!!!"
val url = Uri.parse(urlStr)
url.toSharedContact()
}
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidProtoThrows() {
// Tag 0 is invalid in Protobuf
// 0x00 -> Tag 0, Type 0.
// Base64 for 0x00 is "AA=="
val urlStr = "https://meshtastic.org/v/#AA=="
val url = Uri.parse(urlStr)
url.toSharedContact()
}
}

View file

@ -0,0 +1,95 @@
/*
* 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 io.mockk.every
import io.mockk.mockk
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
class MeshDataMapperTest {
private val nodeIdLookup: NodeIdLookup = mockk()
private lateinit var mapper: MeshDataMapper
@Before
fun setUp() {
mapper = MeshDataMapper(nodeIdLookup)
}
@Test
fun `toDataPacket returns null when no decoded data`() {
val packet = MeshPacket()
assertNull(mapper.toDataPacket(packet))
}
@Test
fun `toDataPacket maps basic fields correctly`() {
val nodeNum = 1234
val nodeId = "!1234abcd"
every { nodeIdLookup.toNodeID(nodeNum) } returns nodeId
every { nodeIdLookup.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST
val proto =
MeshPacket(
id = 42,
from = nodeNum,
to = DataPacket.NODENUM_BROADCAST,
rx_time = 1600000000,
rx_snr = 5.5f,
rx_rssi = -100,
hop_limit = 3,
hop_start = 3,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "hello".encodeToByteArray().toByteString(),
reply_id = 123,
),
)
val result = mapper.toDataPacket(proto)
assertNotNull(result)
assertEquals(42, result!!.id)
assertEquals(nodeId, result.from)
assertEquals(DataPacket.ID_BROADCAST, result.to)
assertEquals(1600000000000L, result.time)
assertEquals(5.5f, result.snr)
assertEquals(-100, result.rssi)
assertEquals(PortNum.TEXT_MESSAGE_APP.value, result.dataType)
assertEquals("hello", result.bytes?.utf8())
assertEquals(123, result.replyId)
}
@Test
fun `toDataPacket maps PKC channel correctly for encrypted packets`() {
val proto = MeshPacket(pki_encrypted = true, channel = 1, decoded = Data())
every { nodeIdLookup.toNodeID(any()) } returns "any"
val result = mapper.toDataPacket(proto)
assertEquals(DataPacket.PKC_CHANNEL_INDEX, result!!.channel)
}
}

View file

@ -103,9 +103,6 @@ enum class RegionInfo(
val freqEnd: Float,
val wideLora: Boolean = false,
) {
/** This needs to be last. Same as US. */
UNSET(RegionCode.UNSET, "Please set a region", 902.0f, 928.0f),
/**
* United States
*
@ -288,6 +285,9 @@ enum class RegionInfo(
* @see [Firmware Issue #7399](https://github.com/meshtastic/firmware/pull/7399)
*/
BR_902(RegionCode.BR_902, "Brazil 902MHz", 902.0f, 907.5f, wideLora = false),
/** This needs to be last. Same as US. */
UNSET(RegionCode.UNSET, "Please set a region", 902.0f, 928.0f),
;
companion object {

View file

@ -32,3 +32,12 @@ data class Contact(
val isUnmessageable: Boolean,
val nodeColors: Pair<Int, Int>? = null,
) : CommonParcelable
data class ContactSettings(
val contactKey: String,
val muteUntil: Long = 0L,
val lastReadMessageUuid: Long? = null,
val lastReadMessageTimestamp: Long? = null,
val filteringDisabled: Boolean = false,
val isMuted: Boolean = false,
)

View file

@ -0,0 +1,31 @@
/*
* 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
/** Address identifiers for all supported radio backend implementations. */
enum class InterfaceId(val id: Char) {
BLUETOOTH('x'),
MOCK('m'),
NOP('n'),
SERIAL('s'),
TCP('t'),
;
companion object {
fun forIdChar(id: Char): InterfaceId? = entries.firstOrNull { it.id == id }
}
}

View file

@ -0,0 +1,26 @@
/*
* 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
/** Represents activity on the mesh network. */
sealed class MeshActivity {
/** Data is being sent to the radio. */
data object Send : MeshActivity()
/** Data is being received from the radio. */
data object Receive : MeshActivity()
}

View file

@ -0,0 +1,110 @@
/*
* 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 org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.delivery_confirmed
import org.meshtastic.core.resources.error
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.message_status_enroute
import org.meshtastic.core.resources.message_status_queued
import org.meshtastic.core.resources.message_status_sfpp_confirmed
import org.meshtastic.core.resources.message_status_sfpp_routing
import org.meshtastic.core.resources.routing_error_admin_bad_session_key
import org.meshtastic.core.resources.routing_error_admin_public_key_unauthorized
import org.meshtastic.core.resources.routing_error_bad_request
import org.meshtastic.core.resources.routing_error_duty_cycle_limit
import org.meshtastic.core.resources.routing_error_got_nak
import org.meshtastic.core.resources.routing_error_max_retransmit
import org.meshtastic.core.resources.routing_error_no_channel
import org.meshtastic.core.resources.routing_error_no_interface
import org.meshtastic.core.resources.routing_error_no_response
import org.meshtastic.core.resources.routing_error_no_route
import org.meshtastic.core.resources.routing_error_none
import org.meshtastic.core.resources.routing_error_not_authorized
import org.meshtastic.core.resources.routing_error_pki_failed
import org.meshtastic.core.resources.routing_error_pki_send_fail_public_key
import org.meshtastic.core.resources.routing_error_pki_unknown_pubkey
import org.meshtastic.core.resources.routing_error_rate_limit_exceeded
import org.meshtastic.core.resources.routing_error_timeout
import org.meshtastic.core.resources.routing_error_too_large
import org.meshtastic.core.resources.unrecognized
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Routing
@Suppress("CyclomaticComplexMethod")
fun getStringResFrom(routingError: Int): StringResource = when (routingError) {
Routing.Error.NONE.value -> Res.string.routing_error_none
Routing.Error.NO_ROUTE.value -> Res.string.routing_error_no_route
Routing.Error.GOT_NAK.value -> Res.string.routing_error_got_nak
Routing.Error.TIMEOUT.value -> Res.string.routing_error_timeout
Routing.Error.NO_INTERFACE.value -> Res.string.routing_error_no_interface
Routing.Error.MAX_RETRANSMIT.value -> Res.string.routing_error_max_retransmit
Routing.Error.NO_CHANNEL.value -> Res.string.routing_error_no_channel
Routing.Error.TOO_LARGE.value -> Res.string.routing_error_too_large
Routing.Error.NO_RESPONSE.value -> Res.string.routing_error_no_response
Routing.Error.DUTY_CYCLE_LIMIT.value -> Res.string.routing_error_duty_cycle_limit
Routing.Error.BAD_REQUEST.value -> Res.string.routing_error_bad_request
Routing.Error.NOT_AUTHORIZED.value -> Res.string.routing_error_not_authorized
Routing.Error.PKI_FAILED.value -> Res.string.routing_error_pki_failed
Routing.Error.PKI_UNKNOWN_PUBKEY.value -> Res.string.routing_error_pki_unknown_pubkey
Routing.Error.ADMIN_BAD_SESSION_KEY.value -> Res.string.routing_error_admin_bad_session_key
Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED.value -> Res.string.routing_error_admin_public_key_unauthorized
Routing.Error.RATE_LIMIT_EXCEEDED.value -> Res.string.routing_error_rate_limit_exceeded
Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value -> Res.string.routing_error_pki_send_fail_public_key
else -> Res.string.unrecognized
}
data class Message(
val uuid: Long,
val receivedTime: Long,
val node: Node,
val text: String,
val fromLocal: Boolean,
val time: String,
val read: Boolean,
val status: MessageStatus?,
val routingError: Int,
val packetId: Int,
val emojis: List<Reaction>,
val snr: Float,
val rssi: Int,
val hopsAway: Int,
val replyId: Int?,
val originalMessage: Message? = null,
val viaMqtt: Boolean = false,
val relayNode: Int? = null,
val relays: Int = 0,
val filtered: Boolean = false,
/** The transport mechanism this packet arrived over (see [MeshPacket.TransportMechanism]). */
val transportMechanism: Int = 0,
) {
fun getStatusStringRes(): Pair<StringResource, StringResource> {
val title = if (routingError > 0) Res.string.error else Res.string.message_delivery_status
val text =
when (status) {
MessageStatus.RECEIVED -> Res.string.delivery_confirmed
MessageStatus.QUEUED -> Res.string.message_status_queued
MessageStatus.ENROUTE -> Res.string.message_status_enroute
MessageStatus.SFPP_ROUTING -> Res.string.message_status_sfpp_routing
MessageStatus.SFPP_CONFIRMED -> Res.string.message_status_sfpp_confirmed
else -> getStringResFrom(routingError)
}
return title to text
}
}

View file

@ -0,0 +1,239 @@
/*
* 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 okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.GPSFormat
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Position
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.User
/**
* Domain model representing a node in the mesh network.
*
* This class aggregates user information, position data, and hardware metrics.
*/
@Suppress("MagicNumber")
data class Node(
val num: Int,
val metadata: DeviceMetadata? = null,
val user: User = User(),
val position: Position = Position(),
val snr: Float = Float.MAX_VALUE,
val rssi: Int = Int.MAX_VALUE,
val lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
val deviceMetrics: DeviceMetrics = DeviceMetrics(),
val channel: Int = 0,
val viaMqtt: Boolean = false,
val hopsAway: Int = -1,
val isFavorite: Boolean = false,
val isIgnored: Boolean = false,
val isMuted: Boolean = false,
val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics(),
val powerMetrics: PowerMetrics = PowerMetrics(),
val paxcounter: Paxcount = Paxcount(),
val publicKey: ByteString? = null,
val notes: String = "",
val manuallyVerified: Boolean = false,
val nodeStatus: String? = null,
/** The transport mechanism this node was last heard over (see [MeshPacket.TransportMechanism]). */
val lastTransport: Int = 0,
) {
val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) }
val isOnline: Boolean
get() = lastHeard > onlineTimeThreshold()
val colors: Pair<Int, Int>
get() { // returns foreground and background @ColorInt for each 'num'
val r = (num and 0xFF0000) shr 16
val g = (num and 0x00FF00) shr 8
val b = num and 0x0000FF
val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255
val foreground = if (brightness > 0.5) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
val background = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
return foreground to background
}
val isUnknownUser
get() = user.hw_model == HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true
val mismatchKey
get() = (publicKey ?: user.public_key) == ERROR_BYTE_STRING
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != EnvironmentMetrics()
val hasPowerMetrics: Boolean
get() = powerMetrics != PowerMetrics()
val batteryLevel
get() = deviceMetrics.battery_level
val voltage
get() = deviceMetrics.voltage
val batteryStr
get() = if ((batteryLevel ?: 0) in 1..100) "$batteryLevel%" else ""
val latitude
get() = (position.latitude_i ?: 0) * 1e-7
val longitude
get() = (position.longitude_i ?: 0) * 1e-7
private fun hasValidPosition(): Boolean = latitude != 0.0 &&
longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
val validPosition: Position?
get() = position.takeIf { hasValidPosition() }
// @return distance in meters to some other node (or null if unknown)
fun distance(o: Node): Int? = when {
validPosition == null || o.validPosition == null -> null
else -> latLongToMeter(latitude, longitude, o.latitude, o.longitude).toInt()
}
// @return formatted distance string to another node, using the given display units
fun distanceStr(o: Node, displayUnits: Config.DisplayConfig.DisplayUnits): String? =
distance(o)?.toDistanceString(displayUnits)
// @return bearing to the other position in degrees
fun bearing(o: Node?): Int? = when {
validPosition == null || o?.validPosition == null -> null
else -> bearing(latitude, longitude, o.latitude, o.longitude).toInt()
}
fun gpsString(): String = GPSFormat.toDec(latitude, longitude)
@Suppress("CyclomaticComplexMethod")
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List<String> {
val temp =
if ((temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(temperature ?: 0f))
} else {
"%.1f°C".format(temperature)
}
} else {
null
}
val humidity = if ((relative_humidity ?: 0f) != 0f) "%.0f%%".format(relative_humidity) else null
val soilTemperatureStr =
if ((soil_temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(soil_temperature ?: 0f))
} else {
"%.1f°C".format(soil_temperature)
}
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
"%d%%".format(soil_moisture)
} else {
null
}
val voltage = if ((this.voltage ?: 0f) != 0f) "%.2fV".format(this.voltage) else null
val current = if ((current ?: 0f) != 0f) "%.1fmA".format(current) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(
paxcounter.getDisplayString(),
temp,
humidity,
soilTemperatureStr,
soilMoisture,
voltage,
current,
iaq,
)
}
private fun Paxcount.getDisplayString() =
"PAX: ${(ble ?: 0) + (wifi ?: 0)} (B:${ble ?: 0}/W:${wifi ?: 0})".takeIf { (ble ?: 0) != 0 || (wifi ?: 0) != 0 }
fun getTelemetryStrings(isFahrenheit: Boolean = false): List<String> =
environmentMetrics.getDisplayStrings(isFahrenheit)
companion object {
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
private const val RELAY_NODE_SUFFIX_MASK = 0xFF
val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString()
fun getRelayNode(relayNodeId: Int, nodes: List<Node>, ourNodeNum: Int?): Node? {
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
val candidateRelayNodes =
nodes.filter {
it.num != ourNodeNum &&
it.lastHeard != 0 &&
(it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
}
val closestRelayNode =
if (candidateRelayNodes.size == 1) {
candidateRelayNodes.first()
} else {
candidateRelayNodes.minByOrNull { it.hopsAway }
}
return closestRelayNode
}
/** Creates a fallback [Node] when the node is not found in the database. */
fun createFallback(nodeNum: Int, fallbackNamePrefix: String): Node {
val userId = DataPacket.nodeNumToDefaultId(nodeNum)
val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH)
val longName = "$fallbackNamePrefix $safeUserId"
val defaultUser =
User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET)
return Node(num = nodeNum, user = defaultUser)
}
}
}
fun Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
listOf(
Config.DeviceConfig.Role.REPEATER,
Config.DeviceConfig.Role.ROUTER,
Config.DeviceConfig.Role.ROUTER_LATE,
Config.DeviceConfig.Role.SENSOR,
Config.DeviceConfig.Role.TRACKER,
Config.DeviceConfig.Role.TAK_TRACKER,
)

View file

@ -0,0 +1,37 @@
/*
* 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 org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.node_sort_alpha
import org.meshtastic.core.resources.node_sort_channel
import org.meshtastic.core.resources.node_sort_distance
import org.meshtastic.core.resources.node_sort_hops_away
import org.meshtastic.core.resources.node_sort_last_heard
import org.meshtastic.core.resources.node_sort_via_favorite
import org.meshtastic.core.resources.node_sort_via_mqtt
enum class NodeSortOption(val sqlValue: String, val stringRes: StringResource) {
LAST_HEARD("last_heard", Res.string.node_sort_last_heard),
ALPHABETICAL("alpha", Res.string.node_sort_alpha),
DISTANCE("distance", Res.string.node_sort_distance),
HOPS_AWAY("hops_away", Res.string.node_sort_hops_away),
CHANNEL("channel", Res.string.node_sort_channel),
VIA_MQTT("via_mqtt", Res.string.node_sort_via_mqtt),
VIA_FAVORITE("via_favorite", Res.string.node_sort_via_favorite),
}

View file

@ -19,67 +19,299 @@ package org.meshtastic.core.model
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.proto.ClientNotification
/**
* Central interface for controlling the radio and mesh network.
*
* This component provides an abstraction over the underlying communication transport (e.g., BLE, Serial, TCP) and the
* low-level mesh protocols. It allows feature modules to interact with the mesh without needing to know about
* platform-specific service details or AIDL interfaces.
*/
@Suppress("TooManyFunctions")
interface RadioController {
/** Reactive connection state of the radio. */
val connectionState: StateFlow<ConnectionState>
/**
* Flow of notifications from the radio client.
*
* These represent high-level events like "Handshake completed" or "Channel configuration updated."
*/
val clientNotification: StateFlow<ClientNotification?>
/**
* Sends a data packet to the mesh.
*
* @param packet The [DataPacket] containing the payload and routing information.
*/
suspend fun sendMessage(packet: DataPacket)
/** Clears the current [clientNotification]. */
fun clearClientNotification()
// Abstracted ServiceActions
/**
* Toggles the favorite status of a node on the radio.
*
* @param nodeNum The node number to favorite/unfavorite.
*/
suspend fun favoriteNode(nodeNum: Int)
/**
* Sends our shared contact information (identity and public key) to a remote node.
*
* @param nodeNum The destination node number.
*/
suspend fun sendSharedContact(nodeNum: Int)
// Radio configuration
/**
* Updates the local radio configuration.
*
* @param config The new configuration [org.meshtastic.proto.Config].
*/
suspend fun setLocalConfig(config: org.meshtastic.proto.Config)
/**
* Updates a local radio channel.
*
* @param channel The channel configuration [org.meshtastic.proto.Channel].
*/
suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel)
/**
* Updates the owner (user info) on a remote node.
*
* @param destNum The destination node number.
* @param user The new user info [org.meshtastic.proto.User].
* @param packetId The request packet ID.
*/
suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int)
/**
* Updates the general configuration on a remote node.
*
* @param destNum The destination node number.
* @param config The new configuration [org.meshtastic.proto.Config].
* @param packetId The request packet ID.
*/
suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int)
/**
* Updates a module configuration on a remote node.
*
* @param destNum The destination node number.
* @param config The new module configuration [org.meshtastic.proto.ModuleConfig].
* @param packetId The request packet ID.
*/
suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int)
/**
* Updates a channel configuration on a remote node.
*
* @param destNum The destination node number.
* @param channel The new channel configuration [org.meshtastic.proto.Channel].
* @param packetId The request packet ID.
*/
suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int)
/**
* Sets a fixed position on a remote node.
*
* @param destNum The destination node number.
* @param position The position to set.
*/
suspend fun setFixedPosition(destNum: Int, position: Position)
/**
* Updates the notification ringtone on a remote node.
*
* @param destNum The destination node number.
* @param ringtone The name/ID of the ringtone.
*/
suspend fun setRingtone(destNum: Int, ringtone: String)
/**
* Updates the canned messages configuration on a remote node.
*
* @param destNum The destination node number.
* @param messages The canned messages string.
*/
suspend fun setCannedMessages(destNum: Int, messages: String)
// Admin get operations
/**
* Requests the current owner (user info) from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getOwner(destNum: Int, packetId: Int)
/**
* Requests a specific configuration section from a remote node.
*
* @param destNum The remote node number.
* @param configType The numeric type of the configuration section.
* @param packetId The request packet ID.
*/
suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
/**
* Requests a module configuration section from a remote node.
*
* @param destNum The remote node number.
* @param moduleConfigType The numeric type of the module configuration section.
* @param packetId The request packet ID.
*/
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
/**
* Requests a specific channel configuration from a remote node.
*
* @param destNum The remote node number.
* @param index The channel index.
* @param packetId The request packet ID.
*/
suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
/**
* Requests the current ringtone from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getRingtone(destNum: Int, packetId: Int)
/**
* Requests the current canned messages from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getCannedMessages(destNum: Int, packetId: Int)
/**
* Requests the hardware connection status from a remote node.
*
* @param destNum The remote node number.
* @param packetId The request packet ID.
*/
suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
// Admin operations
/**
* Commands a node to reboot.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
*/
suspend fun reboot(destNum: Int, packetId: Int)
/**
* Commands a node to reboot into DFU (Device Firmware Update) mode.
*
* @param nodeNum The target node number.
*/
suspend fun rebootToDfu(nodeNum: Int)
/**
* Initiates an Over-The-Air (OTA) reboot request.
*
* @param requestId The request ID.
* @param destNum The target node number.
* @param mode The OTA mode.
* @param hash Optional hash for verification.
*/
suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
/**
* Commands a node to shut down.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
*/
suspend fun shutdown(destNum: Int, packetId: Int)
/**
* Performs a factory reset on a node.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
*/
suspend fun factoryReset(destNum: Int, packetId: Int)
/**
* Resets the NodeDB on a node.
*
* @param destNum The target node number.
* @param packetId The request packet ID.
* @param preserveFavorites Whether to keep favorite nodes in the database.
*/
suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
/**
* Removes a node from the mesh by its node number.
*
* @param packetId The request packet ID.
* @param nodeNum The node number to remove.
*/
suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
// Batch editing
/**
* Requests the current GPS position from a remote node.
*
* @param destNum The target node number.
* @param currentPosition Our current position to provide in the request.
*/
suspend fun requestPosition(destNum: Int, currentPosition: Position)
/**
* Requests detailed user info from a remote node.
*
* @param destNum The target node number.
*/
suspend fun requestUserInfo(destNum: Int)
/**
* Initiates a traceroute request to a remote node.
*
* @param requestId The request ID.
* @param destNum The destination node number.
*/
suspend fun requestTraceroute(requestId: Int, destNum: Int)
/**
* Requests telemetry data from a remote node.
*
* @param requestId The request ID.
* @param destNum The destination node number.
* @param typeValue The numeric type of telemetry requested.
*/
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
/**
* Requests neighbor information (detected nodes) from a remote node.
*
* @param requestId The request ID.
* @param destNum The destination node number.
*/
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
/**
* Signals the start of a batch configuration session.
*
* @param destNum The target node number.
*/
suspend fun beginEditSettings(destNum: Int)
/**
* Commits all pending configuration changes in a batch session.
*
* @param destNum The target node number.
*/
suspend fun commitEditSettings(destNum: Int)
// Helpers
/**
* Generates a unique packet ID for a new request.
*
* @return A unique 32-bit integer.
*/
fun getPacketId(): Int
/** Starts providing the phone's location to the mesh. */
@ -87,4 +319,11 @@ interface RadioController {
/** Stops providing the phone's location to the mesh. */
fun stopProvideLocation()
/**
* Changes the device address (e.g., BLE MAC, IP address) we are communicating with.
*
* @param address The new device identifier.
*/
fun setDeviceAddress(address: String)
}

View file

@ -0,0 +1,20 @@
/*
* 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
/** Exception thrown when an operation is attempted while not connected to a mesh radio. */
open class RadioNotConnectedException(message: String = "Not connected to radio") : Exception(message)

View file

@ -0,0 +1,38 @@
/*
* 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 okio.ByteString
import org.meshtastic.proto.User
data class Reaction(
val replyId: Int,
val user: User,
val emoji: String,
val timestamp: Long,
val snr: Float,
val rssi: Int,
val hopsAway: Int,
val packetId: Int = 0,
val status: MessageStatus = MessageStatus.UNKNOWN,
val routingError: Int = 0,
val relays: Int = 0,
val relayNode: Int? = null,
val to: String? = null,
val channel: Int = 0,
val sfppHash: ByteString? = null,
)

View file

@ -0,0 +1,96 @@
/*
* 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 org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.tak_role_forwardobserver
import org.meshtastic.core.resources.tak_role_hq
import org.meshtastic.core.resources.tak_role_k9
import org.meshtastic.core.resources.tak_role_medic
import org.meshtastic.core.resources.tak_role_rto
import org.meshtastic.core.resources.tak_role_sniper
import org.meshtastic.core.resources.tak_role_teamlead
import org.meshtastic.core.resources.tak_role_teammember
import org.meshtastic.core.resources.tak_role_unspecified
import org.meshtastic.core.resources.tak_team_blue
import org.meshtastic.core.resources.tak_team_brown
import org.meshtastic.core.resources.tak_team_cyan
import org.meshtastic.core.resources.tak_team_dark_blue
import org.meshtastic.core.resources.tak_team_dark_green
import org.meshtastic.core.resources.tak_team_green
import org.meshtastic.core.resources.tak_team_magenta
import org.meshtastic.core.resources.tak_team_maroon
import org.meshtastic.core.resources.tak_team_orange
import org.meshtastic.core.resources.tak_team_purple
import org.meshtastic.core.resources.tak_team_red
import org.meshtastic.core.resources.tak_team_teal
import org.meshtastic.core.resources.tak_team_unspecified_color
import org.meshtastic.core.resources.tak_team_white
import org.meshtastic.core.resources.tak_team_yellow
import org.meshtastic.proto.MemberRole
import org.meshtastic.proto.Team
@Suppress("CyclomaticComplexMethod")
fun getStringResFrom(team: Team): StringResource = when (team) {
Team.Unspecifed_Color -> Res.string.tak_team_unspecified_color
Team.White -> Res.string.tak_team_white
Team.Yellow -> Res.string.tak_team_yellow
Team.Orange -> Res.string.tak_team_orange
Team.Magenta -> Res.string.tak_team_magenta
Team.Red -> Res.string.tak_team_red
Team.Maroon -> Res.string.tak_team_maroon
Team.Purple -> Res.string.tak_team_purple
Team.Dark_Blue -> Res.string.tak_team_dark_blue
Team.Blue -> Res.string.tak_team_blue
Team.Cyan -> Res.string.tak_team_cyan
Team.Teal -> Res.string.tak_team_teal
Team.Green -> Res.string.tak_team_green
Team.Dark_Green -> Res.string.tak_team_dark_green
Team.Brown -> Res.string.tak_team_brown
}
fun getStringResFrom(role: MemberRole): StringResource = when (role) {
MemberRole.Unspecifed -> Res.string.tak_role_unspecified
MemberRole.TeamMember -> Res.string.tak_role_teammember
MemberRole.TeamLead -> Res.string.tak_role_teamlead
MemberRole.HQ -> Res.string.tak_role_hq
MemberRole.Sniper -> Res.string.tak_role_sniper
MemberRole.Medic -> Res.string.tak_role_medic
MemberRole.ForwardObserver -> Res.string.tak_role_forwardobserver
MemberRole.RTO -> Res.string.tak_role_rto
MemberRole.K9 -> Res.string.tak_role_k9
}
@Suppress("CyclomaticComplexMethod", "MagicNumber")
fun getColorFrom(team: Team): Long = when (team) {
Team.Unspecifed_Color -> 0xFF00FFFF // Default to Cyan
Team.White -> 0xFFFFFFFF
Team.Yellow -> 0xFFFFFF00
Team.Orange -> 0xFFFFA500
Team.Magenta -> 0xFFFF00FF
Team.Red -> 0xFFFF0000
Team.Maroon -> 0xFF800000
Team.Purple -> 0xFF800080
Team.Dark_Blue -> 0xFF00008B
Team.Blue -> 0xFF0000FF
Team.Cyan -> 0xFF00FFFF
Team.Teal -> 0xFF008080
Team.Green -> 0xFF00FF00
Team.Dark_Green -> 0xFF006400
Team.Brown -> 0xFFA52A2A
}

View file

@ -0,0 +1,36 @@
/*
* 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.service
import org.meshtastic.core.model.Node
import org.meshtastic.proto.SharedContact
sealed class ServiceAction {
data class GetDeviceMetadata(val destNum: Int) : ServiceAction()
data class Favorite(val node: Node) : ServiceAction()
data class Ignore(val node: Node) : ServiceAction()
data class Mute(val node: Node) : ServiceAction()
data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
data class ImportContact(val contact: SharedContact) : ServiceAction()
data class SendContact(val contact: SharedContact) : ServiceAction()
}

View file

@ -0,0 +1,29 @@
/*
* 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.service
data class TracerouteResponse(
val message: String,
val destinationNodeNum: Int,
val requestId: Int,
val forwardRoute: List<Int> = emptyList(),
val returnRoute: List<Int> = emptyList(),
val logUuid: String? = null,
) {
val hasOverlay: Boolean
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
}

View file

@ -83,7 +83,7 @@ fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null
*/
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): CommonUri {
val channelBytes = ChannelSet.ADAPTER.encode(this)
val enc = channelBytes.toByteString().base64Url()
val enc = channelBytes.toByteString().base64Url().replace("=", "")
val p = if (upperCasePrefix) CHANNEL_URL_PREFIX.uppercase() else CHANNEL_URL_PREFIX
val query = if (shouldAdd) "?add=true" else ""
return CommonUri.parse("$p$query#$enc")