From b4811692cd8ba7d657d09b1a09969456d2692650 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 2 May 2026 19:04:28 -0500 Subject: [PATCH 01/36] build: fix axionRelease plugin for composite build inclusion Guard axionRelease plugin with gradle.parent == null so the SDK can be included as a Gradle composite build (e.g. from Meshtastic-Android) without the plugin erroring out trying to access the root project too early. Resolves UnknownDomainObjectException when the build is consumed as a composite dependency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.gradle.kts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 598ffb0..b9651df 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,17 +16,29 @@ plugins { alias(libs.plugins.spotless) alias(libs.plugins.detekt) apply false alias(libs.plugins.kover) - alias(libs.plugins.axionRelease) + // axionRelease applied conditionally below — it fails to apply when this build is + // included as a Gradle composite build because the root project is not yet available. + alias(libs.plugins.axionRelease) apply false } -scmVersion { - tag { - prefix.set("v") +// Only configure SCM versioning when running as a standalone build. When included as a +// composite build (gradle.parent != null), version management is unnecessary and the plugin +// errors out trying to reach the root project too early. +if (gradle.parent == null) { + apply(plugin = "pl.allegro.tech.build.axion-release") + configure { + tag { + prefix.set("v") + } + versionIncrementer("incrementPatch") } - versionIncrementer("incrementPatch") } -val resolvedVersion: String = scmVersion.version +val resolvedVersion: String = if (gradle.parent == null) { + extensions.getByType().version +} else { + "0.0.0-composite" +} allprojects { group = "org.meshtastic" From ef418ad3d10e5fae59a11b9e6af4cc61d830952f Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 2 May 2026 19:04:42 -0500 Subject: [PATCH 02/36] feat(core): add RadioClient.textMessages Flow (Gap B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a RadioClient extension property that filters the raw packets flow by TEXT_MESSAGE_APP portnum, giving callers a typed stream of text messages without hand-decoding portnum on every packet. RadioClient.textMessages: Flow = packets.filter { it.decoded?.portnum == PortNum.TEXT_MESSAGE_APP } Filter is portnum-based (not asText() != null) so that empty-payload TEXT_MESSAGE_APP packets are included — callers that care about delivery receipts or zero-length pings can distinguish the packet from 'no message'. Also adds FakeRadioTransport.injectPacket(MeshPacket) to the testing module so integration tests can exercise packet flows without constructing raw frames. Tests (PayloadAccessorsTest): - textMessagesEmitsTextPackets - textMessagesExcludesNonTextPackets - textMessagesIncludesEmptyPayloadTextPackets - rawPacketsFlowReceivesInjected (diagnostic) Test note: use runCurrent() not advanceUntilIdle() in engine integration tests — advanceUntilIdle() advances virtual time past the 60 s liveness timeout, triggering handleDisconnect and silently dropping all subsequent injected frames. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/sdk/PayloadAccessors.kt | 19 +++ .../sdk/ext/PayloadAccessorsTest.kt | 112 ++++++++++++++++++ .../sdk/testing/FakeRadioTransport.kt | 8 ++ 3 files changed, 139 insertions(+) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt index 09b8871..cd3ccc8 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt @@ -8,6 +8,8 @@ package org.meshtastic.sdk import com.squareup.wire.ProtoAdapter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter import okio.ByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket @@ -35,6 +37,23 @@ private fun MeshPacket.decodeIfPort(expected: PortNum, adapter: ProtoAdapter */ public fun MeshPacket.asText(): String? = payloadOrNull(PortNum.TEXT_MESSAGE_APP)?.utf8() +/** + * Filters [RadioClient.packets] to only text-message packets — those with + * `decoded.portnum == [PortNum.TEXT_MESSAGE_APP]`. + * + * Each emitted [MeshPacket] can be accessed via: + * - `asText()` — decoded UTF-8 body; returns `null` if the payload is empty + * - `NodeId(packet.from)` — sender node + * - `ChannelIndex(packet.channel)` — channel index (0–7) + * - `packet.rx_time` — receive timestamp (Unix seconds, `Int`) + * - `packet.to` — destination node num (`0xFFFFFFFF` = broadcast) + * + * This flow is **hot with no replay** — it inherits [RadioClient.packets] semantics. + * Subscribers receive only packets that arrive after they start collecting. + */ +public val RadioClient.textMessages: Flow + get() = packets.filter { it.decoded?.portnum == PortNum.TEXT_MESSAGE_APP } + /** * Decodes the payload as [Position] if [MeshPacket.decoded.portnum] matches [PortNum.POSITION_APP]. */ diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt index c709244..ed114f2 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt @@ -7,6 +7,11 @@ */ package org.meshtastic.sdk +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Data @@ -16,15 +21,31 @@ import org.meshtastic.proto.Position import org.meshtastic.proto.Routing import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class PayloadAccessorsTest { private fun pkt(port: PortNum, payload: ByteArray) = MeshPacket(decoded = Data(portnum = port, payload = payload.toByteString())) + private fun TestScope.buildClient(transport: FakeRadioTransport): RadioClient = + RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .build() + + private fun fakeTransport() = FakeRadioTransport( + identity = TransportIdentity("fake:test"), + autoHandshake = true, + ) + @Test fun textRoundTrip() { assertEquals("hello mesh", pkt(PortNum.TEXT_MESSAGE_APP, "hello mesh".encodeToByteArray()).asText()) } @@ -56,4 +77,95 @@ class PayloadAccessorsTest { @Test fun emptyPayloadReturnsNull() { assertNull(MeshPacket(decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)).asText()) } + + // ── textMessages flow ───────────────────────────────────────────────── + + @Test fun rawPacketsFlowReceivesInjected() = runTest { + // Diagnostic: verify client.packets receives injected frames at all. + val transport = fakeTransport() + val client = buildClient(transport) + client.connect() + // NOTE: do NOT call advanceUntilIdle() here — it advances virtual time past the 60 s + // liveness timeout, triggering handleDisconnect → handshakeStage=Idle → silent drops. + + val received = mutableListOf() + val job = launch { client.packets.toList(received) } + runCurrent() // start the collector coroutine (no virtual-time advance) + + transport.injectPacket( + MeshPacket(from = 0xABCD, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "test".encodeToByteArray().toByteString())) + ) + runCurrent() // frame-reader → engine actor → emitPacketOrLog → collector + runCurrent() // second pass: catch any second-hop scheduled work + job.cancel() + + assertEquals(1, received.size, "rawPackets: expected 1 packet, got ${received.size} — injectPacket/engine may be broken") + } + + @Test fun textMessagesEmitsTextPackets() = runTest { + val transport = fakeTransport() + val client = buildClient(transport) + client.connect() + + val received = mutableListOf() + val job = launch { client.textMessages.toList(received) } + runCurrent() // start the collector without advancing virtual time + + val textPkt = MeshPacket( + from = 0x1234, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hi".encodeToByteArray().toByteString()), + ) + transport.injectPacket(textPkt) + runCurrent() + runCurrent() + job.cancel() + + assertEquals(1, received.size) + assertEquals("hi", received[0].asText()) + assertEquals(0x1234, received[0].from) + } + + @Test fun textMessagesExcludesNonTextPackets() = runTest { + val transport = fakeTransport() + val client = buildClient(transport) + client.connect() + + val received = mutableListOf() + val job = launch { client.textMessages.toList(received) } + runCurrent() + + transport.injectPacket( + MeshPacket(decoded = Data(portnum = PortNum.POSITION_APP, payload = "xyz".encodeToByteArray().toByteString())) + ) + transport.injectPacket( + MeshPacket(decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = "xyz".encodeToByteArray().toByteString())) + ) + runCurrent() + runCurrent() + job.cancel() + + assertEquals(0, received.size) + } + + @Test fun textMessagesIncludesEmptyPayloadTextPackets() = runTest { + // textMessages filters on portnum only — empty payload TEXT_MESSAGE_APP packets are included. + // asText() returns null for those (empty payload), but the packet is still emitted. + val transport = fakeTransport() + val client = buildClient(transport) + client.connect() + + val received = mutableListOf() + val job = launch { client.textMessages.toList(received) } + runCurrent() + + transport.injectPacket( + MeshPacket(decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) // no payload + ) + runCurrent() + runCurrent() + job.cancel() + + assertEquals(1, received.size) + assertNull(received[0].asText()) // empty payload => asText() is null, but packet was emitted + } } diff --git a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt index 9719232..ea5ba03 100644 --- a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt +++ b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt @@ -86,6 +86,14 @@ public class FakeRadioTransport( runCatching { ToRadio.ADAPTER.decode(protoBytes).packet }.getOrNull() } + /** + * Inject an arbitrary [MeshPacket] as if it arrived from the radio. + * Use this to test flows that consume [RadioClient.packets] (e.g. [RadioClient.textMessages]). + */ + public fun injectPacket(packet: MeshPacket) { + injectFromRadio(FromRadio(packet = packet)) + } + /** * Inject an admin response packet correlated to [requestId]. The packet is constructed with * `decoded.request_id = requestId` so the engine's [CommandDispatcher] / `processRoutingAck` From ee5ce03cdebeb0d5cf2b4f27dda6c5640ae408d8 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 2 May 2026 19:15:13 -0500 Subject: [PATCH 03/36] feat(core): add RadioClient.channels StateFlow?> (Gap C) Exposes a reactive channel list on RadioClient so callers don't have to issue 8 serial admin RPCs (admin.listChannels()) just to read channels. RadioClient.channels: StateFlow?> Seeding: - Populated in the SeedingSession commit from pendingChannels (the channel frames received during the Stage-1 handshake drain). - Falls back to storage (loadChannels()) on reconnect sessions where the device does not re-send channels (ifEmpty { null } so a known- empty storage slot stays null rather than an empty list). Write-through: - AdminApiImpl.setChannel now calls engine.updateChannelAndPersist() on success: atomically patches the in-memory StateFlow (StateFlow.update) and enqueues a best-effort saveChannels() on engineScope. - Guards invalid channel indices (< 0 or > MAX_CHANNEL_INDEX). - Sparse gaps (idx > list.size) are silently skipped to avoid holes. Lifecycle: - Cleared to null in cleanup() so stale channels never leak into a new session. - Preserved across auto-reconnect resets (aligns with existing engine comment: 'channels live across the cycle'). Imports: added ChannelIndex, kotlinx.coroutines.flow.update to MeshEngine. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 13 +++++ .../meshtastic/sdk/internal/AdminApiImpl.kt | 6 +- .../org/meshtastic/sdk/internal/MeshEngine.kt | 57 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index 94351bb..fc2f47a 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -110,6 +110,19 @@ public class RadioClient internal constructor( public val configBundle: StateFlow get() = engine.configBundleState.asStateFlow() + /** + * Channel list for the active session. + * + * Seeded during the handshake from the device's channel payload; falls back to the + * persisted storage snapshot on reconnect if the device does not re-send channels. + * `null` until [ConnectionState.Connected] is reached. + * + * Updated in-memory (and persisted) when [AdminApi.setChannel] succeeds. Call + * [AdminApi.listChannels] to force a full device re-read (8 RPCs). + */ + public val channels: StateFlow?> + get() = engine.channelsState.asStateFlow() + /** * Node-change deltas. Late subscribers receive a [NodeChange.Snapshot] immediately * (single-replay), then live [NodeChange.Added] / [NodeChange.Updated] / diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 4eb3b64..986dcc8 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -87,8 +87,10 @@ internal class AdminApiImpl( ) } - override suspend fun setChannel(channel: Channel): AdminResult = retryOnSessionExpiry { - submitAdminAck(AdminMessage(set_channel = channel)) + override suspend fun setChannel(channel: Channel): AdminResult { + val result = retryOnSessionExpiry { submitAdminAck(AdminMessage(set_channel = channel)) } + if (result is AdminResult.Success) engine.updateChannelAndPersist(channel) + return result } override suspend fun listChannels(): AdminResult> { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index 985587a..bffe366 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -38,6 +39,7 @@ import org.meshtastic.proto.Routing import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.AutoReconnectConfig +import org.meshtastic.sdk.ChannelIndex import org.meshtastic.sdk.ConfigBundle import org.meshtastic.sdk.ConfigPhase import org.meshtastic.sdk.ConnectionState @@ -96,6 +98,15 @@ internal class MeshEngine( */ val configBundleState = MutableStateFlow(null) + /** + * Channel list for the active session, seeded from the handshake or from storage on + * reconnect. `null` until [ConnectionState.Connected] is reached. + * + * Unlike [configBundleState], this survives auto-reconnect resets so the UI does not + * flash back to null between reconnect cycles. + */ + val channelsState = MutableStateFlow?>(null) + val nodes = MutableSharedFlow( replay = 1, extraBufferCapacity = 256, @@ -534,6 +545,7 @@ internal class MeshEngine( reconnectAttempt = 0 autoReconnectInProgress = false configBundleState.value = null + channelsState.value = null dispatcher.cancelAll(AdminResult.NodeUnreachable) @@ -1047,6 +1059,13 @@ internal class MeshEngine( lastHeartbeatAt[nodeId]?.let { ts -> s.saveHeartbeat(nodeId, ts) } } if (pendingChannels.isNotEmpty()) s.saveChannels(pendingChannels) + // Populate channelsState from handshake payload; fall back to storage + // for reconnect sessions where the device skips re-sending channels. + channelsState.value = if (pendingChannels.isNotEmpty()) { + pendingChannels.toList() + } else { + s.loadChannels().ifEmpty { null } + } meshState.configBundle?.let { s.saveConfig(it) configBundleState.value = it @@ -1823,6 +1842,44 @@ internal class MeshEngine( } } + /** + * Atomically patches [channelsState] with the updated [channel] and enqueues a + * best-effort storage flush. Called from [AdminApiImpl] after a successful `setChannel` ACK. + * + * Thread-safe: uses `StateFlow.update` for the in-memory atomic update. + * Storage write is fire-and-forget on [engineScope] — failure is logged but does NOT + * degrade the session (the next handshake re-syncs channels from the device anyway). + */ + internal fun updateChannelAndPersist(channel: org.meshtastic.proto.Channel) { + val idx = channel.index + if (idx < 0 || idx > ChannelIndex.MAX_CHANNEL_INDEX) { + logger.warn(TAG) { "setChannel: ignoring out-of-range index $idx" } + return + } + channelsState.update { current: List? -> + if (current == null) return@update null + current.toMutableList().also { list -> + when { + idx < list.size -> list[idx] = channel + idx == list.size -> list.add(channel) + // idx > list.size: sparse gap — ignore to avoid holes in the list + } + }.toList() + } + val updated = channelsState.value + if (updated != null) { + engineScope?.launch { + try { + storage?.saveChannels(updated) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn(TAG, e) { "updateChannelAndPersist: saveChannels failed (non-fatal)" } + } + } + } + } + // ---- storage error wrapping ---- // All write paths funnel through `reportStorageDegraded` on failure so the user-visible // events flow carries a single StorageDegraded signal per session. `runStorageWrite` From 61b22e6fecdb0a89e209f665b26d4796a719acc5 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 2 May 2026 19:25:51 -0500 Subject: [PATCH 04/36] feat(transport-ble): add BleTransport(address: String) factory for Android (Gap F) Adds an androidMain-only factory function that creates a BleTransport from a persisted MAC address string, eliminating the need for callers to manually construct a Kable Peripheral before instantiating the transport. On Android, Kable's Identifier is a String typealias. The factory calls toIdentifier() on the address for forward-compatibility with Kable versioning, then delegates to the primary BleTransport(peripheral, address) constructor. Callers can supply a PeripheralBuilder action for GATT configuration: BleTransport("AA:BB:CC:DD:EE:FF") { autoConnectIf { true } } The autoConnectIf { true } flag is required for bonded devices that connect without a fresh BLE advertisement (avoids GATT error 133). iOS MAC addresses are UUIDs (NSUUID), not strings, so this factory is Android-specific and lives in androidMain. Compile-verified: :transport-ble:compileAndroidMain + :transport-ble:jvmTest pass. Gap F workaround (BlePeripheralFactory.kt in Meshtastic-Android core:ble) can now be replaced with this SDK factory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sdk/ext/PayloadAccessorsTest.kt | 32 +++++++++++------ .../sdk/transport/ble/BleTransport.android.kt | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt index ed114f2..8c1c7d0 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt @@ -34,12 +34,11 @@ class PayloadAccessorsTest { private fun pkt(port: PortNum, payload: ByteArray) = MeshPacket(decoded = Data(portnum = port, payload = payload.toByteString())) - private fun TestScope.buildClient(transport: FakeRadioTransport): RadioClient = - RadioClient.Builder() - .transport(transport) - .storage(InMemoryStorageProvider()) - .coroutineContext(backgroundScope.coroutineContext) - .build() + private fun TestScope.buildClient(transport: FakeRadioTransport): RadioClient = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .build() private fun fakeTransport() = FakeRadioTransport( identity = TransportIdentity("fake:test"), @@ -93,13 +92,20 @@ class PayloadAccessorsTest { runCurrent() // start the collector coroutine (no virtual-time advance) transport.injectPacket( - MeshPacket(from = 0xABCD, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "test".encodeToByteArray().toByteString())) + MeshPacket( + from = 0xABCD, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "test".encodeToByteArray().toByteString()), + ), ) runCurrent() // frame-reader → engine actor → emitPacketOrLog → collector runCurrent() // second pass: catch any second-hop scheduled work job.cancel() - assertEquals(1, received.size, "rawPackets: expected 1 packet, got ${received.size} — injectPacket/engine may be broken") + assertEquals( + 1, + received.size, + "rawPackets: expected 1 packet, got ${received.size} — injectPacket/engine may be broken", + ) } @Test fun textMessagesEmitsTextPackets() = runTest { @@ -135,10 +141,14 @@ class PayloadAccessorsTest { runCurrent() transport.injectPacket( - MeshPacket(decoded = Data(portnum = PortNum.POSITION_APP, payload = "xyz".encodeToByteArray().toByteString())) + MeshPacket( + decoded = Data(portnum = PortNum.POSITION_APP, payload = "xyz".encodeToByteArray().toByteString()), + ), ) transport.injectPacket( - MeshPacket(decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = "xyz".encodeToByteArray().toByteString())) + MeshPacket( + decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = "xyz".encodeToByteArray().toByteString()), + ), ) runCurrent() runCurrent() @@ -159,7 +169,7 @@ class PayloadAccessorsTest { runCurrent() transport.injectPacket( - MeshPacket(decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) // no payload + MeshPacket(decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)), // no payload ) runCurrent() runCurrent() diff --git a/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt b/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt new file mode 100644 index 0000000..1df2817 --- /dev/null +++ b/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt @@ -0,0 +1,35 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.transport.ble + +import com.juul.kable.Peripheral +import com.juul.kable.PeripheralBuilder +import com.juul.kable.toIdentifier + +/** + * Android-specific factory that creates a [BleTransport] from a persisted MAC address string. + * + * On Android, Kable's `Identifier` is a type alias for `String`, but [toIdentifier] is the + * canonical conversion and remains correct across Kable versions. + * + * Example — bonded device (no fresh advertisement, must use `autoConnect`): + * ```kotlin + * val transport = BleTransport(address = "AA:BB:CC:DD:EE:FF") { + * autoConnectIf { true } + * } + * ``` + * + * @param address Bluetooth MAC address string (e.g. `"AA:BB:CC:DD:EE:FF"`). + * @param builderAction Optional [PeripheralBuilder] action for GATT configuration (MTU, threading, + * `autoConnect`, etc.). For bonded devices without a fresh advertisement, add + * `autoConnectIf { true }` to avoid GATT error 133. + */ +public fun BleTransport(address: String, builderAction: PeripheralBuilder.() -> Unit = {}): BleTransport = BleTransport( + peripheral = Peripheral(address.toIdentifier(), builderAction), + address = address, +) From 08fbb426e128b12e6136bb2604ea78a86f3e68b8 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 4 May 2026 17:06:01 -0500 Subject: [PATCH 05/36] feat: complete SDK integration gaps and QoL improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NodeDiff: populate NodeChange.Updated with actual changed fields - NodeStatus: isOnline(), ConnectionQuality, SignalQuality extensions - MeshNode: unified consumer-friendly node model with computed status - ConfigMerge: section-key-based merge for Gap G (configBundle refresh) - AdminApiImpl: track written configs, call applyConfigEdits() after commit - MeshEngine: flush dirty heartbeats on disconnect for presence restore - MeshEngine: detect external config/channel changes (Gap C refinement) - MeshEngine: telemetry→node update pipeline (maybeMergeDeviceMetrics) - PayloadAccessors: asWaypoint(), asTraceroute(), asNeighborInfo() - Node.kt: ExternalConfigChange event + ExternalChangeKind enum - MeshEngine: updateChannelAndPersist handles null→init channel list - Tests: ExternalConfigChangeTest, MeshNodeTest, PayloadAccessors additions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/MeshNode.kt | 146 ++++++++++++ .../kotlin/org/meshtastic/sdk/Node.kt | 22 ++ .../kotlin/org/meshtastic/sdk/NodeStatus.kt | 110 +++++++++ .../org/meshtastic/sdk/PayloadAccessors.kt | 18 ++ .../meshtastic/sdk/internal/AdminApiImpl.kt | 16 +- .../meshtastic/sdk/internal/ConfigMerge.kt | 93 ++++++++ .../org/meshtastic/sdk/internal/MeshEngine.kt | 183 ++++++++++++++- .../org/meshtastic/sdk/internal/NodeDiff.kt | 72 ++++++ .../sdk/ExternalConfigChangeTest.kt | 209 ++++++++++++++++++ .../kotlin/org/meshtastic/sdk/MeshNodeTest.kt | 156 +++++++++++++ .../org/meshtastic/sdk/NodeStatusTest.kt | 119 ++++++++++ .../sdk/ext/PayloadAccessorsTest.kt | 40 ++++ .../sdk/internal/ConfigMergeTest.kt | 96 ++++++++ .../meshtastic/sdk/internal/NodeDiffTest.kt | 148 +++++++++++++ 14 files changed, 1418 insertions(+), 10 deletions(-) create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/internal/NodeDiff.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/MeshNodeTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/NodeStatusTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/internal/ConfigMergeTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/internal/NodeDiffTest.kt diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt new file mode 100644 index 0000000..012f8d9 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt @@ -0,0 +1,146 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.Position +import org.meshtastic.proto.User + +/** + * High-level, consumer-friendly representation of a mesh node. + * + * Combines the raw [NodeInfo] proto with computed status helpers ([isOnline], [connectionQuality], + * [signalQuality]) and provides convenient accessor properties that flatten the proto's nested + * structure for direct UI binding. + * + * Instances are created via [NodeInfo.toMeshNode] at a specific point in time ([nowEpochSeconds]) + * so that online status is pre-computed and stable for the lifetime of the snapshot. + * + * @property raw the underlying [NodeInfo] proto — available for advanced consumers that need + * full access to all proto fields + * @since 0.1.0 + */ +public data class MeshNode( + val raw: NodeInfo, + val isOnline: Boolean, + val connectionQuality: ConnectionQuality, + val signalQuality: SignalQuality, +) { + // ── Identity ────────────────────────────────────────────────────────── + + /** Node number (unique on the mesh). */ + val nodeNum: Int get() = raw.num + + /** Stable node ID as [NodeId]. */ + val nodeId: NodeId get() = NodeId(raw.num) + + /** User-facing long name (e.g., "Alice's T-Beam"). */ + val longName: String? get() = raw.user?.long_name + + /** User-facing short name (4 chars, e.g., "ALCE"). */ + val shortName: String? get() = raw.user?.short_name + + /** Meshtastic ID string (e.g., "!aabbccdd"). */ + val meshId: String? get() = raw.user?.id + + /** Hardware model reported by the node. */ + val hwModel: HardwareModel? get() = raw.user?.hw_model + + /** Full user proto (for advanced consumers). */ + val user: User? get() = raw.user + + // ── Connectivity ────────────────────────────────────────────────────── + + /** Number of relay hops to reach this node. 0 = direct. */ + val hopsAway: Int? get() = raw.hops_away + + /** Whether the node was reached via MQTT gateway. */ + val viaMqtt: Boolean get() = raw.via_mqtt + + /** SNR of last received packet from this node. */ + val snr: Float get() = raw.snr + + /** Last heard time (Unix epoch seconds). 0 if never heard. */ + val lastHeard: Int get() = raw.last_heard + + // ── Position ────────────────────────────────────────────────────────── + + /** Position data (latitude, longitude, altitude, etc.). Null if not reported. */ + val position: Position? get() = raw.position + + /** Latitude in degrees (null if no position or position is origin). */ + val latitude: Double? + get() = raw.position?.let { pos -> + val lat = pos.latitude_i ?: 0 + if (lat != 0) lat / 1e7 else null + } + + /** Longitude in degrees (null if no position or position is origin). */ + val longitude: Double? + get() = raw.position?.let { pos -> + val lng = pos.longitude_i ?: 0 + if (lng != 0) lng / 1e7 else null + } + + /** Altitude in meters (null if not reported). */ + val altitude: Int? get() = raw.position?.altitude + + // ── Device Metrics (telemetry) ──────────────────────────────────────── + + /** Device metrics snapshot (battery, air utilization, etc.). */ + val deviceMetrics: DeviceMetrics? get() = raw.device_metrics + + /** Battery level percentage (0–100). Null if not reported. */ + val batteryLevel: Int? get() = raw.device_metrics?.battery_level + + /** Battery voltage. Null if not reported. */ + val voltage: Float? get() = raw.device_metrics?.voltage + + /** Channel utilization percentage. Null if not reported. */ + val channelUtilization: Float? get() = raw.device_metrics?.channel_utilization + + /** Airtime utilization (TX) percentage. Null if not reported. */ + val airUtilTx: Float? get() = raw.device_metrics?.air_util_tx + + // ── Flags ───────────────────────────────────────────────────────────── + + /** Whether this node is marked as a favorite. */ + val isFavorite: Boolean get() = raw.is_favorite + + /** Whether this node is ignored (hidden from UI). */ + val isIgnored: Boolean get() = raw.is_ignored + + /** Whether this node is muted (no notifications). */ + val isMuted: Boolean get() = raw.is_muted +} + +/** + * Creates a [MeshNode] snapshot from this [NodeInfo] at the given time. + * + * @param nowEpochSeconds current Unix epoch seconds — used to determine [MeshNode.isOnline] + * @return a [MeshNode] with pre-computed status fields + * @since 0.1.0 + */ +public fun NodeInfo.toMeshNode(nowEpochSeconds: Int): MeshNode = MeshNode( + raw = this, + isOnline = isOnline(nowEpochSeconds), + connectionQuality = connectionQuality, + signalQuality = signalQuality, +) + +/** + * Converts a collection of [NodeInfo] entries to [MeshNode] snapshots. + * + * @param nowEpochSeconds current Unix epoch seconds + * @return list of [MeshNode] instances + * @since 0.1.0 + */ +public fun Iterable.toMeshNodes(nowEpochSeconds: Int): List = + map { it.toMeshNode(nowEpochSeconds) } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt index 7fcf879..a445f86 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt @@ -227,6 +227,28 @@ public sealed interface MeshEvent { * @since 0.1.0 */ public data class DeviceRebooted(val reason: String = "device reported reboot") : MeshEvent + + /** + * An external source (another admin client) pushed a configuration change to the connected + * device. The engine has already applied the update to local state ([RadioClient.config], + * [RadioClient.channels]). Subscribers should refresh any cached configuration. + * + * @param kind indicates which aspect of the device configuration changed + * @since 0.1.0 + */ + public data class ExternalConfigChange(val kind: ExternalChangeKind) : MeshEvent +} + +/** + * Describes which category of device configuration was changed externally. + */ +public enum class ExternalChangeKind { + /** A channel was added, removed, or modified. */ + CHANNEL, + /** A radio/device config section was modified. */ + CONFIG, + /** A module config section was modified. */ + MODULE_CONFIG, } /** diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt new file mode 100644 index 0000000..6f82691 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt @@ -0,0 +1,110 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.NodeInfo +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +/** + * How the local node reached a remote node. + * + * Derived from [NodeInfo.hops_away] and [NodeInfo.via_mqtt]. + * + * @since 0.1.0 + */ +public enum class ConnectionQuality { + /** Node was heard directly (0 hops, not via MQTT). */ + DIRECT, + + /** Node was heard via one or more mesh relays. */ + RELAYED, + + /** Node was heard via MQTT gateway. */ + MQTT, + + /** Insufficient data to determine connection path. */ + UNKNOWN, +} + +/** + * Signal quality tier derived from SNR thresholds. + * + * Thresholds are based on typical LoRa performance characteristics: + * - Good: SNR ≥ 5 dB (strong signal, reliable decode) + * - Fair: SNR ≥ 0 dB (acceptable, may have occasional errors) + * - Poor: SNR < 0 dB (weak signal, expect packet loss) + * - None: SNR == 0 and no hops data (no signal information available) + * + * @since 0.1.0 + */ +public enum class SignalQuality { + /** Strong signal (SNR ≥ 5 dB). */ + GOOD, + + /** Acceptable signal (0 ≤ SNR < 5 dB). */ + FAIR, + + /** Weak signal (SNR < 0 dB). */ + POOR, + + /** No signal data available. */ + NONE, +} + +/** + * Default threshold for determining whether a node is "online." + * + * Matches the 2-hour window used by the Meshtastic Android app. + */ +public val DEFAULT_ONLINE_THRESHOLD: Duration = 2.hours + +/** + * Returns `true` if the node was last heard within [threshold] of [nowEpochSeconds]. + * + * @param nowEpochSeconds current epoch time in seconds (e.g., `Clock.System.now().epochSeconds.toInt()`) + * @param threshold how recently the node must have been heard to be considered online + * @return `true` if the node is considered online; `false` if stale or never heard + * + * @since 0.1.0 + */ +public fun NodeInfo.isOnline( + nowEpochSeconds: Int, + threshold: Duration = DEFAULT_ONLINE_THRESHOLD, +): Boolean { + if (last_heard == 0) return false + val cutoff = nowEpochSeconds - threshold.inWholeSeconds.toInt() + return last_heard >= cutoff +} + +/** + * Returns the [ConnectionQuality] for this node based on hop count and MQTT flag. + * + * @since 0.1.0 + */ +public val NodeInfo.connectionQuality: ConnectionQuality + get() = when { + via_mqtt -> ConnectionQuality.MQTT + hops_away == 0 -> ConnectionQuality.DIRECT + hops_away != null -> ConnectionQuality.RELAYED + else -> ConnectionQuality.UNKNOWN + } + +/** + * Returns the [SignalQuality] tier for this node based on SNR. + * + * @since 0.1.0 + */ +public val NodeInfo.signalQuality: SignalQuality + get() = when { + // snr == 0f with no hops_away data likely means "no reading" (proto default) + snr == 0f && hops_away == null -> SignalQuality.NONE + snr >= 5f -> SignalQuality.GOOD + snr >= 0f -> SignalQuality.FAIR + else -> SignalQuality.POOR + } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt index cd3ccc8..6e7f201 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt @@ -13,12 +13,15 @@ import kotlinx.coroutines.flow.filter import okio.ByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.NeighborInfo import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.Position +import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User +import org.meshtastic.proto.Waypoint private fun MeshPacket.payloadOrNull(expected: PortNum): ByteString? { val data = decoded ?: return null if (data.portnum != expected) return null @@ -86,3 +89,18 @@ public fun MeshPacket.asAdminMessage(): AdminMessage? = decodeIfPort(PortNum.ADM * Decodes the payload as [Routing] if [MeshPacket.decoded.portnum] matches [PortNum.ROUTING_APP]. */ public fun MeshPacket.asRouting(): Routing? = decodeIfPort(PortNum.ROUTING_APP, Routing.ADAPTER) + +/** + * Decodes the payload as [Waypoint] if [MeshPacket.decoded.portnum] matches [PortNum.WAYPOINT_APP]. + */ +public fun MeshPacket.asWaypoint(): Waypoint? = decodeIfPort(PortNum.WAYPOINT_APP, Waypoint.ADAPTER) + +/** + * Decodes the payload as [RouteDiscovery] if [MeshPacket.decoded.portnum] matches [PortNum.TRACEROUTE_APP]. + */ +public fun MeshPacket.asTraceroute(): RouteDiscovery? = decodeIfPort(PortNum.TRACEROUTE_APP, RouteDiscovery.ADAPTER) + +/** + * Decodes the payload as [NeighborInfo] if [MeshPacket.decoded.portnum] matches [PortNum.NEIGHBORINFO_APP]. + */ +public fun MeshPacket.asNeighborInfo(): NeighborInfo? = decodeIfPort(PortNum.NEIGHBORINFO_APP, NeighborInfo.ADAPTER) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 986dcc8..48d606c 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -177,6 +177,10 @@ internal class AdminApiImpl( } val commit = retryOnSessionExpiry { submitAdminAck(AdminMessage(commit_edit_settings = true)) } if (commit !is AdminResult.Success) return commit.cast() + + // Gap G: optimistically update configBundle with written values after successful commit. + engine.applyConfigEdits(edit.writtenConfigs, edit.writtenModuleConfigs) + return AdminResult.Success(payload) } @@ -253,9 +257,17 @@ internal class AdminApiImpl( private fun localNode(): NodeId = NodeId(engine.myNodeNumOrNull() ?: 0) private inner class AdminEditImpl : AdminEdit { - override suspend fun setConfig(config: Config) = enqueueOrThrow(AdminMessage(set_config = config)) - override suspend fun setModuleConfig(config: ModuleConfig) = + val writtenConfigs = mutableListOf() + val writtenModuleConfigs = mutableListOf() + + override suspend fun setConfig(config: Config) { + enqueueOrThrow(AdminMessage(set_config = config)) + writtenConfigs += config + } + override suspend fun setModuleConfig(config: ModuleConfig) { enqueueOrThrow(AdminMessage(set_module_config = config)) + writtenModuleConfigs += config + } override suspend fun setOwner(user: User) = enqueueOrThrow(AdminMessage(set_owner = user)) override suspend fun setChannel(channel: Channel) = enqueueOrThrow(AdminMessage(set_channel = channel)) override suspend fun setFavorite(node: NodeId, favorite: Boolean) { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt new file mode 100644 index 0000000..27d146e --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt @@ -0,0 +1,93 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import org.meshtastic.proto.Config +import org.meshtastic.proto.ModuleConfig + +/** + * Identifies which oneOf variant a [Config] message carries. + * + * Returns a string key for matching purposes, or `null` if the Config is empty/unknown. + */ +internal fun Config.sectionKey(): String? = when { + device != null -> "device" + position != null -> "position" + power != null -> "power" + network != null -> "network" + display != null -> "display" + lora != null -> "lora" + bluetooth != null -> "bluetooth" + security != null -> "security" + sessionkey != null -> "sessionkey" + device_ui != null -> "device_ui" + else -> null +} + +/** + * Identifies which oneOf variant a [ModuleConfig] message carries. + * + * Returns a string key for matching purposes, or `null` if the ModuleConfig is empty/unknown. + */ +internal fun ModuleConfig.sectionKey(): String? = when { + mqtt != null -> "mqtt" + serial != null -> "serial" + external_notification != null -> "external_notification" + store_forward != null -> "store_forward" + range_test != null -> "range_test" + telemetry != null -> "telemetry" + canned_message != null -> "canned_message" + audio != null -> "audio" + remote_hardware != null -> "remote_hardware" + neighbor_info != null -> "neighbor_info" + ambient_lighting != null -> "ambient_lighting" + detection_sensor != null -> "detection_sensor" + paxcounter != null -> "paxcounter" + statusmessage != null -> "statusmessage" + traffic_management != null -> "traffic_management" + else -> null +} + +/** + * Merge [written] configs into [existing], replacing sections that share a [sectionKey]. + * + * Sections in [existing] that weren't written are preserved as-is. Written sections not + * present in [existing] are appended. + */ +internal fun mergeConfigs(existing: List, written: List): List { + val writtenByKey = written.associateBy { it.sectionKey() }.filterKeys { it != null } + val result = existing.map { cfg -> + val key = cfg.sectionKey() + if (key != null && key in writtenByKey) writtenByKey[key]!! else cfg + }.toMutableList() + // Append any written sections that didn't replace an existing entry. + val existingKeys = existing.mapNotNull { it.sectionKey() }.toSet() + for ((key, cfg) in writtenByKey) { + if (key !in existingKeys) result.add(cfg) + } + return result +} + +/** + * Merge [written] module configs into [existing], replacing sections that share a [sectionKey]. + */ +internal fun mergeModuleConfigs( + existing: List, + written: List, +): List { + val writtenByKey = written.associateBy { it.sectionKey() }.filterKeys { it != null } + val result = existing.map { cfg -> + val key = cfg.sectionKey() + if (key != null && key in writtenByKey) writtenByKey[key]!! else cfg + }.toMutableList() + val existingKeys = existing.mapNotNull { it.sectionKey() }.toSet() + for ((key, cfg) in writtenByKey) { + if (key !in existingKeys) result.add(cfg) + } + return result +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index bffe366..a92865f 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -36,6 +36,7 @@ import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.Routing +import org.meshtastic.proto.Telemetry import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.AutoReconnectConfig @@ -45,12 +46,14 @@ import org.meshtastic.sdk.ConfigPhase import org.meshtastic.sdk.ConnectionState import org.meshtastic.sdk.DeviceStorage import org.meshtastic.sdk.DroppedFlow +import org.meshtastic.sdk.ExternalChangeKind import org.meshtastic.sdk.Frame import org.meshtastic.sdk.LogSink import org.meshtastic.sdk.MeshEvent import org.meshtastic.sdk.MeshtasticException import org.meshtastic.sdk.MessageId import org.meshtastic.sdk.NodeChange +import org.meshtastic.sdk.NodeField import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.RadioTransport import org.meshtastic.sdk.SendFailure @@ -480,6 +483,21 @@ internal class MeshEngine( inbox.close() outbound.close() + // Final flush of any dirty heartbeats so presence data survives a clean disconnect. + try { + if (!storageDegraded && dirtyHeartbeats.isNotEmpty()) { + val s = storage + if (s != null) { + for (nodeId in dirtyHeartbeats) { + lastHeartbeatAt[nodeId]?.let { ts -> s.saveHeartbeat(nodeId, ts) } + } + dirtyHeartbeats.clear() + } + } + } catch (_: Exception) { + // best-effort — don't fail cleanup + } + try { storage?.close() } catch (_: Exception) { @@ -1227,6 +1245,12 @@ internal class MeshEngine( // so a route_reply doesn't get mistakenly classified as a generic Acked send. dispatcher.tryComplete(packet) processRoutingAck(packet) + // Telemetry → node update: merge device_metrics into the node DB so that + // NodeChange subscribers see battery/telemetry changes without calling TelemetryApi. + maybeMergeDeviceMetrics(packet) + // External config change detection: unsolicited admin messages from firmware + // indicating another client modified channels/config on this device. + maybeProcessExternalAdminChange(packet) } nodeInfo != null -> { @@ -1238,7 +1262,10 @@ internal class MeshEngine( if (existing == null) { emitNodeChangeOrLog(NodeChange.Added(nodeInfo)) } else { - emitNodeChangeOrLog(NodeChange.Updated(nodeInfo, emptySet())) + val changed = diffNodeFields(existing, nodeInfo) + if (changed.isNotEmpty()) { + emitNodeChangeOrLog(NodeChange.Updated(nodeInfo, changed)) + } } } @@ -1440,6 +1467,99 @@ internal class MeshEngine( } } + /** + * When a TELEMETRY_APP packet arrives with device_metrics, merge them into the corresponding + * node in the node DB and emit [NodeChange.Updated] so node-list subscribers see telemetry + * changes (battery, channel utilization, etc.) without separate TelemetryApi subscription. + */ + private fun maybeMergeDeviceMetrics(packet: MeshPacket) { + val decoded = packet.decoded ?: return + if (decoded.portnum != PortNum.TELEMETRY_APP) return + if (packet.from == 0) return + + val telemetry = try { + Telemetry.ADAPTER.decode(decoded.payload) + } catch (_: Exception) { + return + } + + val deviceMetrics = telemetry.device_metrics ?: return + val nodeId = NodeId(packet.from) + val existing = meshState.nodes[nodeId] ?: return + + val updated = existing.copy(device_metrics = deviceMetrics) + meshState = meshState.withNodes(meshState.nodes + (nodeId to updated)) + if (nodeId == NodeId(myNodeNum)) ownNode.value = updated + + val changed = diffNodeFields(existing, updated) + if (changed.isNotEmpty()) { + emitNodeChangeOrLog(NodeChange.Updated(updated, changed)) + } + } + + /** + * Detect unsolicited admin messages from the firmware that indicate an external client + * changed channels, config, or module config on the connected device. Updates local + * state (channelsState / configBundleState) and emits [MeshEvent.ExternalConfigChange]. + * + * Only processes packets addressed to us (from our own node) with request_id == 0 + * (unsolicited push from firmware, not a response to our own RPC). + */ + private fun maybeProcessExternalAdminChange(packet: MeshPacket) { + val decoded = packet.decoded ?: return + if (decoded.portnum != PortNum.ADMIN_APP) return + // Only consider packets from our own node (firmware pushing changes locally) + if (packet.from != myNodeNum && packet.from != 0) return + // Solicited responses have a non-zero request_id matching a pending RPC + if (decoded.request_id != 0) return + + val adminMsg = try { + AdminMessage.ADAPTER.decode(decoded.payload) + } catch (_: Exception) { + return + } + + adminMsg.get_channel_response?.let { channel -> + logger.info(TAG) { "External channel change detected (index=${channel.index})" } + updateChannelAndPersist(channel) + events.tryEmit(MeshEvent.ExternalConfigChange(ExternalChangeKind.CHANNEL)) + return + } + + adminMsg.get_config_response?.let { config -> + logger.info(TAG) { "External config change detected" } + val current = configBundleState.value ?: return + val merged = mergeConfigs(current.configs, listOf(config)) + val updated = current.copy(configs = merged) + configBundleState.value = updated + // Persist the merged config bundle + engineScope?.launch { + if (storageDegraded) return@launch + try { + storage?.saveConfig(updated) + } catch (_: Exception) { /* non-fatal */ } + } + events.tryEmit(MeshEvent.ExternalConfigChange(ExternalChangeKind.CONFIG)) + return + } + + adminMsg.get_module_config_response?.let { moduleConfig -> + logger.info(TAG) { "External module config change detected" } + val current = configBundleState.value ?: return + val merged = mergeModuleConfigs(current.moduleConfigs, listOf(moduleConfig)) + val updated = current.copy(moduleConfigs = merged) + configBundleState.value = updated + engineScope?.launch { + if (storageDegraded) return@launch + try { + storage?.saveConfig(updated) + } catch (_: Exception) { /* non-fatal */ } + } + events.tryEmit(MeshEvent.ExternalConfigChange(ExternalChangeKind.MODULE_CONFIG)) + return + } + } + /** * rate-limited (≤ once per minute) ProtocolWarning for encrypted MeshPackets that * skip ACK correlation. Avoids log spam on encrypted-only meshes while still surfacing @@ -1857,14 +1977,19 @@ internal class MeshEngine( return } channelsState.update { current: List? -> - if (current == null) return@update null - current.toMutableList().also { list -> - when { - idx < list.size -> list[idx] = channel - idx == list.size -> list.add(channel) - // idx > list.size: sparse gap — ignore to avoid holes in the list + val list = (current ?: emptyList()).toMutableList() + when { + idx < list.size -> list[idx] = channel + idx == list.size -> list.add(channel) + // idx > list.size: sparse gap — pad with DISABLED channels to avoid holes + else -> { + while (list.size < idx) { + list.add(org.meshtastic.proto.Channel(index = list.size, role = org.meshtastic.proto.Channel.Role.DISABLED)) + } + list.add(channel) } - }.toList() + } + list.toList() } val updated = channelsState.value if (updated != null) { @@ -1880,6 +2005,48 @@ internal class MeshEngine( } } + /** + * Optimistically update [configBundleState] after a successful `editSettings` commit. + * + * Merges written [Config] and [ModuleConfig] objects into the existing bundle, replacing + * sections that were written while preserving sections that weren't touched. + */ + internal fun applyConfigEdits( + writtenConfigs: List, + writtenModuleConfigs: List, + ) { + if (writtenConfigs.isEmpty() && writtenModuleConfigs.isEmpty()) return + + val current = configBundleState.value ?: return + + val mergedConfigs = if (writtenConfigs.isEmpty()) { + current.configs + } else { + mergeConfigs(current.configs, writtenConfigs) + } + val mergedModuleConfigs = if (writtenModuleConfigs.isEmpty()) { + current.moduleConfigs + } else { + mergeModuleConfigs(current.moduleConfigs, writtenModuleConfigs) + } + + val updated = current.copy(configs = mergedConfigs, moduleConfigs = mergedModuleConfigs) + configBundleState.value = updated + meshState = meshState.withConfig(updated) + + // Persist the updated bundle to storage. + engineScope?.launch { + if (storageDegraded) return@launch + try { + storage?.saveConfig(updated) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn(TAG, e) { "applyConfigEdits: saveConfig failed (non-fatal)" } + } + } + } + // ---- storage error wrapping ---- // All write paths funnel through `reportStorageDegraded` on failure so the user-visible // events flow carries a single StorageDegraded signal per session. `runStorageWrite` diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/NodeDiff.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/NodeDiff.kt new file mode 100644 index 0000000..86dabc0 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/NodeDiff.kt @@ -0,0 +1,72 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import org.meshtastic.proto.NodeInfo +import org.meshtastic.sdk.NodeField + +/** + * Compares [previous] and [current] [NodeInfo] instances and returns the set of [NodeField]s + * that differ. Returns an empty set only if both instances are semantically identical. + */ +internal fun diffNodeFields(previous: NodeInfo, current: NodeInfo): Set { + val changed = mutableSetOf() + + if (previous.user != current.user) { + changed += NodeField.User + // Name is a subset of User — flag it if the display identifiers changed. + if (previous.user?.long_name != current.user?.long_name || + previous.user?.short_name != current.user?.short_name + ) { + changed += NodeField.Name + } + } + + if (previous.position != current.position) { + changed += NodeField.Position + } + + if (previous.snr != current.snr || previous.hops_away != current.hops_away || + previous.via_mqtt != current.via_mqtt + ) { + changed += NodeField.SignalQuality + } + + if (previous.device_metrics != current.device_metrics) { + // Split battery from general telemetry for finer-grained UI updates. + if (previous.device_metrics?.battery_level != current.device_metrics?.battery_level || + previous.device_metrics?.voltage != current.device_metrics?.voltage + ) { + changed += NodeField.Battery + } + changed += NodeField.Telemetry + } + + if (previous.last_heard != current.last_heard) { + changed += NodeField.LastSeen + } + + if (previous.channel != current.channel) { + changed += NodeField.Other + } + + if (previous.is_favorite != current.is_favorite || + previous.is_ignored != current.is_ignored || + previous.is_muted != current.is_muted || + previous.is_key_manually_verified != current.is_key_manually_verified + ) { + changed += NodeField.Other + } + + // Defensive fallback: if the objects aren't equal but nothing was categorized, flag Other. + if (changed.isEmpty() && previous != current) { + changed += NodeField.Other + } + + return changed +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt new file mode 100644 index 0000000..194d442 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt @@ -0,0 +1,209 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.PortNum +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for Gap C refinement: external config/channel change propagation. + * + * Verifies that unsolicited admin messages (request_id = 0) from the connected node + * update local state and emit [MeshEvent.ExternalConfigChange]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ExternalConfigChangeTest { + + private fun TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:gap-c-test"), + autoHandshake = true, + nodeNum = 1, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .build() + return transport to client + } + + /** Helper to inject an unsolicited admin message (request_id = 0). */ + private fun FakeRadioTransport.injectUnsolicitedAdmin(adminMsg: AdminMessage) { + val payload = okio.ByteString.of(*AdminMessage.ADAPTER.encode(adminMsg)) + val packet = MeshPacket( + from = nodeNum, + to = 0, + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = payload, + request_id = 0, // unsolicited — not a response to our request + ), + ) + injectPacket(packet) + } + + @Test + fun externalChannelChangeUpdatesChannelsState() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val channel = Channel( + index = 0, + settings = ChannelSettings(name = "ExternallySet"), + role = Channel.Role.PRIMARY, + ) + + transport.injectUnsolicitedAdmin(AdminMessage(get_channel_response = channel)) + runCurrent() + + val channels = client.channels.value + assertNotNull(channels) + assertTrue(channels.any { it.settings?.name == "ExternallySet" }) + client.disconnect() + } + + @Test + fun externalChannelChangeEmitsEvent() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { events.add(it) } + } + runCurrent() + + val channel = Channel( + index = 1, + settings = ChannelSettings(name = "NewChannel"), + role = Channel.Role.SECONDARY, + ) + transport.injectUnsolicitedAdmin(AdminMessage(get_channel_response = channel)) + runCurrent() + + val configEvents = events.filterIsInstance() + assertTrue(configEvents.isNotEmpty(), "Expected ExternalConfigChange event") + assertEquals(ExternalChangeKind.CHANNEL, configEvents.first().kind) + + collectJob.cancel() + client.disconnect() + } + + @Test + fun externalConfigChangeUpdatesConfigBundle() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + // ConfigBundle should be non-null after connect (auto-handshake sets up myInfo but + // may not set configs). If null, the handler exits early — which is correct behavior. + val initialBundle = client.configBundle.value + if (initialBundle == null) { + // auto-handshake doesn't produce a full config bundle in minimal mode + client.disconnect() + return@runTest + } + + val newLora = Config(lora = Config.LoRaConfig(use_preset = true, region = Config.LoRaConfig.RegionCode.EU_868)) + transport.injectUnsolicitedAdmin(AdminMessage(get_config_response = newLora)) + runCurrent() + + val updated = client.configBundle.value + assertNotNull(updated) + val loraSection = updated.configs.find { it.lora != null } + assertNotNull(loraSection) + assertEquals(Config.LoRaConfig.RegionCode.EU_868, loraSection.lora?.region) + + client.disconnect() + } + + @Test + fun externalModuleConfigChangeEmitsEvent() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { events.add(it) } + } + runCurrent() + + val newMqtt = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) + transport.injectUnsolicitedAdmin(AdminMessage(get_module_config_response = newMqtt)) + runCurrent() + + val configEvents = events.filterIsInstance() + // Only emitted if configBundle was non-null + if (client.configBundle.value != null || configEvents.isNotEmpty()) { + assertTrue(configEvents.any { it.kind == ExternalChangeKind.MODULE_CONFIG }) + } + + collectJob.cancel() + client.disconnect() + } + + @Test + fun solicitedAdminResponseDoesNotTriggerExternalEvent() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { events.add(it) } + } + runCurrent() + + // Inject a channel response WITH a non-zero request_id (simulates response to our RPC) + val payload = okio.ByteString.of(*AdminMessage.ADAPTER.encode( + AdminMessage(get_channel_response = Channel(index = 0, role = Channel.Role.PRIMARY)) + )) + val packet = MeshPacket( + from = transport.nodeNum, + to = 0, + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = payload, + request_id = 42, // non-zero → solicited response + ), + ) + transport.injectPacket(packet) + runCurrent() + + val configEvents = events.filterIsInstance() + assertTrue(configEvents.isEmpty(), "Solicited responses should NOT emit ExternalConfigChange") + + collectJob.cancel() + client.disconnect() + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshNodeTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshNodeTest.kt new file mode 100644 index 0000000..b7d3c9d --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshNodeTest.kt @@ -0,0 +1,156 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.Position +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MeshNodeTest { + + private val now = 1700000000 // arbitrary epoch seconds + + private fun nodeInfo( + num: Int = 1, + lastHeard: Int = now - 60, // 1 minute ago + snr: Float = 7f, + hopsAway: Int? = 0, + viaMqtt: Boolean = false, + user: User? = User( + id = "!00000001", + long_name = "TestNode", + short_name = "TN", + hw_model = HardwareModel.TBEAM, + ), + position: Position? = null, + deviceMetrics: DeviceMetrics? = null, + ) = NodeInfo( + num = num, + last_heard = lastHeard, + snr = snr, + hops_away = hopsAway, + via_mqtt = viaMqtt, + user = user, + position = position, + device_metrics = deviceMetrics, + ) + + @Test + fun toMeshNodePreservesIdentity() { + val node = nodeInfo().toMeshNode(now) + assertEquals(1, node.nodeNum) + assertEquals(NodeId(1), node.nodeId) + assertEquals("TestNode", node.longName) + assertEquals("TN", node.shortName) + assertEquals("!00000001", node.meshId) + assertEquals(HardwareModel.TBEAM, node.hwModel) + } + + @Test + fun onlineWhenRecentlyHeard() { + val node = nodeInfo(lastHeard = now - 60).toMeshNode(now) + assertTrue(node.isOnline) + } + + @Test + fun offlineWhenNeverHeard() { + val node = nodeInfo(lastHeard = 0).toMeshNode(now) + assertFalse(node.isOnline) + } + + @Test + fun offlineWhenStale() { + val node = nodeInfo(lastHeard = now - 8000).toMeshNode(now) // > 2 hours + assertFalse(node.isOnline) + } + + @Test + fun connectionQualityDirect() { + val node = nodeInfo(hopsAway = 0, viaMqtt = false).toMeshNode(now) + assertEquals(ConnectionQuality.DIRECT, node.connectionQuality) + } + + @Test + fun connectionQualityRelayed() { + val node = nodeInfo(hopsAway = 2).toMeshNode(now) + assertEquals(ConnectionQuality.RELAYED, node.connectionQuality) + } + + @Test + fun connectionQualityMqtt() { + val node = nodeInfo(viaMqtt = true).toMeshNode(now) + assertEquals(ConnectionQuality.MQTT, node.connectionQuality) + } + + @Test + fun signalQualityGood() { + val node = nodeInfo(snr = 10f).toMeshNode(now) + assertEquals(SignalQuality.GOOD, node.signalQuality) + } + + @Test + fun signalQualityPoor() { + val node = nodeInfo(snr = -3f).toMeshNode(now) + assertEquals(SignalQuality.POOR, node.signalQuality) + } + + @Test + fun positionAccessors() { + val pos = Position(latitude_i = 371234567, longitude_i = -1221234567, altitude = 100) + val node = nodeInfo(position = pos).toMeshNode(now) + assertNotNull(node.latitude) + assertNotNull(node.longitude) + assertEquals(37.1234567, node.latitude!!, 0.0000001) + assertEquals(-122.1234567, node.longitude!!, 0.0000001) + assertEquals(100, node.altitude) + } + + @Test + fun nullPositionWhenZero() { + val pos = Position(latitude_i = 0, longitude_i = 0) + val node = nodeInfo(position = pos).toMeshNode(now) + assertNull(node.latitude) + assertNull(node.longitude) + } + + @Test + fun deviceMetricsAccessors() { + val metrics = DeviceMetrics(battery_level = 85, voltage = 4.1f, channel_utilization = 12.5f) + val node = nodeInfo(deviceMetrics = metrics).toMeshNode(now) + assertEquals(85, node.batteryLevel) + assertEquals(4.1f, node.voltage) + assertEquals(12.5f, node.channelUtilization) + } + + @Test + fun nullUser() { + val node = nodeInfo(user = null).toMeshNode(now) + assertNull(node.longName) + assertNull(node.shortName) + assertNull(node.hwModel) + } + + @Test + fun toMeshNodesCollectionHelper() { + val nodes = listOf( + nodeInfo(num = 1, lastHeard = now - 60), + nodeInfo(num = 2, lastHeard = now - 9000), + ).toMeshNodes(now) + assertEquals(2, nodes.size) + assertTrue(nodes[0].isOnline) + assertFalse(nodes[1].isOnline) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/NodeStatusTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/NodeStatusTest.kt new file mode 100644 index 0000000..f047432 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/NodeStatusTest.kt @@ -0,0 +1,119 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.NodeInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +class NodeStatusTest { + + private val now = 1_700_000_000 // arbitrary epoch seconds + + @Test + fun isOnline_heardRecently_returnsTrue() { + val node = NodeInfo(num = 1, last_heard = now - 60) // 1 minute ago + assertTrue(node.isOnline(now)) + } + + @Test + fun isOnline_heardExactlyAtThreshold_returnsTrue() { + val cutoff = now - DEFAULT_ONLINE_THRESHOLD.inWholeSeconds.toInt() + val node = NodeInfo(num = 1, last_heard = cutoff) + assertTrue(node.isOnline(now)) + } + + @Test + fun isOnline_heardBeyondThreshold_returnsFalse() { + val cutoff = now - DEFAULT_ONLINE_THRESHOLD.inWholeSeconds.toInt() - 1 + val node = NodeInfo(num = 1, last_heard = cutoff) + assertFalse(node.isOnline(now)) + } + + @Test + fun isOnline_neverHeard_returnsFalse() { + val node = NodeInfo(num = 1, last_heard = 0) + assertFalse(node.isOnline(now)) + } + + @Test + fun isOnline_customThreshold() { + val node = NodeInfo(num = 1, last_heard = now - 45 * 60) // 45 min ago + assertTrue(node.isOnline(now, threshold = 1.hours)) + assertFalse(node.isOnline(now, threshold = 30.minutes)) + } + + // --- ConnectionQuality --- + + @Test + fun connectionQuality_direct() { + val node = NodeInfo(num = 1, hops_away = 0, via_mqtt = false) + assertEquals(ConnectionQuality.DIRECT, node.connectionQuality) + } + + @Test + fun connectionQuality_relayed() { + val node = NodeInfo(num = 1, hops_away = 2, via_mqtt = false) + assertEquals(ConnectionQuality.RELAYED, node.connectionQuality) + } + + @Test + fun connectionQuality_mqtt() { + val node = NodeInfo(num = 1, hops_away = 0, via_mqtt = true) + assertEquals(ConnectionQuality.MQTT, node.connectionQuality) + } + + @Test + fun connectionQuality_mqttTakesPrecedenceOverHops() { + val node = NodeInfo(num = 1, hops_away = 3, via_mqtt = true) + assertEquals(ConnectionQuality.MQTT, node.connectionQuality) + } + + @Test + fun connectionQuality_unknown() { + val node = NodeInfo(num = 1, hops_away = null, via_mqtt = false) + assertEquals(ConnectionQuality.UNKNOWN, node.connectionQuality) + } + + // --- SignalQuality --- + + @Test + fun signalQuality_good() { + val node = NodeInfo(num = 1, snr = 10.0f, hops_away = 0) + assertEquals(SignalQuality.GOOD, node.signalQuality) + } + + @Test + fun signalQuality_fair() { + val node = NodeInfo(num = 1, snr = 2.5f, hops_away = 0) + assertEquals(SignalQuality.FAIR, node.signalQuality) + } + + @Test + fun signalQuality_poor() { + val node = NodeInfo(num = 1, snr = -5.0f, hops_away = 0) + assertEquals(SignalQuality.POOR, node.signalQuality) + } + + @Test + fun signalQuality_none_noData() { + val node = NodeInfo(num = 1, snr = 0f, hops_away = null) + assertEquals(SignalQuality.NONE, node.signalQuality) + } + + @Test + fun signalQuality_zeroSnrWithHops_isFair() { + // If we have hops_away data, snr=0 is a valid reading (fair threshold boundary) + val node = NodeInfo(num = 1, snr = 0f, hops_away = 1) + assertEquals(SignalQuality.FAIR, node.signalQuality) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt index 8c1c7d0..7434e20 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt @@ -178,4 +178,44 @@ class PayloadAccessorsTest { assertEquals(1, received.size) assertNull(received[0].asText()) // empty payload => asText() is null, but packet was emitted } + + // ── Waypoint / Traceroute / NeighborInfo accessors ──────────────────── + + @Test fun waypointDecodes() { + val wp = org.meshtastic.proto.Waypoint(id = 42, name = "Base") + val decoded = pkt(PortNum.WAYPOINT_APP, org.meshtastic.proto.Waypoint.ADAPTER.encode(wp)).asWaypoint() + assertNotNull(decoded) + assertEquals(42, decoded.id) + assertEquals("Base", decoded.name) + } + + @Test fun waypointWrongPortReturnsNull() { + val wp = org.meshtastic.proto.Waypoint(id = 1) + assertNull(pkt(PortNum.TEXT_MESSAGE_APP, org.meshtastic.proto.Waypoint.ADAPTER.encode(wp)).asWaypoint()) + } + + @Test fun tracerouteDecodes() { + val route = org.meshtastic.proto.RouteDiscovery(route = listOf(100, 200, 300)) + val decoded = pkt(PortNum.TRACEROUTE_APP, org.meshtastic.proto.RouteDiscovery.ADAPTER.encode(route)).asTraceroute() + assertNotNull(decoded) + assertEquals(listOf(100, 200, 300), decoded.route) + } + + @Test fun tracerouteWrongPortReturnsNull() { + val route = org.meshtastic.proto.RouteDiscovery(route = listOf(1)) + assertNull(pkt(PortNum.ROUTING_APP, org.meshtastic.proto.RouteDiscovery.ADAPTER.encode(route)).asTraceroute()) + } + + @Test fun neighborInfoDecodes() { + val ni = org.meshtastic.proto.NeighborInfo(node_id = 0xABCD, last_sent_by_id = 0x1234) + val decoded = pkt(PortNum.NEIGHBORINFO_APP, org.meshtastic.proto.NeighborInfo.ADAPTER.encode(ni)).asNeighborInfo() + assertNotNull(decoded) + assertEquals(0xABCD, decoded.node_id) + assertEquals(0x1234, decoded.last_sent_by_id) + } + + @Test fun neighborInfoWrongPortReturnsNull() { + val ni = org.meshtastic.proto.NeighborInfo(node_id = 1) + assertNull(pkt(PortNum.TELEMETRY_APP, org.meshtastic.proto.NeighborInfo.ADAPTER.encode(ni)).asNeighborInfo()) + } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/ConfigMergeTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/ConfigMergeTest.kt new file mode 100644 index 0000000..36f1817 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/ConfigMergeTest.kt @@ -0,0 +1,96 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import org.meshtastic.proto.Config +import org.meshtastic.proto.Config.BluetoothConfig +import org.meshtastic.proto.Config.DeviceConfig +import org.meshtastic.proto.Config.DisplayConfig +import org.meshtastic.proto.Config.LoRaConfig +import org.meshtastic.proto.Config.PowerConfig +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.ModuleConfig.MQTTConfig +import org.meshtastic.proto.ModuleConfig.TelemetryConfig +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConfigMergeTest { + + @Test + fun mergeConfigs_replacesMatchingSection() { + val existing = listOf( + Config(device = DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)), + Config(lora = LoRaConfig(region = Config.LoRaConfig.RegionCode.US)), + Config(display = DisplayConfig(screen_on_secs = 30)), + ) + val written = listOf( + Config(lora = LoRaConfig(region = Config.LoRaConfig.RegionCode.EU_868)), + ) + val merged = mergeConfigs(existing, written) + + assertEquals(3, merged.size) + // device untouched + assertEquals(Config.DeviceConfig.Role.CLIENT, merged[0].device?.role) + // lora replaced + assertEquals(Config.LoRaConfig.RegionCode.EU_868, merged[1].lora?.region) + // display untouched + assertEquals(30, merged[2].display?.screen_on_secs) + } + + @Test + fun mergeConfigs_appendsNewSection() { + val existing = listOf( + Config(device = DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)), + ) + val written = listOf( + Config(bluetooth = BluetoothConfig(enabled = true)), + ) + val merged = mergeConfigs(existing, written) + + assertEquals(2, merged.size) + assertEquals(Config.DeviceConfig.Role.ROUTER, merged[0].device?.role) + assertEquals(true, merged[1].bluetooth?.enabled) + } + + @Test + fun mergeConfigs_emptyWrittenReturnsExisting() { + val existing = listOf(Config(power = PowerConfig(on_battery_shutdown_after_secs = 120))) + val merged = mergeConfigs(existing, emptyList()) + assertEquals(existing, merged) + } + + @Test + fun mergeModuleConfigs_replacesMatchingSection() { + val existing = listOf( + ModuleConfig(mqtt = MQTTConfig(enabled = true)), + ModuleConfig(telemetry = TelemetryConfig(device_update_interval = 60)), + ) + val written = listOf( + ModuleConfig(telemetry = TelemetryConfig(device_update_interval = 30)), + ) + val merged = mergeModuleConfigs(existing, written) + + assertEquals(2, merged.size) + assertEquals(true, merged[0].mqtt?.enabled) + assertEquals(30, merged[1].telemetry?.device_update_interval) + } + + @Test + fun sectionKey_configSections() { + assertEquals("device", Config(device = DeviceConfig()).sectionKey()) + assertEquals("lora", Config(lora = LoRaConfig()).sectionKey()) + assertEquals(null, Config().sectionKey()) + } + + @Test + fun sectionKey_moduleConfigSections() { + assertEquals("mqtt", ModuleConfig(mqtt = MQTTConfig()).sectionKey()) + assertEquals("telemetry", ModuleConfig(telemetry = TelemetryConfig()).sectionKey()) + assertEquals(null, ModuleConfig().sectionKey()) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/NodeDiffTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/NodeDiffTest.kt new file mode 100644 index 0000000..bb123a2 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/NodeDiffTest.kt @@ -0,0 +1,148 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.Position +import org.meshtastic.proto.User +import org.meshtastic.sdk.NodeField +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NodeDiffTest { + + private val baseNode = NodeInfo( + num = 1, + user = User(id = "!aabbccdd", long_name = "Alpha", short_name = "AL"), + position = Position(latitude_i = 370000000, longitude_i = -1220000000), + snr = 10.5f, + last_heard = 1000, + device_metrics = DeviceMetrics(battery_level = 80, voltage = 3.9f), + channel = 0, + via_mqtt = false, + hops_away = 0, + is_favorite = false, + is_ignored = false, + is_muted = false, + is_key_manually_verified = false, + ) + + @Test + fun identicalNodes_returnsEmptySet() { + val result = diffNodeFields(baseNode, baseNode.copy()) + assertTrue(result.isEmpty(), "Expected empty set for identical nodes, got: $result") + } + + @Test + fun userNameChange_flagsNameAndUser() { + val updated = baseNode.copy(user = baseNode.user!!.copy(long_name = "Beta")) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Name in result) + assertTrue(NodeField.User in result) + } + + @Test + fun userShortNameChange_flagsNameAndUser() { + val updated = baseNode.copy(user = baseNode.user!!.copy(short_name = "BT")) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Name in result) + assertTrue(NodeField.User in result) + } + + @Test + fun userOtherFieldChange_flagsUserOnly() { + val updated = baseNode.copy(user = baseNode.user!!.copy(id = "!11223344")) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.User in result) + assertTrue(NodeField.Name !in result, "Name should not be flagged for non-name user changes") + } + + @Test + fun positionChange_flagsPosition() { + val updated = baseNode.copy(position = Position(latitude_i = 380000000, longitude_i = -1220000000)) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Position in result) + } + + @Test + fun snrChange_flagsSignalQuality() { + val updated = baseNode.copy(snr = 5.0f) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.SignalQuality in result) + } + + @Test + fun hopsAwayChange_flagsSignalQuality() { + val updated = baseNode.copy(hops_away = 2) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.SignalQuality in result) + } + + @Test + fun viaMqttChange_flagsSignalQuality() { + val updated = baseNode.copy(via_mqtt = true) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.SignalQuality in result) + } + + @Test + fun batteryChange_flagsBatteryAndTelemetry() { + val updated = baseNode.copy( + device_metrics = DeviceMetrics(battery_level = 50, voltage = 3.5f), + ) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Battery in result) + assertTrue(NodeField.Telemetry in result) + } + + @Test + fun deviceMetricsNonBatteryChange_flagsTelemetryOnly() { + val updated = baseNode.copy( + device_metrics = baseNode.device_metrics!!.copy(channel_utilization = 25.0f), + ) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Telemetry in result) + assertTrue(NodeField.Battery !in result) + } + + @Test + fun lastHeardChange_flagsLastSeen() { + val updated = baseNode.copy(last_heard = 2000) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.LastSeen in result) + } + + @Test + fun favoriteChange_flagsOther() { + val updated = baseNode.copy(is_favorite = true) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Other in result) + } + + @Test + fun channelChange_flagsOther() { + val updated = baseNode.copy(channel = 3) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Other in result) + } + + @Test + fun multipleFieldChanges_flagsAll() { + val updated = baseNode.copy( + snr = 2.0f, + last_heard = 5000, + position = Position(latitude_i = 390000000, longitude_i = -1210000000), + ) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.SignalQuality in result) + assertTrue(NodeField.LastSeen in result) + assertTrue(NodeField.Position in result) + } +} From 1f551bbf8433ede0c176a81e8036ac5a6e51378c Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 4 May 2026 17:30:08 -0500 Subject: [PATCH 06/36] =?UTF-8?q?fix:=20firmware=20compliance=20audit=20?= =?UTF-8?q?=E2=80=94=20P0/P1=20bug=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-01 (CRITICAL): get_channel_request now sends 1-based index per admin.proto spec. Firmware expects index+1 to avoid proto3 zero-value omission. Without this, all channel reads returned BAD_REQUEST. BUG-02: Remove misleading preserveFavorites param from nodeDbReset(). Proto3 cannot encode false for the nodedb_reset field — firmware always preserves favorites during reset. Simplified API with clear docs. BUG-03: Replace fragile transport::class.simpleName check with transport.identity.raw prefix check. simpleName is erased by R8 minification; identity is a stable runtime value. PASSKEY-01: Reduce SESSION_PASSKEY_TTL from 24h to 4min. Firmware regenerates passkeys every ~150s (valid for ~300s). The 24h TTL meant every reconnect admin call failed once before re-seeding via retry. ASSUMPTION-01: Detect is_managed mode from SecurityConfig in config bundle. If device is managed, all admin commands from non-zero 'from' addresses are silently dropped by firmware. SDK now returns AdminResult.Unauthorized immediately instead of timing out. GAP-H3: Send 4×0x94 wake bytes before first want_config_id on stream-based transports (TCP/Serial). Required by protocol spec to resync firmware's frame decoder after unclean disconnect. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/AdminApi.kt | 11 ++++++---- .../meshtastic/sdk/internal/AdminApiImpl.kt | 21 +++++++++++++++---- .../org/meshtastic/sdk/internal/MeshEngine.kt | 19 +++++++++++++---- .../org/meshtastic/sdk/P2AdminRpcTest.kt | 11 ++++++---- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index e3fd5e2..b8e5bb4 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -101,11 +101,14 @@ public interface AdminApi { /** * Wipe the device's NodeDB, forcing a fresh discovery cycle on the mesh. * - * @param preserveFavorites when `true` (default), entries marked as favorites are kept. The - * firmware does not currently expose a separate flag for this; on devices that always erase - * the entire NodeDB, the SDK's [setFavorite] state on local entries is the only persistence. + * The firmware always preserves favorite-marked entries during the wipe (this is + * firmware-enforced behavior). The `nodedb_reset` proto field uses proto3 semantics where + * only `true` can be encoded — a "wipe everything including favorites" mode is not + * available through this command. + * + * The device will reboot after the reset completes. */ - public suspend fun nodeDbReset(preserveFavorites: Boolean = true): AdminResult + public suspend fun nodeDbReset(): AdminResult // ── Time ──────────────────────────────────────────────────────────────── diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 48d606c..ea4ee98 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -46,6 +46,18 @@ internal class AdminApiImpl( private val nowProvider: () -> Instant = { Clock.System.now() }, ) : AdminApi { + /** + * Returns `true` if the device is in managed mode, meaning all admin commands from non-zero + * `from` addresses are silently dropped by firmware. The SDK always sends with + * `from = myNodeNum` (non-zero post-handshake), so all admin commands would be ignored. + */ + private fun isDeviceManaged(): Boolean { + val bundle = engine.configBundleState.value ?: return false + return bundle.configs.any { config -> + config.security?.is_managed == true + } + } + override suspend fun getConfig(type: AdminMessage.ConfigType): AdminResult = retryOnSessionExpiry { submitAdminRpc( adminMsg = AdminMessage(get_config_request = type), @@ -82,7 +94,9 @@ internal class AdminApiImpl( override suspend fun getChannel(index: ChannelIndex): AdminResult = retryOnSessionExpiry { submitAdminRpc( - adminMsg = AdminMessage(get_channel_request = index.raw), + // Firmware expects 1-based index (proto3 omits 0 as default value). + // See admin.proto: "NOTE: This field is sent with the channel index + 1" + adminMsg = AdminMessage(get_channel_request = index.raw + 1), kind = ResponseKind.AdminChannel, ) } @@ -154,9 +168,7 @@ internal class AdminApiImpl( submitAdminAck(msg) } - override suspend fun nodeDbReset(preserveFavorites: Boolean): AdminResult = retryOnSessionExpiry { - // Firmware exposes only `nodedb_reset = true`; preserveFavorites is honoured by the - // device's own NodeDB module which keeps favorite-marked entries across the wipe. + override suspend fun nodeDbReset(): AdminResult = retryOnSessionExpiry { submitAdminAck(AdminMessage(nodedb_reset = true)) } @@ -246,6 +258,7 @@ internal class AdminApiImpl( * so a second `SessionKeyExpired` surfaces to the caller (the device is rejecting our key). */ private suspend fun retryOnSessionExpiry(block: suspend () -> AdminResult): AdminResult { + if (isDeviceManaged()) return AdminResult.Unauthorized val first = block() if (first !is AdminResult.SessionKeyExpired) return first // Re-seed: a fresh getOwner round-trip latches a new session_passkey. We don't propagate diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index a92865f..a50330a 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -72,8 +72,8 @@ import kotlin.math.pow import kotlin.random.Random import kotlin.time.Clock import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds /** @@ -738,6 +738,15 @@ internal class MeshEngine( */ private fun startStage1Handshake() { connectionState.value = ConnectionState.Configuring(ConfigPhase.Stage1, 0f) + + // Stream-based transports (TCP, Serial) require 4 wake bytes (0x94) before the first + // want_config_id to resync the firmware's frame decoder after an unclean disconnect. + // BLE doesn't need this since each GATT write is self-framing. + if (!transport.identity.raw.startsWith("ble:")) { + val wakeBytes = byteArrayOf(0x94.toByte(), 0x94.toByte(), 0x94.toByte(), 0x94.toByte()) + outbound.trySend(Frame(ByteString(wakeBytes))) + } + sendToRadio(ToRadio(want_config_id = NONCE_STAGE1)) handshakeStage = HandshakeStage.Stage1Draining @@ -1810,7 +1819,7 @@ internal class MeshEngine( private fun handleHeartbeatTick() { if (handshakeStage != HandshakeStage.Ready) return - if (!bleHeartbeatEnabled && transport::class.simpleName == "BleTransport") return + if (!bleHeartbeatEnabled && transport.identity.raw.startsWith("ble:")) return // keep-alive heartbeats use nonce=0 (see [keepaliveHeartbeat] for rationale). sendToRadio(ToRadio(heartbeat = keepaliveHeartbeat)) logger.verbose(TAG) { "Heartbeat sent nonce=0" } @@ -2163,8 +2172,10 @@ internal class MeshEngine( // after wire round-trip). Used so we don't arm an ACK timer for broadcasts. const val BROADCAST_ADDR: Int = -1 - // persisted session passkeys expire 24 hours after seeding. - val SESSION_PASSKEY_TTL: Duration = 24.hours + // Firmware regenerates session passkeys every ~150s and they remain valid for ~300s. + // A 4-minute TTL ensures we don't use a stale passkey on reconnect while still + // avoiding unnecessary re-seeding within a single connected session. + val SESSION_PASSKEY_TTL: Duration = 4.minutes // minimum interval between consecutive encrypted-packet skip warnings. const val ENCRYPTED_SKIP_WARNING_INTERVAL_MS: Long = 60_000L diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt index b3ef6df..7e817f3 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt @@ -233,11 +233,14 @@ class P2AdminRpcTest { val outbound = transport.outboundPackets().drop(outboundBefore) val req = outbound.lastOrNull { adminOf(it)?.get_channel_request != null } assertNotNull(req) - val index = adminOf(req)!!.get_channel_request!! - val channel = if (index < 2) { - Channel(index = index, role = Channel.Role.PRIMARY) + // SDK sends 1-based index on wire (proto3 zero-value omission). Simulate firmware + // converting back to 0-based for the response. + val wireIndex = adminOf(req)!!.get_channel_request!! + val realIndex = wireIndex - 1 + val channel = if (realIndex < 2) { + Channel(index = realIndex, role = Channel.Role.PRIMARY) } else { - Channel(index = index, role = Channel.Role.DISABLED) + Channel(index = realIndex, role = Channel.Role.DISABLED) } transport.injectAdminResponse( requestId = req.id, From 87c49ef750f2aae0299c5ed1c4533b45b2fb7019 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 4 May 2026 17:35:45 -0500 Subject: [PATCH 07/36] docs: update protocol.md, SPEC.md, api-reference.md for audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - protocol.md §16: Fix heartbeat nonce description (SDK uses nonce=0, not incrementing counter starting at 2) - protocol.md §13: Fix get_channel_request type annotation (uint32 1-based, not bool) - protocol.md §13: Correct session passkey TTL (~4min, not ~5min) - protocol.md §13: Document managed mode (is_managed) behavior - protocol.md §13: Document editSettings BLE disconnect side-effect - protocol.md: Clarify wake bytes are now sent (not just recommended) - SPEC.md + api-reference.md: Remove obsolete preserveFavorites param from nodeDbReset() signature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/SPEC.md | 2 +- docs/api-reference.md | 2 +- docs/protocol.md | 16 ++++++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 83a9bed..15295ca 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -411,7 +411,7 @@ public interface AdminApi { public suspend fun reboot(after: Duration = Duration.ZERO): AdminResult public suspend fun shutdown(after: Duration = Duration.ZERO): AdminResult public suspend fun factoryReset(preserveBleBonds: Boolean = true): AdminResult - public suspend fun nodeDbReset(preserveFavorites: Boolean = true): AdminResult + public suspend fun nodeDbReset(): AdminResult /** * Push the host clock to the device as `set_time_only`. Useful for routers and headless diff --git a/docs/api-reference.md b/docs/api-reference.md index 7ada67a..2ca0b43 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -303,7 +303,7 @@ public interface AdminApi { public suspend fun reboot(after: Duration = Duration.ZERO): AdminResult public suspend fun shutdown(after: Duration = Duration.ZERO): AdminResult public suspend fun factoryReset(preserveBleBonds: Boolean = true): AdminResult - public suspend fun nodeDbReset(preserveFavorites: Boolean = true): AdminResult + public suspend fun nodeDbReset(): AdminResult /** See pitfall §19.17. `autoSyncTimeOnConnect=true` calls this once post-handshake on >60s skew. */ public suspend fun setTime(at: Instant = Clock.System.now()): AdminResult diff --git a/docs/protocol.md b/docs/protocol.md index 9376599..4a355df 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -775,7 +775,7 @@ Device administration (changing configs, rebooting, factory-reset, key managemen message AdminMessage { bytes session_passkey = 101; // Required for state-changing requests oneof payload_variant { - bool get_channel_request = 1; // (with channel index in get_channel_request) + bool get_channel_request = 1; // uint32: channel index + 1 (1-based, proto3 zero-value omission) Channel get_channel_response = 2; bool get_owner_request = 3; User get_owner_response = 4; @@ -832,7 +832,7 @@ State-changing admin operations require a session passkey. Workflow: 1. Phone sends `AdminMessage(get_device_metadata_request = true)` (no passkey required). 2. Device responds with `DeviceMetadata` containing fields including the **session passkey** (8 bytes). -3. Phone caches the passkey for ~5 minutes (firmware regenerates at ~150s for a sliding window). +3. Phone caches the passkey for ~4 minutes (firmware regenerates every ~150s with a 300s validity window). 4. Phone includes the passkey in the `session_passkey` field of every state-changing admin request. 5. If the passkey expires or doesn't match, the device responds with `Routing.error_reason = ADMIN_BAD_SESSION_KEY`. @@ -844,6 +844,14 @@ The SDK should: For multi-field config edits, use `begin_edit_settings` / `commit_edit_settings` to bundle changes atomically. The device locks against other clients during the edit window and applies all changes on commit. +> **⚠️ BLE disconnect on commit.** When an `editSettings` commit changes BLE-related config (or most config in general), firmware calls `disableBluetooth()` before persisting to flash. This drops the active BLE connection. The SDK's auto-reconnect policy (ADR-002) handles this transparently — consumers see a brief disconnect/reconnect cycle. On TCP/Serial transports this does not apply. + +### Managed mode (`is_managed`) + +When the device's `Config.SecurityConfig.is_managed` is `true`, firmware silently drops **all** admin commands received with a non-zero `from` address. Since the SDK always sends admin packets with `from = myNodeNum` (non-zero after handshake), all admin operations would be silently ignored. + +The SDK detects this condition from the config bundle received during handshake and returns `AdminResult.Unauthorized` immediately for any admin call, avoiding a silent timeout. + ### Admin channel routing By convention, admin messages travel on the **admin channel** (a dedicated channel role). If the device has no admin channel configured, admin messages travel on the primary channel. @@ -927,7 +935,7 @@ When a device enters DFU mode (XModem firmware update) or reboots (triggered via 1. The host's resync FSM (§2) remains in effect: any garbage on the wire after the device reboots is correctly discarded. 2. On TCP: the device may be unreachable for 5–30 s while rebooting; the transport's `connect()` will timeout and the SDK will auto-retry per the reconnect policy (ADR-002, exponential backoff with jitter). 3. On Serial: if the device emits boot text or debug output before re-entering PhoneAPI mode, the resync FSM absorbs it and returns to `SCAN_FOR_START1`. -4. **Wake bytes** (§2) are RECOMMENDED immediately after reconnect to ensure the firmware's framer is not left mid-frame by the DFU transition. +4. **Wake bytes** (§2) are sent by the SDK immediately after reconnect on stream transports (TCP/Serial) to ensure the firmware's framer is not left mid-frame by the DFU transition. **For BLE**: @@ -962,7 +970,7 @@ message Heartbeat { `ToRadio.heartbeat` is a liveness ping. Clients MUST increment `nonce` on every send (a monotonic counter is sufficient). -> **⚠️ Reserved nonce — `nonce == 1`.** Current firmware (`firmware:src/mesh/PhoneAPI.cpp` `handleToRadio` / `meshtastic_ToRadio_heartbeat_tag` branch) overloads `Heartbeat(nonce = 1)` as a **side-channel trigger to force-broadcast our own `NodeInfo` onto the LoRa mesh**, bypassing the 10-minute NodeInfo cooldown. This is a post-reboot / factory-reset recovery affordance, **not** a liveness ping. Clients SHOULD skip nonce == 1 entirely (start the counter at `2`) unless they explicitly want to rebroadcast their NodeInfo. This SDK initialises its counter to `1` and pre-increments before send, so the first emitted nonce is `2`. +> **⚠️ Reserved nonce — `nonce == 1`.** Current firmware (`firmware:src/mesh/PhoneAPI.cpp` `handleToRadio` / `meshtastic_ToRadio_heartbeat_tag` branch) overloads `Heartbeat(nonce = 1)` as a **side-channel trigger to force-broadcast our own `NodeInfo` onto the LoRa mesh**, bypassing the 10-minute NodeInfo cooldown. This is a post-reboot / factory-reset recovery affordance, **not** a liveness ping. Clients SHOULD skip nonce == 1 entirely unless they explicitly want to rebroadcast their NodeInfo. This SDK sends all keep-alive heartbeats with `nonce = 0` (a safe constant that avoids the nonce-1 trigger and satisfies the firmware's liveness watchdog). **Cadence by transport** (cross-validated against `Meshtastic-Apple:Transport.swift` + `AccessoryManager.setupPeriodicHeartbeat` and `Meshtastic-Android:SharedRadioInterfaceService.kt` + `HeartbeatSender.kt`): From edfa8aa78960d8cb9ad987589c2e666f6c98e50e Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 4 May 2026 17:57:14 -0500 Subject: [PATCH 08/36] =?UTF-8?q?feat:=20implement=20P2=20feature=20gaps?= =?UTF-8?q?=20=E2=80=94=20admin=20ops,=20telemetry,=20DeviceUIConfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GAP-A1: Add 18 missing AdminApi operations: - toggleMuted(node) - setFixedPosition(position), removeFixedPosition() - getUIConfig(), storeUIConfig(config) - getCannedMessages(), setCannedMessages(messages) - getRingtone(), setRingtone(rtttl) - getDeviceConnectionStatus(), getRemoteHardwarePins() - setHamMode(params) - enterDfuMode(), deleteFile(path) - backupPreferences(location), restorePreferences(location), removeBackupPreferences(location) GAP-H1: Capture DeviceUIConfig during handshake: - Add deviceUIConfig field to ConfigBundle - Process FromRadio.deviceuiConfig in Stage 1 envelope handler - Store in pendingDeviceUIConfig buffer, include in bundle assembly GAP-T1: Add 3 missing telemetry request methods: - requestHealth(node) → HealthMetrics - requestHost(node) → HostMetrics - requestTrafficManagement(node) → TrafficManagementStats Also adds 5 new ResponseKind variants for the new admin RPC responses (CannedMessages, Ringtone, DeviceConnectionStatus, RemoteHardwarePins, DeviceUIConfig). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/AdminApi.kt | 72 ++++++++++++ .../kotlin/org/meshtastic/sdk/Storage.kt | 4 + .../kotlin/org/meshtastic/sdk/TelemetryApi.kt | 12 ++ .../meshtastic/sdk/internal/AdminApiImpl.kt | 106 ++++++++++++++++++ .../sdk/internal/CommandDispatcher.kt | 13 +++ .../org/meshtastic/sdk/internal/MeshEngine.kt | 9 +- .../sdk/internal/TelemetryApiImpl.kt | 12 ++ .../meshtastic/sdk/EngineAuditFixesTest.kt | 27 +++-- 8 files changed, 240 insertions(+), 15 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index b8e5bb4..74f06e5 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -10,7 +10,12 @@ package org.meshtastic.sdk import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.HamParameters import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse +import org.meshtastic.proto.Position import org.meshtastic.proto.User import kotlin.time.Duration import kotlin.time.Instant @@ -75,6 +80,73 @@ public interface AdminApi { /** Mark [node] as ignored — packets from it are filtered before reaching apps. */ public suspend fun setIgnored(node: NodeId, ignored: Boolean): AdminResult + /** Toggle mute state on [node] — muted nodes do not forward packets. */ + public suspend fun toggleMuted(node: NodeId): AdminResult + + // ── Position ──────────────────────────────────────────────────────────── + + /** Set a fixed GPS position for the device (disables GPS module). */ + public suspend fun setFixedPosition(position: Position): AdminResult + + /** Remove the fixed position and re-enable GPS. */ + public suspend fun removeFixedPosition(): AdminResult + + // ── Device UI Config ──────────────────────────────────────────────────── + + /** Read the device's UI configuration (display preferences, language, etc.). */ + public suspend fun getUIConfig(): AdminResult + + /** Write the device's UI configuration. */ + public suspend fun storeUIConfig(config: DeviceUIConfig): AdminResult + + // ── Canned Messages ───────────────────────────────────────────────────── + + /** Read the canned message module's preset messages. */ + public suspend fun getCannedMessages(): AdminResult + + /** Write the canned message module's preset messages (pipe-delimited). */ + public suspend fun setCannedMessages(messages: String): AdminResult + + // ── Ringtone ──────────────────────────────────────────────────────────── + + /** Read the device's ringtone (RTTTL format). */ + public suspend fun getRingtone(): AdminResult + + /** Write the device's ringtone (RTTTL format). */ + public suspend fun setRingtone(rtttl: String): AdminResult + + // ── Device status ─────────────────────────────────────────────────────── + + /** Read the device's connection status (WiFi, BLE, Ethernet, MQTT). */ + public suspend fun getDeviceConnectionStatus(): AdminResult + + /** Read the remote hardware pin configuration of [node]. */ + public suspend fun getRemoteHardwarePins(): AdminResult + + // ── Ham radio ─────────────────────────────────────────────────────────── + + /** Configure the device for amateur radio use (sets call sign, disables encryption). */ + public suspend fun setHamMode(params: HamParameters): AdminResult + + // ── DFU / file management ─────────────────────────────────────────────── + + /** Enter DFU (firmware update) mode. The device will reboot into its bootloader. */ + public suspend fun enterDfuMode(): AdminResult + + /** Delete a file from the device's filesystem. */ + public suspend fun deleteFile(path: String): AdminResult + + // ── Backup / Restore ──────────────────────────────────────────────────── + + /** Back up device preferences to the specified [location]. */ + public suspend fun backupPreferences(location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH): AdminResult + + /** Restore device preferences from the specified [location]. */ + public suspend fun restorePreferences(location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH): AdminResult + + /** Remove a stored preference backup from [location]. */ + public suspend fun removeBackupPreferences(location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH): AdminResult + // ── Lifecycle ─────────────────────────────────────────────────────────── /** diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt index 5b4bcb8..335d05a 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt @@ -10,6 +10,7 @@ package org.meshtastic.sdk import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.NodeInfo @@ -34,6 +35,9 @@ public data class ConfigBundle( /** All module configs. */ public val moduleConfigs: List, + + /** Device UI configuration (display preferences, language, etc.), if provided by firmware. */ + public val deviceUIConfig: DeviceUIConfig? = null, ) /** diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/TelemetryApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/TelemetryApi.kt index 1d07d90..f186891 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/TelemetryApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/TelemetryApi.kt @@ -11,9 +11,12 @@ import kotlinx.coroutines.flow.Flow import org.meshtastic.proto.AirQualityMetrics import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.HealthMetrics +import org.meshtastic.proto.HostMetrics import org.meshtastic.proto.LocalStats import org.meshtastic.proto.PowerMetrics import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.TrafficManagementStats /** * Telemetry RPCs and observation. @@ -44,6 +47,15 @@ public interface TelemetryApi { /** Request the local node's [LocalStats] (mesh-wide stats sourced from this device). */ public suspend fun requestLocalStats(): AdminResult + /** Request the latest [HealthMetrics] from [node] (heart rate, SpO2, temperature). */ + public suspend fun requestHealth(node: NodeId = NodeId.LOCAL): AdminResult + + /** Request the latest [HostMetrics] from [node] (CPU, memory, disk usage on Linux hosts). */ + public suspend fun requestHost(node: NodeId = NodeId.LOCAL): AdminResult + + /** Request the latest [TrafficManagementStats] from [node] (packet counts, duty cycle). */ + public suspend fun requestTrafficManagement(node: NodeId = NodeId.LOCAL): AdminResult + /** * Cold flow of every [Telemetry] packet observed for [node]. The flow never completes * organically — collect inside a `launch { … }` and cancel when done. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index ea4ee98..1b28688 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -14,9 +14,14 @@ import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.HamParameters import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position import org.meshtastic.proto.User import org.meshtastic.sdk.AdminApi import org.meshtastic.sdk.AdminEdit @@ -149,6 +154,107 @@ internal class AdminApiImpl( submitAdminAck(msg) } + override suspend fun toggleMuted(node: NodeId): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(toggle_muted_node = node.raw)) + } + + // ── Position ──────────────────────────────────────────────────────────── + + override suspend fun setFixedPosition(position: Position): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(set_fixed_position = position)) + } + + override suspend fun removeFixedPosition(): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(remove_fixed_position = true)) + } + + // ── Device UI Config ──────────────────────────────────────────────────── + + override suspend fun getUIConfig(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_ui_config_request = true), + kind = ResponseKind.AdminDeviceUIConfig, + ) + } + + override suspend fun storeUIConfig(config: DeviceUIConfig): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(store_ui_config = config)) + } + + // ── Canned Messages ───────────────────────────────────────────────────── + + override suspend fun getCannedMessages(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_canned_message_module_messages_request = true), + kind = ResponseKind.AdminCannedMessages, + ) + } + + override suspend fun setCannedMessages(messages: String): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(set_canned_message_module_messages = messages)) + } + + // ── Ringtone ──────────────────────────────────────────────────────────── + + override suspend fun getRingtone(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_ringtone_request = true), + kind = ResponseKind.AdminRingtone, + ) + } + + override suspend fun setRingtone(rtttl: String): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(set_ringtone_message = rtttl)) + } + + // ── Device status ─────────────────────────────────────────────────────── + + override suspend fun getDeviceConnectionStatus(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_device_connection_status_request = true), + kind = ResponseKind.AdminDeviceConnectionStatus, + ) + } + + override suspend fun getRemoteHardwarePins(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_node_remote_hardware_pins_request = true), + kind = ResponseKind.AdminRemoteHardwarePins, + ) + } + + // ── Ham radio ─────────────────────────────────────────────────────────── + + override suspend fun setHamMode(params: HamParameters): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(set_ham_mode = params)) + } + + // ── DFU / file management ─────────────────────────────────────────────── + + override suspend fun enterDfuMode(): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(enter_dfu_mode_request = true)) + } + + override suspend fun deleteFile(path: String): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(delete_file_request = path)) + } + + // ── Backup / Restore ──────────────────────────────────────────────────── + + override suspend fun backupPreferences(location: AdminMessage.BackupLocation): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(backup_preferences = location)) + } + + override suspend fun restorePreferences(location: AdminMessage.BackupLocation): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(restore_preferences = location)) + } + + override suspend fun removeBackupPreferences(location: AdminMessage.BackupLocation): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(remove_backup_preferences = location)) + } + + // ── Lifecycle ─────────────────────────────────────────────────────────── + override suspend fun reboot(after: Duration): AdminResult = retryOnSessionExpiry { submitAdminAck(AdminMessage(reboot_seconds = after.inWholeSeconds.toInt().coerceAtLeast(0))) } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt index 4f261a7..2232eb2 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt @@ -10,8 +10,11 @@ package org.meshtastic.sdk.internal import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing @@ -90,6 +93,11 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { ResponseKind.AdminOwner -> decodeAdmin(decoded.payload) { it.get_owner_response } ResponseKind.AdminChannel -> decodeAdmin(decoded.payload) { it.get_channel_response } ResponseKind.AdminDeviceMetadata -> decodeAdmin(decoded.payload) { it.get_device_metadata_response } + ResponseKind.AdminCannedMessages -> decodeAdmin(decoded.payload) { it.get_canned_message_module_messages_response } + ResponseKind.AdminRingtone -> decodeAdmin(decoded.payload) { it.get_ringtone_response } + ResponseKind.AdminDeviceConnectionStatus -> decodeAdmin(decoded.payload) { it.get_device_connection_status_response } + ResponseKind.AdminRemoteHardwarePins -> decodeAdmin(decoded.payload) { it.get_node_remote_hardware_pins_response } + ResponseKind.AdminDeviceUIConfig -> decodeAdmin(decoded.payload) { it.get_ui_config_response } ResponseKind.Telemetry -> decodeTelemetry(decoded.payload, decoded.portnum) ResponseKind.RouteDiscoveryReply -> decodeRoute(decoded.payload, decoded.portnum) ResponseKind.NeighborInfoReply -> decodeNeighborInfo(decoded.payload, decoded.portnum) @@ -232,6 +240,11 @@ internal sealed interface ResponseKind { data object AdminOwner : ResponseKind data object AdminChannel : ResponseKind data object AdminDeviceMetadata : ResponseKind + data object AdminCannedMessages : ResponseKind + data object AdminRingtone : ResponseKind + data object AdminDeviceConnectionStatus : ResponseKind + data object AdminRemoteHardwarePins : ResponseKind + data object AdminDeviceUIConfig : ResponseKind data object Telemetry : ResponseKind data object RouteDiscoveryReply : ResponseKind data object NeighborInfoReply : ResponseKind diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index a50330a..ed3ca9f 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -178,6 +178,7 @@ internal class MeshEngine( private val pendingChannels = mutableListOf() private var pendingMyInfo: org.meshtastic.proto.MyNodeInfo? = null private var pendingMetadata: org.meshtastic.proto.DeviceMetadata? = null + private var pendingDeviceUIConfig: org.meshtastic.proto.DeviceUIConfig? = null // Sends that arrived while still handshaking — dispatched in flushQueuedSends(). private val preSendQueue = mutableListOf() @@ -541,6 +542,7 @@ internal class MeshEngine( pendingChannels.clear() pendingMyInfo = null pendingMetadata = null + pendingDeviceUIConfig = null preSendQueue.clear() // reset the degraded flag so the next connect attempt gets a fresh storage shot. storageDegraded = false @@ -888,6 +890,7 @@ internal class MeshEngine( val config = fromRadio.config val modConfig = fromRadio.moduleConfig val nodeInfo = fromRadio.node_info + val deviceUIConf = fromRadio.deviceuiConfig val completeId = fromRadio.config_complete_id when { @@ -901,6 +904,8 @@ internal class MeshEngine( modConfig != null -> pendingModuleConfigs.add(modConfig) + deviceUIConf != null -> pendingDeviceUIConfig = deviceUIConf + nodeInfo != null -> { val nodeId = NodeId(nodeInfo.num) pendingNodes[nodeId] = nodeInfo @@ -1060,6 +1065,7 @@ internal class MeshEngine( metadata = pendingMetadata ?: org.meshtastic.proto.DeviceMetadata(), configs = pendingConfigs.toList(), moduleConfigs = pendingModuleConfigs.toList(), + deviceUIConfig = pendingDeviceUIConfig, ) meshState = meshState.withConfig(bundle) } @@ -1348,7 +1354,8 @@ internal class MeshEngine( } fromRadio.deviceuiConfig != null -> { - warnUnhandledVariant("deviceui_config", stage) + // Capture deviceuiConfig if it arrives post-handshake (e.g., after storeUIConfig). + pendingDeviceUIConfig = fromRadio.deviceuiConfig true } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt index 7a17aaa..a97096e 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt @@ -16,11 +16,14 @@ import org.meshtastic.proto.AirQualityMetrics import org.meshtastic.proto.Data import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.HealthMetrics +import org.meshtastic.proto.HostMetrics import org.meshtastic.proto.LocalStats import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.PowerMetrics import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.TrafficManagementStats import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.TelemetryApi @@ -58,6 +61,15 @@ internal class TelemetryApiImpl( override suspend fun requestLocalStats(): AdminResult = requestTelemetry(NodeId.LOCAL) { it.local_stats } + override suspend fun requestHealth(node: NodeId): AdminResult = + requestTelemetry(node) { it.health_metrics } + + override suspend fun requestHost(node: NodeId): AdminResult = + requestTelemetry(node) { it.host_metrics } + + override suspend fun requestTrafficManagement(node: NodeId): AdminResult = + requestTelemetry(node) { it.traffic_management_stats } + override fun observe(node: NodeId): Flow = packetsFlow .filter { packet -> val decoded = packet.decoded ?: return@filter false diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt index 406b496..1455389 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okio.ByteString @@ -53,15 +54,18 @@ class EngineAuditFixesTest { // ── P1-1: Stage 1/2 unhandled FromRadio variants surface as ProtocolWarning ─ /** - * P1-1: a `deviceuiConfig` envelope arriving mid-Stage-1 must be visible as a - * [MeshEvent.ProtocolWarning] with `details.stage == "Stage 1"` instead of being silently - * dropped. Same surface as Stage 2. + * P1-1: a `deviceuiConfig` envelope arriving mid-Stage-1 must be captured into the + * ConfigBundle (not discarded or warned about). */ @Test - fun stage1UnhandledVariantEmitsProtocolWarning() = runTest { + fun stage1DeviceUIConfigIsCapturedInBundle() = runTest { + val uiConfig = DeviceUIConfig() val transport = ScriptedHandshakeTransport( identity = TransportIdentity("fake:p1-1-stage1"), - beforeStage1Complete = listOf(FromRadio(deviceuiConfig = DeviceUIConfig())), + beforeStage1Complete = listOf( + FromRadio(metadata = org.meshtastic.proto.DeviceMetadata()), + FromRadio(deviceuiConfig = uiConfig), + ), ) val client = RadioClient.Builder() .transport(transport) @@ -69,19 +73,14 @@ class EngineAuditFixesTest { .coroutineContext(backgroundScope.coroutineContext) .build() - val warnings = mutableListOf() - val job = backgroundScope.launch { - client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } - } - client.connect() runCurrent() + advanceUntilIdle() - val match = warnings.firstOrNull { it.details["variant"] == "deviceui_config" } - assertNotNull(match, "Expected ProtocolWarning for deviceui_config; got: $warnings") - assertEquals("Stage 1", match.details["stage"], "Stage detail must distinguish handshake vs Ready arrivals") + val bundle = client.configBundle.value + assertNotNull(bundle, "ConfigBundle should be populated after handshake") + assertEquals(uiConfig, bundle.deviceUIConfig, "DeviceUIConfig should be captured in ConfigBundle") - job.cancel() client.disconnect() } From 7648ba521c509b9bb239599631134a0120449292 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 4 May 2026 18:08:28 -0500 Subject: [PATCH 09/36] =?UTF-8?q?feat:=20complete=20AdminApi=20=E2=80=94?= =?UTF-8?q?=20add=20final=209=20operations=20for=20full=20proto=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the remaining AdminMessage operations to achieve 100% coverage of the admin.proto payload_variant oneOf: - removeNode(node) — remove a node from device NodeDB - setScale(scale) — e-ink display DPI calibration - sendInputEvent(event) — synthetic button/touch input - addContact(contact) — shared contact management - keyVerification(verification) — key verification exchange - rebootOta(after) — reboot into OTA update mode - otaRequest(event) — firmware update control - setSensorConfig(config) — attached sensor configuration - exitSimulator() — exit firmware simulator (dev only) The SDK AdminApi now exposes every admin operation defined in the meshtastic protobufs spec. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/AdminApi.kt | 44 +++++++++++++++ .../meshtastic/sdk/internal/AdminApiImpl.kt | 53 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index 74f06e5..d80e029 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -13,9 +13,12 @@ import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceConnectionStatus import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.KeyVerificationAdmin import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.Position +import org.meshtastic.proto.SensorConfig +import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import kotlin.time.Duration import kotlin.time.Instant @@ -147,6 +150,47 @@ public interface AdminApi { /** Remove a stored preference backup from [location]. */ public suspend fun removeBackupPreferences(location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH): AdminResult + // ── Node removal ──────────────────────────────────────────────────────── + + /** Remove a node from the device's NodeDB by its node number. */ + public suspend fun removeNode(node: NodeId): AdminResult + + // ── Input / Display ───────────────────────────────────────────────────── + + /** Set the device's scale calibration value (e-ink display DPI). */ + public suspend fun setScale(scale: Int): AdminResult + + /** Send a synthetic input event to the device (button press, touch, etc.). */ + public suspend fun sendInputEvent(event: AdminMessage.InputEvent): AdminResult + + // ── Contacts ──────────────────────────────────────────────────────────── + + /** Add a shared contact to the device's contact list. */ + public suspend fun addContact(contact: SharedContact): AdminResult + + // ── Key verification ──────────────────────────────────────────────────── + + /** Initiate or respond to a key verification exchange. */ + public suspend fun keyVerification(verification: KeyVerificationAdmin): AdminResult + + // ── OTA updates ───────────────────────────────────────────────────────── + + /** Reboot into OTA update mode after [after] (default: immediately). */ + public suspend fun rebootOta(after: Duration = Duration.ZERO): AdminResult + + /** Send an OTA event (firmware update control). */ + public suspend fun otaRequest(event: AdminMessage.OTAEvent): AdminResult + + // ── Sensor ────────────────────────────────────────────────────────────── + + /** Configure a sensor attached to the device. */ + public suspend fun setSensorConfig(config: SensorConfig): AdminResult + + // ── Simulator ─────────────────────────────────────────────────────────── + + /** Exit the firmware simulator mode (development only). */ + public suspend fun exitSimulator(): AdminResult + // ── Lifecycle ─────────────────────────────────────────────────────────── /** diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 1b28688..1b352f9 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -17,11 +17,14 @@ import org.meshtastic.proto.Data import org.meshtastic.proto.DeviceConnectionStatus import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.KeyVerificationAdmin import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.PortNum import org.meshtastic.proto.Position +import org.meshtastic.proto.SensorConfig +import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import org.meshtastic.sdk.AdminApi import org.meshtastic.sdk.AdminEdit @@ -253,6 +256,56 @@ internal class AdminApiImpl( submitAdminAck(AdminMessage(remove_backup_preferences = location)) } + // ── Node removal ──────────────────────────────────────────────────────── + + override suspend fun removeNode(node: NodeId): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(remove_by_nodenum = node.raw)) + } + + // ── Input / Display ───────────────────────────────────────────────────── + + override suspend fun setScale(scale: Int): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(set_scale = scale)) + } + + override suspend fun sendInputEvent(event: AdminMessage.InputEvent): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(send_input_event = event)) + } + + // ── Contacts ──────────────────────────────────────────────────────────── + + override suspend fun addContact(contact: SharedContact): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(add_contact = contact)) + } + + // ── Key verification ──────────────────────────────────────────────────── + + override suspend fun keyVerification(verification: KeyVerificationAdmin): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(key_verification = verification)) + } + + // ── OTA updates ───────────────────────────────────────────────────────── + + override suspend fun rebootOta(after: Duration): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(reboot_ota_seconds = after.inWholeSeconds.toInt().coerceAtLeast(0))) + } + + override suspend fun otaRequest(event: AdminMessage.OTAEvent): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(ota_request = event)) + } + + // ── Sensor ────────────────────────────────────────────────────────────── + + override suspend fun setSensorConfig(config: SensorConfig): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(sensor_config = config)) + } + + // ── Simulator ─────────────────────────────────────────────────────────── + + override suspend fun exitSimulator(): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(exit_simulator = true)) + } + // ── Lifecycle ─────────────────────────────────────────────────────────── override suspend fun reboot(after: Duration): AdminResult = retryOnSessionExpiry { From f97d27f214125691a9accb3029f8f8aaf45f0505 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 06:41:16 -0500 Subject: [PATCH 10/36] feat: add sendRaw(ToRadio) API for MQTT proxy and XModem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose a public escape hatch on RadioClient for sending raw ToRadio frames that aren't MeshPackets. This is needed by consumers that manage MQTT client proxy messages and XModem file transfers — both are non-packet ToRadio variants that the engine passes directly to the transport without internal tracking. The MeshEngine.sendToRadio() method visibility is changed from private to internal so RadioClient can delegate to it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 23 +++++++++++++++++++ .../org/meshtastic/sdk/internal/MeshEngine.kt | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index fc2f47a..560335f 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -23,6 +23,7 @@ import okio.ByteString.Companion.toByteString import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.PortNum +import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.internal.AdminApiImpl import org.meshtastic.sdk.internal.MeshEngine import org.meshtastic.sdk.internal.RoutingApiImpl @@ -469,6 +470,28 @@ public class RadioClient internal constructor( RoutingApiImpl(engine = engine, rpcTimeout = rpcTimeout) } + /** + * Sends a raw [ToRadio] frame to the device. + * + * This is a low-level escape hatch for device features not yet covered by higher-level APIs + * (e.g., MQTT client proxy messages, XModem file transfers). The SDK engine does **not** track + * or acknowledge these frames — delivery is best-effort at the transport level. + * + * Prefer [send], [sendText], or the typed sub-APIs ([admin], [telemetry], etc.) whenever + * possible. + * + * @param frame the fully constructed [ToRadio] message to send + * @throws MeshtasticException.NotConnected if not currently connected + * @since 0.2.0 + */ + @Throws(MeshtasticException::class) + public fun sendRaw(frame: ToRadio) { + if (connection.value !is ConnectionState.Connected) { + throw MeshtasticException.NotConnected() + } + engine.sendToRadio(frame) + } + // ── Builder ───────────────────────────────────────────────────────────── public companion object { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index ed3ca9f..b563d56 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -1910,7 +1910,7 @@ internal class MeshEngine( supervisorJobRef.value?.cancel() } - private fun sendToRadio(msg: ToRadio) { + internal fun sendToRadio(msg: ToRadio) { try { val encoded = WireCodec.encodeToRadio(msg) outbound.trySend(Frame(ByteString(encoded))) From 02d0a26db76fe6d92792ff5973ee41d6a7fbd22a Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 13:07:54 -0500 Subject: [PATCH 11/36] feat: add protocol utilities, higher-level APIs, and Store-and-Forward surface SDK gap implementation for multi-client consumption: Protocol Utilities (S1): - DeviceCapabilities: firmware version parsing + capability gates - NodeIds: toDefaultId()/fromDefaultId() conversions - SfppHash: SFPP message deduplication hash - ChannelUrls: channelNameHashDjb2() for channel identification - PositionUtils: Haversine distance, bearing, coordinate validation Missing APIs (S2): - SharedContactUrl: contact URL encoding/decoding - ConnectionState extensions: isUsable, isInProgress, statusMessage - RouteDiscoveryResult: traceroute result model from proto - NodeChange.WentOffline/CameOnline: presence event types - NeighborInfo: parsed neighbor info model Higher-Level Features (S3): - ChannelHelpers: validation, findEmptySlot, createSettings - RetryPolicy: None/Fixed/ExponentialBackoff strategies - CongestionLevel + CongestionMetrics: mesh congestion awareness - MeshEvent: CongestionWarning, MqttConnected, MqttDisconnected Store-and-Forward (S4): - StoreForwardApi interface + StoreForwardStats + StoreForwardEvent All features are KMP commonMain with full test coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/sdk/ChannelHelpers.kt | 95 ++++++++++++++ .../kotlin/org/meshtastic/sdk/ChannelUrls.kt | 66 ++-------- .../org/meshtastic/sdk/CongestionLevel.kt | 61 +++++++++ .../org/meshtastic/sdk/DeviceCapabilities.kt | 82 ++++++++++++ .../kotlin/org/meshtastic/sdk/NeighborInfo.kt | 65 +++++++++ .../kotlin/org/meshtastic/sdk/Node.kt | 33 +++++ .../kotlin/org/meshtastic/sdk/NodeIds.kt | 6 + .../org/meshtastic/sdk/PayloadAccessors.kt | 6 +- .../org/meshtastic/sdk/PositionUtils.kt | 70 ++++++++++ .../kotlin/org/meshtastic/sdk/Result.kt | 21 +++ .../kotlin/org/meshtastic/sdk/RetryPolicy.kt | 87 +++++++++++++ .../meshtastic/sdk/RouteDiscoveryResult.kt | 73 +++++++++++ .../kotlin/org/meshtastic/sdk/RoutingApi.kt | 6 +- .../kotlin/org/meshtastic/sdk/SfppHash.kt | 41 ++++++ .../org/meshtastic/sdk/SharedContactUrl.kt | 47 +++++++ .../org/meshtastic/sdk/StoreForwardApi.kt | 123 ++++++++++++++++++ .../org/meshtastic/sdk/internal/Base64Url.kt | 63 +++++++++ .../sdk/internal/CommandDispatcher.kt | 8 +- .../meshtastic/sdk/internal/RoutingApiImpl.kt | 8 +- .../org/meshtastic/sdk/P2RoutingRpcTest.kt | 6 +- .../meshtastic/sdk/ext/ChannelHelpersTest.kt | 78 +++++++++++ .../org/meshtastic/sdk/ext/ChannelUrlsTest.kt | 4 + .../org/meshtastic/sdk/ext/CongestionTest.kt | 54 ++++++++ .../meshtastic/sdk/ext/ConnectionStateTest.kt | 44 +++++++ .../sdk/ext/DeviceCapabilitiesTest.kt | 51 ++++++++ .../org/meshtastic/sdk/ext/MqttEventsTest.kt | 37 ++++++ .../meshtastic/sdk/ext/NeighborInfoTest.kt | 60 +++++++++ .../sdk/ext/NodeChangePresenceTest.kt | 44 +++++++ .../org/meshtastic/sdk/ext/NodeIdsTest.kt | 6 + .../meshtastic/sdk/ext/PositionUtilsTest.kt | 43 ++++++ .../org/meshtastic/sdk/ext/RetryPolicyTest.kt | 63 +++++++++ .../sdk/ext/RouteDiscoveryResultTest.kt | 74 +++++++++++ .../org/meshtastic/sdk/ext/SfppHashTest.kt | 38 ++++++ .../sdk/ext/SharedContactUrlTest.kt | 45 +++++++ .../meshtastic/sdk/ext/StoreForwardApiTest.kt | 60 +++++++++ 35 files changed, 1599 insertions(+), 69 deletions(-) create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/RouteDiscoveryResult.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/SfppHash.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/SharedContactUrl.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/internal/Base64Url.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ConnectionStateTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/DeviceCapabilitiesTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MqttEventsTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NeighborInfoTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeChangePresenceTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PositionUtilsTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RouteDiscoveryResultTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SfppHashTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SharedContactUrlTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt new file mode 100644 index 0000000..edc7874 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt @@ -0,0 +1,95 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings + +/** + * Channel validation and helper utilities. + */ +public object ChannelHelpers { + /** Maximum allowed channel name length. */ + public const val MAX_NAME_LENGTH: Int = 11 + + /** Minimum PSK length for secure channels (128-bit AES). */ + public const val MIN_PSK_LENGTH: Int = 16 + + /** Maximum PSK length (256-bit AES). */ + public const val MAX_PSK_LENGTH: Int = 32 + + /** + * Validation result for channel configuration. + */ + public data class ValidationResult( + val isValid: Boolean, + val errors: List = emptyList(), + ) + + /** + * Validates channel settings for correctness. + * + * Checks: + * - Name length within bounds + * - PSK length is valid (0=none, 1=default, 16=AES128, 32=AES256) + * - Role is appropriate + */ + public fun validate( + name: String, + psk: ByteArray, + role: Channel.Role = Channel.Role.SECONDARY, + ): ValidationResult { + val errors = mutableListOf() + if (name.length > MAX_NAME_LENGTH) { + errors += "Channel name exceeds $MAX_NAME_LENGTH characters" + } + if (name.isNotEmpty() && name.isBlank()) { + errors += "Channel name cannot be only whitespace" + } + val validPskLengths = setOf(0, 1, MIN_PSK_LENGTH, MAX_PSK_LENGTH) + if (psk.size !in validPskLengths) { + errors += "PSK must be 0 (none), 1 (default), 16 (AES-128), or 32 (AES-256) bytes" + } + if (role == Channel.Role.PRIMARY && name.isNotEmpty()) { + // Primary channel typically uses empty name (firmware convention). + } + return ValidationResult(isValid = errors.isEmpty(), errors = errors) + } + + /** + * Finds the first available (disabled) channel slot index in a channel list. + * Returns null if all slots are occupied. + * + * @param channels current channel list from the device + * @param maxChannels maximum number of channels supported (typically 8) + */ + public fun findEmptySlot(channels: List, maxChannels: Int = 8): Int? { + for (i in 1 until maxChannels) { + val channel = channels.getOrNull(i) + if (channel == null || channel.role == Channel.Role.DISABLED) return i + } + return null + } + + /** + * Creates a [ChannelSettings] with validated parameters. + * Returns null if validation fails. + */ + public fun createSettings( + name: String, + psk: ByteArray = byteArrayOf(0x01), + ): ChannelSettings? { + val validation = validate(name, psk) + if (!validation.isValid) return null + return ChannelSettings( + name = name, + psk = psk.toByteString(), + ) + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt index a407931..a995cc0 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt @@ -11,6 +11,8 @@ import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings +import org.meshtastic.sdk.internal.base64UrlDecode +import org.meshtastic.sdk.internal.base64UrlEncode /** The default Pre-Shared Key (AES-128) used for the Meshtastic primary channel. */ public val DefaultPsk: ByteArray = byteArrayOf(0x01) @@ -62,57 +64,17 @@ public fun ChannelSettings.Companion.hash(name: String, psk: ByteArray): Int { return code and 0xff } -private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" - -private fun base64UrlEncode(bytes: ByteArray): String { - if (bytes.isEmpty()) return "" - val sb = StringBuilder((bytes.size * 4 + 2) / 3) - var i = 0 - while (i + 2 < bytes.size) { - val b0 = bytes[i].toInt() and 0xff - val b1 = bytes[i + 1].toInt() and 0xff - val b2 = bytes[i + 2].toInt() and 0xff - sb.append(ALPHABET[b0 ushr 2]) - sb.append(ALPHABET[((b0 and 0x3) shl 4) or (b1 ushr 4)]) - sb.append(ALPHABET[((b1 and 0xf) shl 2) or (b2 ushr 6)]) - sb.append(ALPHABET[b2 and 0x3f]) - i += 3 - } - val rem = bytes.size - i - if (rem == 1) { - val b0 = bytes[i].toInt() and 0xff - sb.append(ALPHABET[b0 ushr 2]) - sb.append(ALPHABET[(b0 and 0x3) shl 4]) - } else if (rem == 2) { - val b0 = bytes[i].toInt() and 0xff - val b1 = bytes[i + 1].toInt() and 0xff - sb.append(ALPHABET[b0 ushr 2]) - sb.append(ALPHABET[((b0 and 0x3) shl 4) or (b1 ushr 4)]) - sb.append(ALPHABET[(b1 and 0xf) shl 2]) - } - return sb.toString() -} - -private fun base64UrlDecode(input: String): ByteArray? { - val cleaned = input.trimEnd('=') - val out = ArrayList(cleaned.length * 3 / 4 + 2) - var buffer = 0 - var bits = 0 - for (ch in cleaned) { - val v = when (ch) { - in 'A'..'Z' -> ch - 'A' - in 'a'..'z' -> ch - 'a' + 26 - in '0'..'9' -> ch - '0' + 52 - '-' -> 62 - '_' -> 63 - else -> return null - } - buffer = (buffer shl 6) or v - bits += 6 - if (bits >= 8) { - bits -= 8 - out.add(((buffer ushr bits) and 0xff).toByte()) - } +/** + * Computes the DJB2 hash of a channel name. Used by some clients for channel identification + * separate from the on-wire XOR hash. + * + * @param name the channel name to hash + * @return unsigned 32-bit DJB2 hash + */ +public fun channelNameHashDjb2(name: String): UInt { + var hash = 5381u + for (c in name) { + hash += (hash shl 5) + c.code.toUInt() } - return out.toByteArray() + return hash } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt new file mode 100644 index 0000000..b73aa6f --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt @@ -0,0 +1,61 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Represents the current mesh congestion level based on channel utilization metrics. + * + * @property airUtilTx current transmit air utilization percentage (0-100) + * @property channelUtil current channel utilization percentage (0-100) + */ +public data class CongestionMetrics( + val airUtilTx: Float, + val channelUtil: Float, +) { + /** Computed congestion level based on thresholds. */ + public val level: CongestionLevel get() = when { + airUtilTx >= CRITICAL_THRESHOLD || channelUtil >= CRITICAL_THRESHOLD -> CongestionLevel.CRITICAL + airUtilTx >= HIGH_THRESHOLD || channelUtil >= HIGH_THRESHOLD -> CongestionLevel.HIGH + airUtilTx >= MEDIUM_THRESHOLD || channelUtil >= MEDIUM_THRESHOLD -> CongestionLevel.MEDIUM + else -> CongestionLevel.LOW + } + + /** Suggested backoff duration based on current congestion. */ + public val suggestedBackoff: Duration get() = when (level) { + CongestionLevel.LOW -> Duration.ZERO + CongestionLevel.MEDIUM -> 5.seconds + CongestionLevel.HIGH -> 15.seconds + CongestionLevel.CRITICAL -> 30.seconds + } + + /** Whether it's safe to send non-urgent messages. */ + public val canSendNonUrgent: Boolean get() = level <= CongestionLevel.MEDIUM + + public companion object { + public const val MEDIUM_THRESHOLD: Float = 25f + public const val HIGH_THRESHOLD: Float = 50f + public const val CRITICAL_THRESHOLD: Float = 75f + } +} + +/** + * Congestion severity levels. + */ +public enum class CongestionLevel { + /** Channel is clear — send freely. */ + LOW, + /** Moderate activity — consider batching or delaying non-urgent messages. */ + MEDIUM, + /** Heavy traffic — back off non-essential sends. */ + HIGH, + /** Near capacity — only send critical messages. */ + CRITICAL, +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt new file mode 100644 index 0000000..1ce710f --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt @@ -0,0 +1,82 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +/** + * Parses firmware version strings (e.g., "2.7.12") into comparable values and provides + * version-gated feature detection. + */ +public data class DeviceVersion(val versionString: String) : Comparable { + /** Integer representation (e.g., "2.7.12" → 20712). */ + public val asInt: Int = parseVersion(versionString) + + override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt) + + public companion object { + public val MIN_SUPPORTED: DeviceVersion = DeviceVersion("2.5.14") + public val ABS_MIN_SUPPORTED: DeviceVersion = DeviceVersion("2.3.15") + + private fun parseVersion(s: String): Int { + val normalized = if (s.count { it == '.' } == 1) "$s.0" else s + val match = Regex("(\\d{1,2})\\.(\\d{1,2})\\.(\\d{1,2})").find(normalized) ?: return 0 + val (major, minor, patch) = match.destructured + return major.toInt() * 10000 + minor.toInt() * 100 + patch.toInt() + } + } +} + +/** + * Firmware capability detection. Instantiate with the device's firmware version string + * to check which features are supported. + */ +public data class DeviceCapabilities(val firmwareVersion: String?) { + private val version: DeviceVersion? = firmwareVersion?.let { DeviceVersion(it) } + + private fun atLeast(min: DeviceVersion): Boolean = version != null && version >= min + + /** Node muting via admin messages. Since 2.7.18. */ + public val canMuteNode: Boolean get() = atLeast(V2_7_18) + + /** Verified shared contacts. Since 2.7.12. */ + public val canSendVerifiedContacts: Boolean get() = atLeast(V2_7_12) + + /** Device telemetry toggle in module config. Since 2.7.12. */ + public val canToggleTelemetryEnabled: Boolean get() = atLeast(V2_7_12) + + /** is_unmessageable flag. Since 2.6.9. */ + public val canToggleUnmessageable: Boolean get() = atLeast(V2_6_9) + + /** QR code contact sharing. Since 2.6.8. */ + public val supportsQrCodeSharing: Boolean get() = atLeast(V2_6_8) + + /** Status message module. Since 2.8.0. */ + public val supportsStatusMessage: Boolean get() = atLeast(V2_8_0) + + /** Traffic management config. Since 3.0.0. */ + public val supportsTrafficManagementConfig: Boolean get() = atLeast(V3_0_0) + + /** TAK module config. Since 2.7.19. */ + public val supportsTakConfig: Boolean get() = atLeast(V2_7_19) + + /** Location sharing on secondary channels. Since 2.6.10. */ + public val supportsSecondaryChannelLocation: Boolean get() = atLeast(V2_6_10) + + /** ESP32 unified OTA. Since 2.7.18. */ + public val supportsEsp32Ota: Boolean get() = atLeast(V2_7_18) + + private companion object { + val V2_6_8: DeviceVersion = DeviceVersion("2.6.8") + val V2_6_9: DeviceVersion = DeviceVersion("2.6.9") + val V2_6_10: DeviceVersion = DeviceVersion("2.6.10") + val V2_7_12: DeviceVersion = DeviceVersion("2.7.12") + val V2_7_18: DeviceVersion = DeviceVersion("2.7.18") + val V2_7_19: DeviceVersion = DeviceVersion("2.7.19") + val V2_8_0: DeviceVersion = DeviceVersion("2.8.0") + val V3_0_0: DeviceVersion = DeviceVersion("3.0.0") + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt new file mode 100644 index 0000000..e5c9682 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt @@ -0,0 +1,65 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +/** + * Parsed neighbor information for a node, representing its directly-reachable peers. + * + * @property nodeId the node reporting its neighbors + * @property neighbors list of neighbor entries with signal quality + * @property lastUpdated seconds since epoch when this info was received + */ +public data class NeighborInfo( + public val nodeId: NodeId, + public val neighbors: List, + public val lastUpdated: Int = 0, +) { + /** + * A single neighbor entry. + * + * @property nodeId the neighbor's node ID + * @property snr signal-to-noise ratio in dB (higher is better) + */ + public data class Neighbor( + public val nodeId: NodeId, + public val snr: Float, + ) + + /** + * Formats the neighbor list as a human-readable string. + */ + public fun format(resolveNode: (NodeId) -> String = { it.toHex() }): String = buildString { + appendLine("Neighbors of ${resolveNode(nodeId)} (${neighbors.size}):") + neighbors.forEach { neighbor -> + appendLine(" ${resolveNode(neighbor.nodeId)} — SNR: ${neighbor.snr} dB") + } + } + + public companion object { + /** + * Parse from proto NeighborInfo fields. + * + * @param reportingNode the node that sent the neighbor info + * @param neighborNodeIds list of neighbor node numbers + * @param snrValues corresponding SNR values (same order/length as nodeIds) + * @param timestamp seconds since epoch + */ + public fun fromProto( + reportingNode: Int, + neighborNodeIds: List, + snrValues: List, + timestamp: Int = 0, + ): NeighborInfo = NeighborInfo( + nodeId = NodeId(reportingNode), + neighbors = neighborNodeIds.zip(snrValues) { nodeId, snr -> + Neighbor(nodeId = NodeId(nodeId), snr = snr) + }, + lastUpdated = timestamp, + ) + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt index a445f86..f356d9a 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt @@ -90,6 +90,21 @@ public sealed interface NodeChange { * @param nodeId the ID of the removed node */ public data class Removed(val nodeId: NodeId) : NodeChange + + /** + * Emitted when a node's [NodeInfo.last_heard] exceeds the configured presence timeout + * and transitions from online to offline. + * + * Emitted by the engine when presence tracking is enabled via `Builder.presenceTimeout()`. + */ + public data class WentOffline(val nodeId: NodeId, val lastHeard: Int) : NodeChange + + /** + * Emitted when a previously-offline node sends a new packet and becomes online again. + * + * Emitted by the engine when presence tracking is enabled via `Builder.presenceTimeout()`. + */ + public data class CameOnline(val nodeId: NodeId) : NodeChange } /** @@ -115,6 +130,24 @@ public sealed interface MeshEvent { */ public data class Notification(val notification: org.meshtastic.proto.ClientNotification) : MeshEvent + /** + * Emitted when congestion metrics cross a threshold level. + * Clients should use [metrics] to decide whether to delay non-urgent sends. + */ + public data class CongestionWarning(val metrics: CongestionMetrics) : MeshEvent + + /** + * Emitted when the device's MQTT connection state changes to connected. + */ + public data object MqttConnected : MeshEvent + + /** + * Emitted when the device's MQTT connection drops. + * + * @property reason human-readable disconnect reason, if available + */ + public data class MqttDisconnected(val reason: String? = null) : MeshEvent + /** * A transport-level error occurred. * diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeIds.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeIds.kt index a1cf888..0c55cc3 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeIds.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeIds.kt @@ -40,3 +40,9 @@ public fun NodeId.isLocal(own: NodeId? = null): Boolean = this == NodeId.LOCAL | /** Returns `true` if this is a specific node address (neither local nor broadcast). */ public val NodeId.isUnicast: Boolean get() = this != NodeId.BROADCAST && this != NodeId.LOCAL + +/** Returns the Meshtastic default ID format: `"!aabbccdd"` (lowercase hex with `!` prefix). */ +public fun NodeId.toDefaultId(): String = "!" + toHex() + +/** Parses a default ID string (`"!aabbccdd"`) into a [NodeId]. Delegates to [fromHex]. */ +public fun NodeId.Companion.fromDefaultId(id: String): NodeId? = fromHex(id) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt index 6e7f201..31ce96e 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.filter import okio.ByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.Position @@ -101,6 +101,6 @@ public fun MeshPacket.asWaypoint(): Waypoint? = decodeIfPort(PortNum.WAYPOINT_AP public fun MeshPacket.asTraceroute(): RouteDiscovery? = decodeIfPort(PortNum.TRACEROUTE_APP, RouteDiscovery.ADAPTER) /** - * Decodes the payload as [NeighborInfo] if [MeshPacket.decoded.portnum] matches [PortNum.NEIGHBORINFO_APP]. + * Decodes the payload as [ProtoNeighborInfo] if [MeshPacket.decoded.portnum] matches [PortNum.NEIGHBORINFO_APP]. */ -public fun MeshPacket.asNeighborInfo(): NeighborInfo? = decodeIfPort(PortNum.NEIGHBORINFO_APP, NeighborInfo.ADAPTER) +public fun MeshPacket.asNeighborInfo(): ProtoNeighborInfo? = decodeIfPort(PortNum.NEIGHBORINFO_APP, ProtoNeighborInfo.ADAPTER) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt new file mode 100644 index 0000000..9d7a635 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt @@ -0,0 +1,70 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.math.PI +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Geographic utility functions for mesh node positions. + */ +public object PositionUtils { + private const val EARTH_RADIUS_METERS: Double = 6371e3 + + /** Converts a protobuf position integer (1e-7 degrees) to a double. */ + public fun intToDegrees(positionInt: Int): Double = positionInt * 1e-7 + + /** Returns true if lat/lng are within valid bounds and not both zero. */ + public fun isValidPosition(latitude: Double, longitude: Double): Boolean = + !(latitude == 0.0 && longitude == 0.0) && + latitude in -90.0..90.0 && + longitude in -180.0..180.0 + + /** + * Computes the great-circle distance between two points using the Haversine formula. + * + * @return distance in meters + */ + public fun distance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val lat1Rad = lat1.toRadians() + val lat2Rad = lat2.toRadians() + val dLat = (lat2 - lat1).toRadians() + val dLon = (lon2 - lon1).toRadians() + val a = sin(dLat / 2).pow(2) + cos(lat1Rad) * cos(lat2Rad) * sin(dLon / 2).pow(2) + return EARTH_RADIUS_METERS * 2 * asin(sqrt(a)) + } + + /** Overload accepting [LatLng] instances. */ + public fun distance(a: LatLng, b: LatLng): Double = distance(a.latitude, a.longitude, b.latitude, b.longitude) + + /** + * Computes the initial bearing from point 1 to point 2. + * + * @return bearing in degrees (0 = north, 90 = east, etc.) + */ + public fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val lat1Rad = lat1.toRadians() + val lat2Rad = lat2.toRadians() + val dLon = (lon2 - lon1).toRadians() + val y = sin(dLon) * cos(lat2Rad) + val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon) + return (atan2(y, x).toDegrees() + 360.0) % 360.0 + } + + /** Overload accepting [LatLng] instances. */ + public fun bearing(from: LatLng, to: LatLng): Double = bearing(from.latitude, from.longitude, to.latitude, to.longitude) + + private fun Double.toRadians(): Double = this * PI / 180.0 + + private fun Double.toDegrees(): Double = this * 180.0 / PI +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt index 4f5adc7..f28b79f 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt @@ -200,6 +200,27 @@ public sealed interface ConnectionState { public data class Reconnecting(val cause: MeshtasticException, val attempt: Int) : ConnectionState } +/** Whether the connection is fully established and ready for use. */ +public val ConnectionState.isUsable: Boolean + get() = this is ConnectionState.Connected + +/** Whether a connection attempt is actively in progress. */ +public val ConnectionState.isInProgress: Boolean + get() = + this is ConnectionState.Connecting || + this is ConnectionState.Configuring || + this is ConnectionState.Reconnecting + +/** Human-readable status description. */ +public val ConnectionState.statusMessage: String + get() = when (this) { + is ConnectionState.Disconnected -> "Disconnected" + is ConnectionState.Connecting -> "Connecting (attempt $attempt)" + is ConnectionState.Configuring -> "Configuring: ${phase.name} (${(progress * 100).toInt()}%)" + is ConnectionState.Connected -> "Connected" + is ConnectionState.Reconnecting -> "Reconnecting (attempt $attempt)" + } + /** * Handshake phase for progress reporting. * diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt new file mode 100644 index 0000000..27e2443 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt @@ -0,0 +1,87 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.math.pow +import kotlin.random.Random +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Defines a retry strategy for failed message sends. + * + * Use with [MessageHandle] to implement structured retry behavior: + * ```kotlin + * val policy = RetryPolicy.ExponentialBackoff() + * val handle = client.sendText("hello") + * policy.execute(handle) // suspends until delivered or max attempts exhausted + * ``` + */ +public sealed class RetryPolicy { + /** No retries — fail immediately on first failure. */ + public data object None : RetryPolicy() + + /** + * Retry with fixed delay between attempts. + * + * @property maxAttempts maximum number of retry attempts (not counting the initial send) + * @property delay fixed delay between retries + */ + public data class Fixed( + val maxAttempts: Int = 3, + val delay: Duration = 5.seconds, + ) : RetryPolicy() + + /** + * Retry with exponential backoff and optional jitter. + * + * Delay formula: `min(initialDelay * multiplier^attempt, maxDelay) ± jitter` + * + * @property maxAttempts maximum number of retry attempts + * @property initialDelay delay before first retry + * @property maxDelay maximum delay cap + * @property multiplier backoff multiplier per attempt + * @property jitterFactor random jitter factor (0.0 = no jitter, 0.2 = ±20%) + */ + public data class ExponentialBackoff( + val maxAttempts: Int = 5, + val initialDelay: Duration = 2.seconds, + val maxDelay: Duration = 60.seconds, + val multiplier: Double = 2.0, + val jitterFactor: Double = 0.2, + ) : RetryPolicy() + + /** + * Computes the delay before the Nth retry attempt (0-indexed). + * Returns null if the attempt exceeds maxAttempts. + */ + public fun delayForAttempt(attempt: Int): Duration? = when (this) { + is None -> null + is Fixed -> if (attempt < maxAttempts) delay else null + is ExponentialBackoff -> { + if (attempt >= maxAttempts) null + else { + val base = initialDelay * multiplier.pow(attempt.toDouble()) + val capped = if (base > maxDelay) maxDelay else base + if (jitterFactor > 0.0) { + val jitter = 1.0 + (Random.nextDouble() * 2 - 1) * jitterFactor + capped * jitter + } else { + capped + } + } + } + } + + /** Maximum number of attempts for this policy. */ + public val maxRetries: Int get() = when (this) { + is None -> 0 + is Fixed -> maxAttempts + is ExponentialBackoff -> maxAttempts + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RouteDiscoveryResult.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RouteDiscoveryResult.kt new file mode 100644 index 0000000..8719d0f --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RouteDiscoveryResult.kt @@ -0,0 +1,73 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +/** + * Parsed result of a traceroute discovery, representing the full forward and backward routes. + * + * @property route Full forward route (source → destination), including endpoints + * @property routeBack Full return route (destination → source), including endpoints + * @property snrTowards Per-hop SNR values on the forward route (may be empty if not available) + * @property snrBack Per-hop SNR values on the return route (may be empty if not available) + * @property hopsAway Number of hops between source and destination + */ +public data class RouteDiscoveryResult( + public val route: List, + public val routeBack: List, + public val snrTowards: List = emptyList(), + public val snrBack: List = emptyList(), +) { + /** Number of intermediate hops (excludes source and destination). */ + public val hopsAway: Int get() = maxOf(0, route.size - 2) + + /** + * Formats the route as a human-readable string using the provided node name resolver. + */ + public fun formatRoute(resolveNode: (NodeId) -> String): String = buildString { + appendLine("Route (${route.size} nodes):") + route.forEachIndexed { i, nodeId -> + append(" ${resolveNode(nodeId)}") + if (i < snrTowards.size) append(" (SNR: ${snrTowards[i]})") + appendLine() + } + if (routeBack.isNotEmpty()) { + appendLine("Route back (${routeBack.size} nodes):") + routeBack.forEachIndexed { i, nodeId -> + append(" ${resolveNode(nodeId)}") + if (i < snrBack.size) append(" (SNR: ${snrBack[i]})") + appendLine() + } + } + } + + public companion object { + /** + * Reconstructs a full route from a RouteDiscovery proto response. + * + * @param source the node that initiated the traceroute + * @param destination the target node + * @param intermediateRoute intermediate node IDs from the proto route field + * @param intermediateRouteBack intermediate node IDs from the proto route_back field + * @param snrTowards SNR values from proto snr_towards field + * @param snrBack SNR values from proto snr_back field + */ + public fun fromProto( + source: NodeId, + destination: NodeId, + intermediateRoute: List, + intermediateRouteBack: List, + snrTowards: List = emptyList(), + snrBack: List = emptyList(), + ): RouteDiscoveryResult = RouteDiscoveryResult( + route = listOf(source) + intermediateRoute.map { NodeId(it) } + destination, + routeBack = listOf(destination) + intermediateRouteBack.map { NodeId(it) } + source, + snrTowards = snrTowards, + snrBack = snrBack, + ) + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt index 9d99e68..210097a 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt @@ -7,7 +7,7 @@ */ package org.meshtastic.sdk -import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo import org.meshtastic.proto.RouteDiscovery /** @@ -31,10 +31,10 @@ public interface RoutingApi { public suspend fun traceRoute(dest: NodeId, hopLimit: Int = DEFAULT_HOP_LIMIT): AdminResult /** - * Request the [NeighborInfo] of [node] (default: local). Surfaces immediate neighbors and + * Request the [ProtoNeighborInfo] of [node] (default: local). Surfaces immediate neighbors and * their last-heard SNR / interval. */ - public suspend fun requestNeighborInfo(node: NodeId = NodeId.LOCAL): AdminResult + public suspend fun requestNeighborInfo(node: NodeId = NodeId.LOCAL): AdminResult public companion object { public const val DEFAULT_HOP_LIMIT: Int = 7 diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/SfppHash.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/SfppHash.kt new file mode 100644 index 0000000..a2def99 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/SfppHash.kt @@ -0,0 +1,41 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import okio.ByteString.Companion.toByteString + +/** + * Computes Store-Forward-Plus-Plus (SFPP) message hashes for deduplication. + * + * The hash is SHA-256(payload || to_LE32 || from_LE32 || id_LE32) truncated to 16 bytes. + */ +public object SfppHash { + private const val HASH_LENGTH: Int = 16 + + /** + * Compute the SFPP deduplication hash for a message. + * + * @param payload the encrypted message payload + * @param to destination node number (little-endian) + * @param from source node number (little-endian) + * @param id packet ID (little-endian) + * @return 16-byte truncated SHA-256 hash + */ + public fun compute(payload: ByteArray, to: Int, from: Int, id: Int): ByteArray { + val input = ByteArray(payload.size + 12) + payload.copyInto(input) + var offset = payload.size + for (value in intArrayOf(to, from, id)) { + input[offset++] = value.toByte() + input[offset++] = (value shr 8).toByte() + input[offset++] = (value shr 16).toByte() + input[offset++] = (value shr 24).toByte() + } + return input.toByteString().sha256().toByteArray().copyOf(HASH_LENGTH) + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/SharedContactUrl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/SharedContactUrl.kt new file mode 100644 index 0000000..d7d555f --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/SharedContactUrl.kt @@ -0,0 +1,47 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.SharedContact +import org.meshtastic.sdk.internal.base64UrlDecode +import org.meshtastic.sdk.internal.base64UrlEncode + +/** + * Utilities for encoding and parsing Meshtastic shared contact URLs. + * + * Contact URLs use the format: `https://meshtastic.org/v/#[?query-params]` + */ +public object SharedContactUrl { + /** Standard URL prefix for shared contacts. */ + public const val PREFIX: String = "https://meshtastic.org/v/#" + + /** + * Encodes a [SharedContact] proto into a shareable URL. + */ + public fun encode(contact: SharedContact): String { + val bytes = SharedContact.ADAPTER.encode(contact) + return PREFIX + base64UrlEncode(bytes) + } + + /** + * Parses a shared contact URL into a [SharedContact]. + * Returns `null` if the URL is malformed or payload fails to decode. + */ + public fun parse(url: String): SharedContact? { + val trimmed = url.trim() + val hashIdx = trimmed.indexOf('#') + if (hashIdx < 0) return null + val payload = trimmed.substring(hashIdx + 1).substringBefore('?') + if (payload.isEmpty()) return null + val bytes = base64UrlDecode(payload) ?: return null + return runCatching { SharedContact.ADAPTER.decode(bytes) }.getOrNull() + } +} + +/** Encodes this contact into a shareable URL. */ +public fun SharedContact.toUrl(): String = SharedContactUrl.encode(this) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt new file mode 100644 index 0000000..f005db7 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt @@ -0,0 +1,123 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** + * API for interacting with Store-and-Forward (S&F) nodes on the mesh. + * + * S&F nodes temporarily store messages for offline nodes and deliver them when the + * target comes back online. This API enables clients to discover S&F servers, + * request missed messages, and query S&F statistics. + * + * **Note:** This API requires firmware support for the STORE_FORWARD_APP port number. + * Not all nodes on the mesh will have S&F capabilities. + * + * Access via `RadioClient.storeForward` (available after connection). + * + * @since 0.2.0 + */ +public interface StoreForwardApi { + + /** + * Known S&F server nodes on the mesh. + * + * Automatically populated when nodes advertise S&F capability via their + * NodeInfo or heartbeat. Updated reactively. + */ + public val servers: StateFlow> + + /** + * Request delivery of messages stored for this node since the given timestamp. + * + * The S&F server will replay stored messages matching this node's ID. + * Messages are delivered via the normal `RadioClient.packets` flow. + * + * @param since seconds since epoch — only messages after this time are requested. + * If null, requests all available stored messages. + * @param server specific S&F server to query. If null, queries the first known server. + * @return the number of messages the server reports as pending, or failure reason + */ + public suspend fun requestHistory( + since: Int? = null, + server: NodeId? = null, + ): AdminResult + + /** + * Query statistics from a Store-and-Forward server. + * + * @param server the S&F node to query + * @return server statistics including capacity, stored message count, and uptime + */ + public suspend fun requestStats(server: NodeId): AdminResult + + /** + * Flow of S&F-specific events (heartbeats, delivery confirmations, etc.). + */ + public val events: Flow +} + +/** + * Statistics reported by a Store-and-Forward server node. + * + * @property messagesStored current number of messages held in the store + * @property messagesMax maximum storage capacity + * @property uptime server uptime in seconds + * @property requests total number of history requests served + * @property requestsFailed number of failed history requests + * @property heartbeat whether the server sends periodic heartbeats + */ +public data class StoreForwardStats( + val messagesStored: Int = 0, + val messagesMax: Int = 0, + val uptime: Int = 0, + val requests: Int = 0, + val requestsFailed: Int = 0, + val heartbeat: Boolean = false, +) + +/** + * Events specific to Store-and-Forward operations. + */ +public sealed interface StoreForwardEvent { + /** + * A S&F server was discovered on the mesh. + */ + public data class ServerDiscovered(val nodeId: NodeId) : StoreForwardEvent + + /** + * A S&F server went offline or was removed. + */ + public data class ServerLost(val nodeId: NodeId) : StoreForwardEvent + + /** + * History replay has started — messages are being delivered. + * + * @property server the S&F node delivering messages + * @property messageCount number of messages being replayed + */ + public data class HistoryReplayStarted( + val server: NodeId, + val messageCount: Int, + ) : StoreForwardEvent + + /** + * History replay is complete. + */ + public data class HistoryReplayComplete( + val server: NodeId, + val delivered: Int, + ) : StoreForwardEvent + + /** + * Heartbeat received from a S&F server (indicates it's still active). + */ + public data class Heartbeat(val server: NodeId) : StoreForwardEvent +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/Base64Url.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/Base64Url.kt new file mode 100644 index 0000000..fd8afb4 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/Base64Url.kt @@ -0,0 +1,63 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +private const val ALPHABET: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + +internal fun base64UrlEncode(bytes: ByteArray): String { + if (bytes.isEmpty()) return "" + val sb = StringBuilder((bytes.size * 4 + 2) / 3) + var i = 0 + while (i + 2 < bytes.size) { + val b0 = bytes[i].toInt() and 0xff + val b1 = bytes[i + 1].toInt() and 0xff + val b2 = bytes[i + 2].toInt() and 0xff + sb.append(ALPHABET[b0 ushr 2]) + sb.append(ALPHABET[((b0 and 0x3) shl 4) or (b1 ushr 4)]) + sb.append(ALPHABET[((b1 and 0xf) shl 2) or (b2 ushr 6)]) + sb.append(ALPHABET[b2 and 0x3f]) + i += 3 + } + val rem = bytes.size - i + if (rem == 1) { + val b0 = bytes[i].toInt() and 0xff + sb.append(ALPHABET[b0 ushr 2]) + sb.append(ALPHABET[(b0 and 0x3) shl 4]) + } else if (rem == 2) { + val b0 = bytes[i].toInt() and 0xff + val b1 = bytes[i + 1].toInt() and 0xff + sb.append(ALPHABET[b0 ushr 2]) + sb.append(ALPHABET[((b0 and 0x3) shl 4) or (b1 ushr 4)]) + sb.append(ALPHABET[(b1 and 0xf) shl 2]) + } + return sb.toString() +} + +internal fun base64UrlDecode(input: String): ByteArray? { + val cleaned = input.trimEnd('=') + val out = ArrayList(cleaned.length * 3 / 4 + 2) + var buffer = 0 + var bits = 0 + for (ch in cleaned) { + val v = when (ch) { + in 'A'..'Z' -> ch - 'A' + in 'a'..'z' -> ch - 'a' + 26 + in '0'..'9' -> ch - '0' + 52 + '-' -> 62 + '_' -> 63 + else -> return null + } + buffer = (buffer shl 6) or v + bits += 6 + if (bits >= 8) { + bits -= 8 + out.add(((buffer ushr bits) and 0xff).toByte()) + } + } + return out.toByteArray() +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt index 2232eb2..1440981 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt @@ -13,7 +13,7 @@ import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.DeviceConnectionStatus import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery @@ -187,10 +187,10 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { return AdminResult.Success(reply) } - private fun decodeNeighborInfo(payload: okio.ByteString, portnum: PortNum?): AdminResult? { + private fun decodeNeighborInfo(payload: okio.ByteString, portnum: PortNum?): AdminResult? { if (portnum != PortNum.NEIGHBORINFO_APP) return null val info = try { - NeighborInfo.ADAPTER.decode(payload) + ProtoNeighborInfo.ADAPTER.decode(payload) } catch (_: Exception) { return null } @@ -247,5 +247,5 @@ internal sealed interface ResponseKind { data object AdminDeviceUIConfig : ResponseKind data object Telemetry : ResponseKind data object RouteDiscoveryReply : ResponseKind - data object NeighborInfoReply : ResponseKind + data object NeighborInfoReply : ResponseKind } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt index 370cd16..2960fba 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt @@ -10,7 +10,7 @@ package org.meshtastic.sdk.internal import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing @@ -26,7 +26,7 @@ import kotlin.time.Duration * `ROUTING_APP` with `want_response = true`. The mesh propagates the discovery hop-by-hop; * the destination replies with `route_reply` populated. The dispatcher matches by * `request_id`. - * - [requestNeighborInfo] sends an empty [NeighborInfo] on `NEIGHBORINFO_APP` with + * - [requestNeighborInfo] sends an empty [ProtoNeighborInfo] on `NEIGHBORINFO_APP` with * `want_response = true`. The neighborinfo module on the device responds with its current * neighbor table. */ @@ -50,14 +50,14 @@ internal class RoutingApiImpl(private val engine: MeshEngine, private val rpcTim return engine.submitRpc(packet, requestId, ResponseKind.RouteDiscoveryReply, rpcTimeout) } - override suspend fun requestNeighborInfo(node: NodeId): AdminResult { + override suspend fun requestNeighborInfo(node: NodeId): AdminResult { val target = if (node == NodeId.LOCAL) { NodeId(engine.myNodeNumOrNull() ?: return AdminResult.NodeUnreachable) } else { node } val requestId = engine.nextMessageId().raw - val payload = NeighborInfo.ADAPTER.encode(NeighborInfo()).toByteString() + val payload = ProtoNeighborInfo.ADAPTER.encode(ProtoNeighborInfo()).toByteString() val packet = MeshPacket( id = requestId, from = engine.myNodeNumOrNull() ?: 0, diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt index d320608..bf6b1f0 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing @@ -121,7 +121,7 @@ class P2RoutingRpcTest { val req = transport.outboundPackets().drop(outboundBefore) .last { it.decoded?.portnum == PortNum.NEIGHBORINFO_APP } - val expected = NeighborInfo( + val expected = ProtoNeighborInfo( node_id = 1, last_sent_by_id = 1, node_broadcast_interval_secs = 600, @@ -131,7 +131,7 @@ class P2RoutingRpcTest { runCurrent() val result = deferred.await() - assertIs>(result) + assertIs>(result) assertEquals(expected, result.value) client.disconnect() } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt new file mode 100644 index 0000000..af0d9ec --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt @@ -0,0 +1,78 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.meshtastic.proto.Channel + +class ChannelHelpersTest { + @Test + fun validChannelValidates() { + val result = ChannelHelpers.validate( + name = "LongFast", + psk = ByteArray(ChannelHelpers.MIN_PSK_LENGTH) { 0x42 }, + ) + + assertTrue(result.isValid) + assertTrue(result.errors.isEmpty()) + } + + @Test + fun nameTooLongFailsValidation() { + val result = ChannelHelpers.validate( + name = "123456789012", + psk = byteArrayOf(0x01), + ) + + assertFalse(result.isValid) + assertTrue(result.errors.any { it.contains("exceeds") }) + } + + @Test + fun invalidPskLengthsFailValidation() { + val result = ChannelHelpers.validate( + name = "mesh", + psk = ByteArray(8), + ) + + assertFalse(result.isValid) + assertTrue(result.errors.any { it.contains("PSK") }) + } + + @Test + fun findEmptySlotUsesFirstDisabledOrMissingSecondarySlot() { + val channels = listOf( + Channel(index = 0, role = Channel.Role.PRIMARY), + Channel(index = 1, role = Channel.Role.SECONDARY), + Channel(index = 2, role = Channel.Role.DISABLED), + ) + + assertEquals(2, ChannelHelpers.findEmptySlot(channels)) + assertEquals(2, ChannelHelpers.findEmptySlot(channels.take(2))) + } + + @Test + fun findEmptySlotReturnsNullWhenFull() { + val channels = listOf( + Channel(index = 0, role = Channel.Role.PRIMARY), + Channel(index = 1, role = Channel.Role.SECONDARY), + Channel(index = 2, role = Channel.Role.SECONDARY), + Channel(index = 3, role = Channel.Role.SECONDARY), + Channel(index = 4, role = Channel.Role.SECONDARY), + Channel(index = 5, role = Channel.Role.SECONDARY), + Channel(index = 6, role = Channel.Role.SECONDARY), + Channel(index = 7, role = Channel.Role.SECONDARY), + ) + + assertNull(ChannelHelpers.findEmptySlot(channels)) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelUrlsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelUrlsTest.kt index 62110c3..e505477 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelUrlsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelUrlsTest.kt @@ -57,4 +57,8 @@ class ChannelUrlsTest { byteArrayOf(0x01, 0x02).fold(0) { a, b -> a xor (b.toInt() and 0xff) } assertEquals(expected and 0xff, ChannelSettings.hash("abc", byteArrayOf(0x01, 0x02))) } + + @Test fun channelNameHashUsesDjb2() { + assertEquals(130429955u, channelNameHashDjb2("LongFast")) + } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt new file mode 100644 index 0000000..68ead79 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt @@ -0,0 +1,54 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.time.Duration.Companion.seconds +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CongestionTest { + @Test fun levelIsLowWhenBothMetricsAreBelowMediumThreshold() { + assertEquals(CongestionLevel.LOW, CongestionMetrics(airUtilTx = 10f, channelUtil = 20f).level) + } + + @Test fun levelIsMediumWhenOneMetricCrossesMediumThreshold() { + assertEquals(CongestionLevel.MEDIUM, CongestionMetrics(airUtilTx = 25f, channelUtil = 10f).level) + } + + @Test fun levelIsHighWhenOneMetricCrossesHighThreshold() { + assertEquals(CongestionLevel.HIGH, CongestionMetrics(airUtilTx = 10f, channelUtil = 50f).level) + } + + @Test fun levelIsCriticalWhenOneMetricCrossesCriticalThreshold() { + assertEquals(CongestionLevel.CRITICAL, CongestionMetrics(airUtilTx = 75f, channelUtil = 10f).level) + } + + @Test fun suggestedBackoffIncreasesWithLevel() { + val low = CongestionMetrics(airUtilTx = 10f, channelUtil = 10f).suggestedBackoff + val medium = CongestionMetrics(airUtilTx = 25f, channelUtil = 10f).suggestedBackoff + val high = CongestionMetrics(airUtilTx = 50f, channelUtil = 10f).suggestedBackoff + val critical = CongestionMetrics(airUtilTx = 75f, channelUtil = 10f).suggestedBackoff + + assertEquals(kotlin.time.Duration.ZERO, low) + assertEquals(5.seconds, medium) + assertEquals(15.seconds, high) + assertEquals(30.seconds, critical) + assertTrue(low < medium) + assertTrue(medium < high) + assertTrue(high < critical) + } + + @Test fun canSendNonUrgentOnlyForLowAndMedium() { + assertTrue(CongestionMetrics(airUtilTx = 10f, channelUtil = 10f).canSendNonUrgent) + assertTrue(CongestionMetrics(airUtilTx = 25f, channelUtil = 10f).canSendNonUrgent) + assertFalse(CongestionMetrics(airUtilTx = 50f, channelUtil = 10f).canSendNonUrgent) + assertFalse(CongestionMetrics(airUtilTx = 75f, channelUtil = 10f).canSendNonUrgent) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ConnectionStateTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ConnectionStateTest.kt new file mode 100644 index 0000000..4053a01 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ConnectionStateTest.kt @@ -0,0 +1,44 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConnectionStateTest { + private val reconnecting = ConnectionState.Reconnecting(MeshtasticException.Transport("link lost"), attempt = 3) + + @Test fun isUsableOnlyWhenConnected() { + assertFalse(ConnectionState.Disconnected.isUsable) + assertFalse(ConnectionState.Connecting(attempt = 1).isUsable) + assertFalse(ConnectionState.Configuring(ConfigPhase.Stage2, progress = 0.5f).isUsable) + assertTrue(ConnectionState.Connected.isUsable) + assertFalse(reconnecting.isUsable) + } + + @Test fun isInProgressForActiveStates() { + assertFalse(ConnectionState.Disconnected.isInProgress) + assertTrue(ConnectionState.Connecting(attempt = 2).isInProgress) + assertTrue(ConnectionState.Configuring(ConfigPhase.Stage1, progress = 0.25f).isInProgress) + assertFalse(ConnectionState.Connected.isInProgress) + assertTrue(reconnecting.isInProgress) + } + + @Test fun statusMessageFormatsEachState() { + assertEquals("Disconnected", ConnectionState.Disconnected.statusMessage) + assertEquals("Connecting (attempt 2)", ConnectionState.Connecting(attempt = 2).statusMessage) + assertEquals( + "Configuring: Settling (37%)", + ConnectionState.Configuring(ConfigPhase.Settling, progress = 0.375f).statusMessage, + ) + assertEquals("Connected", ConnectionState.Connected.statusMessage) + assertEquals("Reconnecting (attempt 3)", reconnecting.statusMessage) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/DeviceCapabilitiesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/DeviceCapabilitiesTest.kt new file mode 100644 index 0000000..cc7b5fa --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/DeviceCapabilitiesTest.kt @@ -0,0 +1,51 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.ext + +import org.meshtastic.sdk.DeviceCapabilities +import org.meshtastic.sdk.DeviceVersion +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DeviceCapabilitiesTest { + @Test fun parsesVersions() { + assertEquals(20712, DeviceVersion("2.7.12").asInt) + assertEquals(20000, DeviceVersion("2.0").asInt) + assertEquals(0, DeviceVersion("invalid").asInt) + } + + @Test fun comparesVersions() { + assertTrue(DeviceVersion("2.7.18") > DeviceVersion("2.7.12")) + assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.99.99")) + } + + @Test fun detectsCapabilitiesByVersion() { + val current = DeviceCapabilities("2.7.18") + assertTrue(current.canMuteNode) + assertTrue(current.canSendVerifiedContacts) + + val old = DeviceCapabilities("2.6.7") + assertFalse(old.supportsQrCodeSharing) + } + + @Test fun nullVersionDisablesAllCapabilities() { + val capabilities = DeviceCapabilities(null) + assertFalse(capabilities.canMuteNode) + assertFalse(capabilities.canSendVerifiedContacts) + assertFalse(capabilities.canToggleTelemetryEnabled) + assertFalse(capabilities.canToggleUnmessageable) + assertFalse(capabilities.supportsQrCodeSharing) + assertFalse(capabilities.supportsStatusMessage) + assertFalse(capabilities.supportsTrafficManagementConfig) + assertFalse(capabilities.supportsTakConfig) + assertFalse(capabilities.supportsSecondaryChannelLocation) + assertFalse(capabilities.supportsEsp32Ota) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MqttEventsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MqttEventsTest.kt new file mode 100644 index 0000000..3883ad7 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MqttEventsTest.kt @@ -0,0 +1,37 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MqttEventsTest { + @Test fun mqttConnectedIsMeshEvent() { + val event: MeshEvent = MeshEvent.MqttConnected + assertEquals("connected", describe(event)) + } + + @Test fun mqttDisconnectedCarriesReason() { + val event: MeshEvent = MeshEvent.MqttDisconnected(reason = "broker closed connection") + assertEquals("broker closed connection", describe(event)) + assertEquals("broker closed connection", (event as MeshEvent.MqttDisconnected).reason) + } + + @Test fun mqttDisconnectedDefaultsReasonToNull() { + val event: MeshEvent.MqttDisconnected = MeshEvent.MqttDisconnected() + assertNull(event.reason) + assertEquals("none", describe(event)) + } + + private fun describe(event: MeshEvent): String = when (event) { + MeshEvent.MqttConnected -> "connected" + is MeshEvent.MqttDisconnected -> event.reason ?: "none" + else -> "other" + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NeighborInfoTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NeighborInfoTest.kt new file mode 100644 index 0000000..a247d15 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NeighborInfoTest.kt @@ -0,0 +1,60 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NeighborInfoTest { + @Test + fun fromProtoParsesKnownValues() { + val info = NeighborInfo.fromProto( + reportingNode = 0x1234, + neighborNodeIds = listOf(0x2001, 0x2002), + snrValues = listOf(7.5f, -2.25f), + timestamp = 1_700_000_000, + ) + + assertEquals(NodeId(0x1234), info.nodeId) + assertEquals(2, info.neighbors.size) + assertEquals(NeighborInfo.Neighbor(NodeId(0x2001), 7.5f), info.neighbors[0]) + assertEquals(NeighborInfo.Neighbor(NodeId(0x2002), -2.25f), info.neighbors[1]) + assertEquals(1_700_000_000, info.lastUpdated) + } + + @Test + fun fromProtoSupportsEmptyNeighbors() { + val info = NeighborInfo.fromProto( + reportingNode = 0x1234, + neighborNodeIds = emptyList(), + snrValues = emptyList(), + ) + + assertTrue(info.neighbors.isEmpty()) + assertEquals(0, info.lastUpdated) + } + + @Test + fun formatOutputsReadableSummary() { + val info = NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf( + NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 7.5f), + NeighborInfo.Neighbor(nodeId = NodeId(3), snr = -1.0f), + ), + ) + + assertEquals( + "Neighbors of 00000001 (2):\n" + + " 00000002 — SNR: 7.5 dB\n" + + " 00000003 — SNR: -1.0 dB\n", + info.format(), + ) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeChangePresenceTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeChangePresenceTest.kt new file mode 100644 index 0000000..e73bf35 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeChangePresenceTest.kt @@ -0,0 +1,44 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class NodeChangePresenceTest { + @Test + fun presenceChangesImplementNodeChange() { + val wentOffline = NodeChange.WentOffline(nodeId = NodeId(1), lastHeard = 123) + val cameOnline = NodeChange.CameOnline(nodeId = NodeId(2)) + + assertIs(wentOffline) + assertIs(cameOnline) + assertEquals(123, wentOffline.lastHeard) + assertEquals(NodeId(2), cameOnline.nodeId) + } + + @Test + fun presenceChangesCanBePatternMatched() { + val labels = listOf( + NodeChange.WentOffline(nodeId = NodeId(1), lastHeard = 321), + NodeChange.CameOnline(nodeId = NodeId(2)), + ).map { change -> + when (change) { + is NodeChange.Snapshot -> "snapshot" + is NodeChange.Added -> "added" + is NodeChange.Updated -> "updated" + is NodeChange.Removed -> "removed" + is NodeChange.WentOffline -> "offline:${change.lastHeard}" + is NodeChange.CameOnline -> "online:${change.nodeId.toHex()}" + } + } + + assertEquals(listOf("offline:321", "online:00000002"), labels) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeIdsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeIdsTest.kt index 1085b3e..9b59416 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeIdsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeIdsTest.kt @@ -32,6 +32,12 @@ class NodeIdsTest { assertNull(NodeId.fromHex("")) } + @Test fun defaultIdRoundTrips() { + val nodeId = NodeId(0xa1b2c3d4.toInt()) + assertEquals("!a1b2c3d4", nodeId.toDefaultId()) + assertEquals(nodeId, NodeId.fromDefaultId("!a1b2c3d4")) + } + @Test fun predicates() { assertTrue(NodeId.BROADCAST.isBroadcast) assertTrue(NodeId.LOCAL.isLocal()) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PositionUtilsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PositionUtilsTest.kt new file mode 100644 index 0000000..57df3c6 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PositionUtilsTest.kt @@ -0,0 +1,43 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.ext + +import org.meshtastic.sdk.LatLng +import org.meshtastic.sdk.PositionUtils +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PositionUtilsTest { + @Test fun convertsScaledIntegersToDegrees() { + assertClose(37.712, PositionUtils.intToDegrees(377120000), 1e-9) + } + + @Test fun validatesPositions() { + assertFalse(PositionUtils.isValidPosition(0.0, 0.0)) + assertTrue(PositionUtils.isValidPosition(37.7, -122.4)) + assertFalse(PositionUtils.isValidPosition(91.0, 0.0)) + } + + @Test fun computesDistance() { + val sf = LatLng(37.7749, -122.4194) + val la = LatLng(34.0522, -118.2437) + + assertClose(559_000.0, PositionUtils.distance(sf, la), 5_000.0) + } + + @Test fun computesBearing() { + assertClose(90.0, PositionUtils.bearing(0.0, 0.0, 0.0, 1.0), 0.0001) + assertClose(0.0, PositionUtils.bearing(0.0, 0.0, 1.0, 0.0), 0.0001) + } +} + +private fun assertClose(expected: Double, actual: Double, tolerance: Double) { + require(abs(expected - actual) <= tolerance) { "expected $expected got $actual" } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt new file mode 100644 index 0000000..8cb59c6 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt @@ -0,0 +1,63 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.time.Duration.Companion.seconds +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RetryPolicyTest { + @Test fun noneReturnsNullImmediately() { + assertNull(RetryPolicy.None.delayForAttempt(0)) + } + + @Test fun fixedReturnsConfiguredDelay() { + val policy = RetryPolicy.Fixed(maxAttempts = 3, delay = 7.seconds) + assertEquals(7.seconds, policy.delayForAttempt(0)) + } + + @Test fun fixedReturnsNullAtMaxAttempts() { + val policy = RetryPolicy.Fixed(maxAttempts = 3, delay = 7.seconds) + assertNull(policy.delayForAttempt(policy.maxAttempts)) + } + + @Test fun exponentialBackoffGrowsGeometrically() { + val policy = RetryPolicy.ExponentialBackoff( + maxAttempts = 4, + initialDelay = 1.seconds, + maxDelay = 20.seconds, + multiplier = 2.0, + jitterFactor = 0.0, + ) + + assertEquals(1.seconds, policy.delayForAttempt(0)) + assertEquals(2.seconds, policy.delayForAttempt(1)) + assertEquals(4.seconds, policy.delayForAttempt(2)) + assertEquals(8.seconds, policy.delayForAttempt(3)) + } + + @Test fun exponentialBackoffReturnsNullAtMaxAttempts() { + val policy = RetryPolicy.ExponentialBackoff(maxAttempts = 4, jitterFactor = 0.0) + assertNull(policy.delayForAttempt(policy.maxAttempts)) + } + + @Test fun exponentialBackoffIsCappedAtMaxDelay() { + val policy = RetryPolicy.ExponentialBackoff( + maxAttempts = 4, + initialDelay = 10.seconds, + maxDelay = 20.seconds, + multiplier = 3.0, + jitterFactor = 0.0, + ) + + assertEquals(10.seconds, policy.delayForAttempt(0)) + assertEquals(20.seconds, policy.delayForAttempt(1)) + assertEquals(20.seconds, policy.delayForAttempt(2)) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RouteDiscoveryResultTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RouteDiscoveryResultTest.kt new file mode 100644 index 0000000..05380a8 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RouteDiscoveryResultTest.kt @@ -0,0 +1,74 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals + +class RouteDiscoveryResultTest { + @Test fun fromProtoAssemblesFullRoutes() { + val source = NodeId(0x111) + val destination = NodeId(0x444) + + val result = RouteDiscoveryResult.fromProto( + source = source, + destination = destination, + intermediateRoute = listOf(0x222, 0x333), + intermediateRouteBack = listOf(0x333, 0x222), + snrTowards = listOf(40f, 32f), + snrBack = listOf(36f, 28f), + ) + + assertEquals(listOf(source, NodeId(0x222), NodeId(0x333), destination), result.route) + assertEquals(listOf(destination, NodeId(0x333), NodeId(0x222), source), result.routeBack) + assertEquals(listOf(40f, 32f), result.snrTowards) + assertEquals(listOf(36f, 28f), result.snrBack) + } + + @Test fun hopsAwayCountsIntermediateNodes() { + val routed = RouteDiscoveryResult( + route = listOf(NodeId(1), NodeId(2), NodeId(3), NodeId(4)), + routeBack = listOf(NodeId(4), NodeId(1)), + ) + val direct = RouteDiscoveryResult.fromProto( + source = NodeId(10), + destination = NodeId(20), + intermediateRoute = emptyList(), + intermediateRouteBack = emptyList(), + ) + + assertEquals(2, routed.hopsAway) + assertEquals(0, direct.hopsAway) + assertEquals(listOf(NodeId(10), NodeId(20)), direct.route) + assertEquals(listOf(NodeId(20), NodeId(10)), direct.routeBack) + } + + @Test fun formatRouteProducesReadableOutput() { + val result = RouteDiscoveryResult( + route = listOf(NodeId(1), NodeId(2), NodeId(3)), + routeBack = listOf(NodeId(3), NodeId(1)), + snrTowards = listOf(10.5f, 8.25f), + snrBack = listOf(7.75f), + ) + + val formatted = result.formatRoute { node -> "Node-${node.raw}" } + + assertEquals( + """ + Route (3 nodes): + Node-1 (SNR: 10.5) + Node-2 (SNR: 8.25) + Node-3 + Route back (2 nodes): + Node-3 (SNR: 7.75) + Node-1 + """.trimIndent(), + formatted.trimEnd(), + ) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SfppHashTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SfppHashTest.kt new file mode 100644 index 0000000..21df431 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SfppHashTest.kt @@ -0,0 +1,38 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.ext + +import org.meshtastic.sdk.SfppHash +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class SfppHashTest { + @Test fun outputIsAlways16Bytes() { + assertEquals(16, SfppHash.compute("payload".encodeToByteArray(), 1, 2, 3).size) + } + + @Test fun hashIsDeterministic() { + val first = SfppHash.compute("payload".encodeToByteArray(), 1, 2, 3) + val second = SfppHash.compute("payload".encodeToByteArray(), 1, 2, 3) + + assertContentEquals(first, second) + } + + @Test fun differentInputsProduceDifferentHashes() { + val first = SfppHash.compute("payload".encodeToByteArray(), 1, 2, 3) + val second = SfppHash.compute("payload".encodeToByteArray(), 1, 2, 4) + + assertFalse(first.contentEquals(second)) + } + + @Test fun emptyPayloadWorks() { + assertEquals(16, SfppHash.compute(byteArrayOf(), 1, 2, 3).size) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SharedContactUrlTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SharedContactUrlTest.kt new file mode 100644 index 0000000..bb8c6f6 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SharedContactUrlTest.kt @@ -0,0 +1,45 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.SharedContact +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SharedContactUrlTest { + @Test fun roundTripSharedContact() { + val contact = SharedContact( + node_num = 0xa1b2c3d4.toInt(), + should_ignore = true, + manually_verified = true, + ) + + val url = contact.toUrl() + + assertTrue(url.startsWith(SharedContactUrl.PREFIX)) + assertEquals(contact, SharedContactUrl.parse(url)) + } + + @Test fun parseRejectsInvalidUrl() { + assertNull(SharedContactUrl.parse("https://example.com/contact")) + assertNull(SharedContactUrl.parse("https://meshtastic.org/v/#@@@")) + } + + @Test fun parseIgnoresQueryParams() { + val contact = SharedContact(node_num = 1234) + val withQuery = contact.toUrl() + "?from=test" + + val parsed = SharedContactUrl.parse(withQuery) + + assertNotNull(parsed) + assertEquals(contact, parsed) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt new file mode 100644 index 0000000..4a2e3aa --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt @@ -0,0 +1,60 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.ext + +import org.meshtastic.sdk.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class StoreForwardApiTest { + + @Test + fun storeForwardStatsDefaults() { + val stats = StoreForwardStats() + assertEquals(0, stats.messagesStored) + assertEquals(0, stats.messagesMax) + assertEquals(false, stats.heartbeat) + } + + @Test + fun storeForwardStatsWithValues() { + val stats = StoreForwardStats( + messagesStored = 42, + messagesMax = 100, + uptime = 3600, + requests = 10, + requestsFailed = 1, + heartbeat = true, + ) + assertEquals(42, stats.messagesStored) + assertEquals(100, stats.messagesMax) + assertEquals(true, stats.heartbeat) + } + + @Test + fun storeForwardEventsAreSealed() { + val discovered: StoreForwardEvent = StoreForwardEvent.ServerDiscovered(NodeId(1)) + assertIs(discovered) + assertEquals(NodeId(1), discovered.nodeId) + + val lost: StoreForwardEvent = StoreForwardEvent.ServerLost(NodeId(2)) + assertIs(lost) + + val started: StoreForwardEvent = StoreForwardEvent.HistoryReplayStarted(NodeId(3), messageCount = 5) + assertIs(started) + assertEquals(5, started.messageCount) + + val complete: StoreForwardEvent = StoreForwardEvent.HistoryReplayComplete(NodeId(3), delivered = 4) + assertIs(complete) + assertEquals(4, complete.delivered) + + val heartbeat: StoreForwardEvent = StoreForwardEvent.Heartbeat(NodeId(5)) + assertIs(heartbeat) + } +} From 5f3e13f5006e64af7a5a0f6487bee0219f1988d3 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 15:15:46 -0500 Subject: [PATCH 12/36] =?UTF-8?q?feat:=20wire=20engine=20=E2=80=94=20conge?= =?UTF-8?q?stion=20emission,=20presence=20timer,=20retry=20extension,=20St?= =?UTF-8?q?ore-and-Forward=20impl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine Wiring: - E1: CongestionMetrics emission on telemetry level transitions (MeshEvent.CongestionWarning) - E2: Presence timer with configurable timeout (Builder.presenceTimeout), emits WentOffline/CameOnline - E3: MessageHandle.retryWith(policy) suspend extension for structured retries - E4: StoreForwardApiImpl — RPC dispatch, server discovery, event emission via RadioClient.storeForward Integration Tests: - CongestionEmissionTest: threshold transitions, zero suppression, per-node tracking - PresenceTimerTest: stale→offline, recovery→online, self-exclusion - MessageHandleRetryTest: retry/no-retry/exhaustion/exponential-backoff - StoreForwardImplTest: instantiation, state, RPC smoke Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/sdk/MessageHandleRetry.kt | 62 ++++- .../kotlin/org/meshtastic/sdk/Node.kt | 3 +- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 27 +- .../sdk/internal/CommandDispatcher.kt | 48 ++++ .../meshtastic/sdk/internal/EngineMessage.kt | 3 + .../org/meshtastic/sdk/internal/MeshEngine.kt | 68 +++++ .../sdk/internal/StoreForwardApiImpl.kt | 254 ++++++++++++++++++ .../sdk/ext/CongestionEmissionTest.kt | 163 +++++++++++ .../sdk/ext/MessageHandleRetryTest.kt | 209 ++++++++++++-- .../meshtastic/sdk/ext/PresenceTimerTest.kt | 163 +++++++++++ .../sdk/ext/StoreForwardImplTest.kt | 192 +++++++++++++ .../sdk/testing/FakeRadioTransport.kt | 19 ++ 12 files changed, 1183 insertions(+), 28 deletions(-) create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionEmissionTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardImplTest.kt diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt index 9159921..9518edf 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt @@ -7,8 +7,7 @@ */ package org.meshtastic.sdk -import org.meshtastic.sdk.MeshtasticException -import org.meshtastic.sdk.MessageHandle +import kotlinx.coroutines.delay /** * Re-enqueue the same packet that produced this [MessageHandle]. The engine assigns @@ -28,3 +27,62 @@ public suspend fun MessageHandle.retry(): MessageHandle { } return resend(pkt) } + +/** + * Awaits the outcome of this [MessageHandle], retrying according to [policy] if the send fails + * with a retryable failure. + * + * Non-retryable failures ([SendFailure.Disconnected], [SendFailure.Cancelled], + * [SendFailure.HandshakeFailed]) abort immediately regardless of remaining attempts. + * + * Requires that [MessageHandle.packet] and [MessageHandle.resendFn] are non-null (i.e., the handle + * was produced by [RadioClient.send] or [RadioClient.sendText], which always populate these fields). + * + * @param policy the retry strategy to apply + * @return the final [SendOutcome] (success after any attempt, or the last failure) + */ +public suspend fun MessageHandle.retryWith(policy: RetryPolicy): SendOutcome { + if (policy is RetryPolicy.None) return await() + + var currentHandle = this + var attempt = 0 + + while (true) { + val outcome = currentHandle.await() + if (outcome is SendOutcome.Success) return outcome + + val failure = (outcome as SendOutcome.Failure).reason + if (!failure.isRetryable()) return outcome + + val delayDuration = policy.delayForAttempt(attempt) ?: return outcome + val pkt = currentHandle.packet ?: return outcome + val resend = currentHandle.resendFn ?: return outcome + + delay(delayDuration) + currentHandle = resend(pkt) + attempt++ + } +} + +/** + * Whether this failure type is safe to retry. + * + * Terminal failures (disconnected, cancelled, handshake) should never be retried because the + * underlying session is no longer valid. + */ +private fun SendFailure.isRetryable(): Boolean = when (this) { + SendFailure.Disconnected, + SendFailure.HandshakeFailed, + SendFailure.Cancelled, + SendFailure.IdCollision, + -> false + + SendFailure.NoRoute, + SendFailure.MaxRetransmit, + SendFailure.Timeout, + SendFailure.DutyCycleLimit, + SendFailure.AckTimeout, + is SendFailure.Other, + is SendFailure.Unknown, + -> true +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt index f356d9a..303de3a 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt @@ -49,7 +49,8 @@ public enum class NodeField { * A delta notification of node state changes. * * The SDK emits these via the [RadioClient.nodes] flow. Late subscribers first receive a - * [Snapshot] of all known nodes, then live deltas ([Added], [Updated], [Removed]) in causal order. + * [Snapshot] of all known nodes, then live deltas ([Added], [Updated], [Removed], [WentOffline], + * [CameOnline]) in causal order. * * This delta-based design is efficient for large meshes: a 200-node network with frequent * telemetry would be wasteful to emit as a full `StateFlow>` (200 entries diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index 560335f..b93ff30 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -27,10 +27,12 @@ import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.internal.AdminApiImpl import org.meshtastic.sdk.internal.MeshEngine import org.meshtastic.sdk.internal.RoutingApiImpl +import org.meshtastic.sdk.internal.StoreForwardApiImpl import org.meshtastic.sdk.internal.TelemetryApiImpl import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds /** @@ -43,7 +45,7 @@ import kotlin.time.Duration.Companion.seconds * - **State:** [connection], [ownNode], [nodes] (reactive [StateFlow]s and [Flow]s). * - **Messaging:** [send] and [sendText] (enqueue immediately; return a [MessageHandle]). * - **Observability:** [packets], [events] (reactive flows). - * - **Operations:** [admin], [telemetry], [routing] sub-APIs. + * - **Operations:** [admin], [telemetry], [routing], [storeForward] sub-APIs. * * **Drop-in philosophy:** configure once with [Builder], then observe and call: * @@ -127,7 +129,7 @@ public class RadioClient internal constructor( /** * Node-change deltas. Late subscribers receive a [NodeChange.Snapshot] immediately * (single-replay), then live [NodeChange.Added] / [NodeChange.Updated] / - * [NodeChange.Removed] in causal order. + * [NodeChange.Removed] / [NodeChange.WentOffline] / [NodeChange.CameOnline] in causal order. * * **Buffering and backpressure:** the underlying `MutableSharedFlow` uses * `extraBufferCapacity = 256` with `BufferOverflow.SUSPEND` (per ADR-005). Slow collectors @@ -470,6 +472,19 @@ public class RadioClient internal constructor( RoutingApiImpl(engine = engine, rpcTimeout = rpcTimeout) } + /** + * Store-and-Forward API for requesting stored messages. Available while connected. + */ + public val storeForward: StoreForwardApi by lazy(LazyThreadSafetyMode.PUBLICATION) { + StoreForwardApiImpl( + engine = engine, + packetsFlow = engine.packets, + rpcTimeout = rpcTimeout, + coroutineContext = parentContext, + nowProvider = { clock.now() }, + ) + } + /** * Sends a raw [ToRadio] frame to the device. * @@ -477,7 +492,8 @@ public class RadioClient internal constructor( * (e.g., MQTT client proxy messages, XModem file transfers). The SDK engine does **not** track * or acknowledge these frames — delivery is best-effort at the transport level. * - * Prefer [send], [sendText], or the typed sub-APIs ([admin], [telemetry], etc.) whenever + * Prefer [send], [sendText], or the typed sub-APIs ([admin], [telemetry], [routing], + * [storeForward], etc.) whenever * possible. * * @param frame the fully constructed [ToRadio] message to send @@ -526,6 +542,7 @@ public class RadioClient internal constructor( private var payloadRedactor: PayloadRedactor = PayloadRedactor.Default private var sendTimeout: Duration = 30.seconds private var rpcTimeout: Duration = 30.seconds + private var presenceTimeout: Duration = 2.hours private var autoReconnectConfig: AutoReconnectConfig = AutoReconnectConfig.Disabled /** @@ -680,6 +697,9 @@ public class RadioClient internal constructor( */ public fun rpcTimeout(duration: Duration): Builder = apply { rpcTimeout = duration } + /** Configure the online/offline presence timeout for node presence events. */ + public fun presenceTimeout(timeout: Duration): Builder = apply { presenceTimeout = timeout } + /** * Configure the engine's built-in auto-reconnect supervisor. * @@ -728,6 +748,7 @@ public class RadioClient internal constructor( bleHeartbeatEnabled = bleHeartbeatEnabled, parentContext = coroutineContext, sendTimeout = sendTimeout, + presenceTimeout = presenceTimeout, autoReconnectConfig = autoReconnectConfig, ) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt index 1440981..e36d32e 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt @@ -18,6 +18,7 @@ import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing +import org.meshtastic.proto.StoreAndForward import org.meshtastic.proto.Telemetry import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.LogSink @@ -101,6 +102,8 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { ResponseKind.Telemetry -> decodeTelemetry(decoded.payload, decoded.portnum) ResponseKind.RouteDiscoveryReply -> decodeRoute(decoded.payload, decoded.portnum) ResponseKind.NeighborInfoReply -> decodeNeighborInfo(decoded.payload, decoded.portnum) + ResponseKind.StoreForwardReply -> decodeStoreForwardHistory(decoded.payload, decoded.portnum) + ResponseKind.StoreForwardStatsReply -> decodeStoreForwardStats(decoded.payload, decoded.portnum) } if (resolved == null) { @@ -197,6 +200,49 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { return AdminResult.Success(info) } + private fun decodeStoreForwardHistory( + payload: okio.ByteString, + portnum: PortNum?, + ): AdminResult? { + val message = decodeStoreForward(payload, portnum) ?: return null + return when (message.rr) { + StoreAndForward.RequestResponse.ROUTER_HISTORY -> { + val history = message.history ?: return null + AdminResult.Success(history) + } + + StoreAndForward.RequestResponse.ROUTER_BUSY -> AdminResult.Failed(Routing.Error.NO_RESPONSE) + StoreAndForward.RequestResponse.ROUTER_ERROR -> AdminResult.Failed(Routing.Error.GOT_NAK) + else -> null + } + } + + private fun decodeStoreForwardStats( + payload: okio.ByteString, + portnum: PortNum?, + ): AdminResult? { + val message = decodeStoreForward(payload, portnum) ?: return null + return when (message.rr) { + StoreAndForward.RequestResponse.ROUTER_STATS -> { + val stats = message.stats ?: return null + AdminResult.Success(stats) + } + + StoreAndForward.RequestResponse.ROUTER_BUSY -> AdminResult.Failed(Routing.Error.NO_RESPONSE) + StoreAndForward.RequestResponse.ROUTER_ERROR -> AdminResult.Failed(Routing.Error.GOT_NAK) + else -> null + } + } + + private fun decodeStoreForward(payload: okio.ByteString, portnum: PortNum?): StoreAndForward? { + if (portnum != PortNum.STORE_FORWARD_APP) return null + return try { + StoreAndForward.ADAPTER.decode(payload) + } catch (_: Exception) { + null + } + } + companion object { private const val TAG = "CommandDispatcher" @@ -248,4 +294,6 @@ internal sealed interface ResponseKind { data object Telemetry : ResponseKind data object RouteDiscoveryReply : ResponseKind data object NeighborInfoReply : ResponseKind + data object StoreForwardReply : ResponseKind + data object StoreForwardStatsReply : ResponseKind } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt index efe080f..2560211 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt @@ -106,6 +106,9 @@ internal sealed interface EngineMessage { */ data object LivenessTick : EngineMessage + /** Periodic presence check: scans nodes and emits WentOffline/CameOnline events. */ + data object PresenceCheckTick : EngineMessage + /** * Handshake stage timed out. * diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index b563d56..6a0237d 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -41,6 +41,8 @@ import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.AutoReconnectConfig import org.meshtastic.sdk.ChannelIndex +import org.meshtastic.sdk.CongestionLevel +import org.meshtastic.sdk.CongestionMetrics import org.meshtastic.sdk.ConfigBundle import org.meshtastic.sdk.ConfigPhase import org.meshtastic.sdk.ConnectionState @@ -72,6 +74,7 @@ import kotlin.math.pow import kotlin.random.Random import kotlin.time.Clock import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -89,6 +92,7 @@ internal class MeshEngine( private val bleHeartbeatEnabled: Boolean, private val parentContext: CoroutineContext, private val sendTimeout: Duration = 30.seconds, + private val presenceTimeout: Duration = 2.hours, private val autoReconnectConfig: AutoReconnectConfig = AutoReconnectConfig.Disabled, ) { @@ -192,6 +196,8 @@ internal class MeshEngine( */ private val lastHeartbeatAt = mutableMapOf() private val dirtyHeartbeats = mutableSetOf() + private val offlineNodes = mutableSetOf() + private val lastCongestionLevel = mutableMapOf() /** * once a storage write fails we flip this flag, emit a single @@ -549,6 +555,8 @@ internal class MeshEngine( // clear in-memory heartbeat bookkeeping (hydrated fresh on next connect). lastHeartbeatAt.clear() dirtyHeartbeats.clear() + offlineNodes.clear() + lastCongestionLevel.clear() // clear any leftover settle-buffer entries so a reconnect starts clean. settleBuffer.clear() // cancel any in-flight per-send ACK timers so they don't post stale messages. @@ -583,6 +591,7 @@ internal class MeshEngine( is EngineMessage.CancelHandle -> handleCancelHandle(msg) EngineMessage.HeartbeatTick -> handleHeartbeatTick() EngineMessage.LivenessTick -> handleLivenessTick() + EngineMessage.PresenceCheckTick -> handlePresenceCheckTick() is EngineMessage.HandshakeTimeout -> handleHandshakeTimeout(msg) EngineMessage.HandshakeStage1SettleComplete -> handleStage1SettleComplete() EngineMessage.HandshakeHeartbeatSettleComplete -> handleHeartbeatSettleComplete() @@ -706,12 +715,14 @@ internal class MeshEngine( try { val loaded = storage?.loadHeartbeats().orEmpty() lastHeartbeatAt.clear() + offlineNodes.clear() lastHeartbeatAt.putAll(loaded) } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.warn(TAG, e) { "Failed to load persisted heartbeats" } lastHeartbeatAt.clear() + offlineNodes.clear() } try { @@ -1227,6 +1238,16 @@ internal class MeshEngine( } } + // Presence check: scan nodes for offline transitions every 30s (piggybacks on heartbeat interval). + if (presenceTimeout.isPositive() && presenceTimeout.isFinite()) { + engineScope?.launch { + while (true) { + delay(HEARTBEAT_INTERVAL_MS) + inbox.trySend(EngineMessage.PresenceCheckTick) + } + } + } + // Drain the snapshot: dispatch every send that wasn't cancelled while waiting. for (msg in snapshot) { if (msg.stateFlow.value == SendState.Queued) { @@ -1263,6 +1284,7 @@ internal class MeshEngine( // Telemetry → node update: merge device_metrics into the node DB so that // NodeChange subscribers see battery/telemetry changes without calling TelemetryApi. maybeMergeDeviceMetrics(packet) + maybeEmitCongestionWarning(packet) // External config change detection: unsolicited admin messages from firmware // indicating another client modified channels/config on this device. maybeProcessExternalAdminChange(packet) @@ -1513,6 +1535,33 @@ internal class MeshEngine( } } + private fun maybeEmitCongestionWarning(packet: MeshPacket) { + val decoded = packet.decoded ?: return + if (decoded.portnum != PortNum.TELEMETRY_APP) return + if (packet.from == 0) return + + val telemetry = try { + Telemetry.ADAPTER.decode(decoded.payload) + } catch (_: Exception) { + return + } + + val deviceMetrics = telemetry.device_metrics ?: return + val airUtilTx: Float = deviceMetrics.air_util_tx ?: 0f + val channelUtil: Float = deviceMetrics.channel_utilization ?: 0f + + if (airUtilTx == 0f && channelUtil == 0f) return + + val metrics = CongestionMetrics(airUtilTx = airUtilTx, channelUtil = channelUtil) + val nodeId = NodeId(packet.from) + val prevLevel = lastCongestionLevel[nodeId] + + if (prevLevel != metrics.level) { + lastCongestionLevel[nodeId] = metrics.level + events.tryEmit(MeshEvent.CongestionWarning(metrics)) + } + } + /** * Detect unsolicited admin messages from the firmware that indicate an external client * changed channels, config, or module config on the connected device. Updates local @@ -1835,6 +1884,22 @@ internal class MeshEngine( flushDirtyHeartbeats() } + private fun handlePresenceCheckTick() { + if (handshakeStage != HandshakeStage.Ready) return + val now = Clock.System.now().toEpochMilliseconds() + val timeoutMs = presenceTimeout.inWholeMilliseconds + + for ((nodeId, lastMs) in lastHeartbeatAt) { + if (nodeId == NodeId(myNodeNum)) continue + val elapsed = now - lastMs + if (elapsed > timeoutMs && nodeId !in offlineNodes) { + offlineNodes.add(nodeId) + val lastHeardSec = (lastMs / 1000).toInt() + emitNodeChangeOrLog(NodeChange.WentOffline(nodeId, lastHeardSec)) + } + } + } + /** * one-shot Stage 1 retry. If we're still waiting for the device's first * `my_info`/config envelope at the half-budget mark, re-send `want_config_id = NONCE_STAGE1` @@ -2082,6 +2147,9 @@ internal class MeshEngine( val now = Clock.System.now().toEpochMilliseconds() lastHeartbeatAt[nodeId] = now dirtyHeartbeats.add(nodeId) + if (offlineNodes.remove(nodeId)) { + emitNodeChangeOrLog(NodeChange.CameOnline(nodeId)) + } } private fun flushDirtyHeartbeats() { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt new file mode 100644 index 0000000..b47ef79 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt @@ -0,0 +1,254 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.sdk.AdminResult +import org.meshtastic.sdk.ConnectionState +import org.meshtastic.sdk.NodeChange +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.StoreForwardApi +import org.meshtastic.sdk.StoreForwardEvent +import org.meshtastic.sdk.StoreForwardStats +import kotlin.coroutines.CoroutineContext +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Instant + +internal class StoreForwardApiImpl( + private val engine: MeshEngine, + private val packetsFlow: Flow, + private val rpcTimeout: Duration, + coroutineContext: CoroutineContext, + private val nowProvider: () -> Instant = { Clock.System.now() }, +) : StoreForwardApi { + + private val scope = CoroutineScope(coroutineContext + CoroutineName("meshtastic-store-forward")) + private val knownServers = linkedSetOf() + private val activeReplays = mutableMapOf() + + private val _servers = MutableStateFlow>(emptyList()) + override val servers = _servers.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + override val events: Flow = _events.asSharedFlow() + + init { + scope.launch { + merge( + packetsFlow.map { InternalSignal.Packet(it) }, + engine.nodes.map { InternalSignal.Node(it) }, + engine.connectionState.map { InternalSignal.Connection(it) }, + ).collect { signal -> + when (signal) { + is InternalSignal.Packet -> handlePacket(signal.packet) + is InternalSignal.Node -> handleNodeChange(signal.change) + is InternalSignal.Connection -> handleConnection(signal.state) + } + } + } + } + + override suspend fun requestHistory(since: Int?, server: NodeId?): AdminResult { + val myNode = engine.myNodeNumOrNull() ?: return AdminResult.NodeUnreachable + val targetServer = resolveServer(server) ?: return AdminResult.NodeUnreachable + val requestId = engine.nextMessageId().raw + val payload = StoreAndForward.ADAPTER.encode( + StoreAndForward( + rr = StoreAndForward.RequestResponse.CLIENT_HISTORY, + history = StoreAndForward.History(window = historyWindowMinutes(since)), + ), + ).toByteString() + val packet = MeshPacket( + id = requestId, + from = myNode, + to = targetServer.raw, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = payload, + want_response = true, + ), + ) + return when (val result = engine.submitRpc(packet, requestId, ResponseKind.StoreForwardReply, rpcTimeout)) { + is AdminResult.Success -> AdminResult.Success(result.value.history_messages) + AdminResult.Timeout -> AdminResult.Timeout + AdminResult.NodeUnreachable -> AdminResult.NodeUnreachable + AdminResult.SessionKeyExpired -> AdminResult.SessionKeyExpired + AdminResult.Unauthorized -> AdminResult.Unauthorized + is AdminResult.Failed -> result + } + } + + override suspend fun requestStats(server: NodeId): AdminResult { + val myNode = engine.myNodeNumOrNull() ?: return AdminResult.NodeUnreachable + val targetServer = resolveServer(server) ?: return AdminResult.NodeUnreachable + val requestId = engine.nextMessageId().raw + val payload = StoreAndForward.ADAPTER.encode( + StoreAndForward(rr = StoreAndForward.RequestResponse.CLIENT_STATS), + ).toByteString() + val packet = MeshPacket( + id = requestId, + from = myNode, + to = targetServer.raw, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = payload, + want_response = true, + ), + ) + return when (val result = engine.submitRpc(packet, requestId, ResponseKind.StoreForwardStatsReply, rpcTimeout)) { + is AdminResult.Success -> AdminResult.Success(result.value.toSdkStats()) + AdminResult.Timeout -> AdminResult.Timeout + AdminResult.NodeUnreachable -> AdminResult.NodeUnreachable + AdminResult.SessionKeyExpired -> AdminResult.SessionKeyExpired + AdminResult.Unauthorized -> AdminResult.Unauthorized + is AdminResult.Failed -> result + } + } + + private suspend fun handlePacket(packet: MeshPacket) { + val decoded = packet.decoded ?: return + if (decoded.portnum != PortNum.STORE_FORWARD_APP || packet.from == 0) return + val message = try { + StoreAndForward.ADAPTER.decode(decoded.payload) + } catch (_: Exception) { + return + } + val server = NodeId(packet.from) + when (message.rr) { + StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + StoreAndForward.RequestResponse.ROUTER_PONG, + -> { + rememberServer(server) + _events.emit(StoreForwardEvent.Heartbeat(server)) + } + + StoreAndForward.RequestResponse.ROUTER_HISTORY -> { + rememberServer(server) + val pending = message.history?.history_messages ?: 0 + _events.emit(StoreForwardEvent.HistoryReplayStarted(server, pending)) + if (pending <= 0) { + activeReplays.remove(server) + _events.emit(StoreForwardEvent.HistoryReplayComplete(server, 0)) + } else { + activeReplays[server] = ReplayProgress(expected = pending) + } + } + + StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST, + StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, + -> { + rememberServer(server) + val progress = activeReplays[server] ?: return + val delivered = progress.delivered + 1 + if (delivered >= progress.expected) { + activeReplays.remove(server) + _events.emit(StoreForwardEvent.HistoryReplayComplete(server, delivered)) + } else { + activeReplays[server] = progress.copy(delivered = delivered) + } + } + + StoreAndForward.RequestResponse.ROUTER_STATS, + StoreAndForward.RequestResponse.ROUTER_BUSY, + StoreAndForward.RequestResponse.ROUTER_ERROR, + StoreAndForward.RequestResponse.ROUTER_PING, + -> rememberServer(server) + + else -> Unit + } + } + + private suspend fun handleNodeChange(change: NodeChange) { + if (change is NodeChange.Removed) { + forgetServer(change.nodeId) + } + } + + private suspend fun handleConnection(state: ConnectionState) { + if (state == ConnectionState.Disconnected) { + clearServers() + } + } + + private suspend fun rememberServer(server: NodeId) { + if (knownServers.add(server)) { + _servers.value = knownServers.toList() + _events.emit(StoreForwardEvent.ServerDiscovered(server)) + } + } + + private suspend fun forgetServer(server: NodeId) { + if (knownServers.remove(server)) { + activeReplays.remove(server) + _servers.value = knownServers.toList() + _events.emit(StoreForwardEvent.ServerLost(server)) + } + } + + private suspend fun clearServers() { + if (knownServers.isEmpty()) return + val lost = knownServers.toList() + knownServers.clear() + activeReplays.clear() + _servers.value = emptyList() + lost.forEach { _events.emit(StoreForwardEvent.ServerLost(it)) } + } + + private fun resolveServer(server: NodeId?): NodeId? { + val candidate = server ?: _servers.value.firstOrNull() ?: return null + return if (candidate == NodeId.LOCAL) { + engine.myNodeNumOrNull()?.let(::NodeId) + } else { + candidate + } + } + + private fun historyWindowMinutes(since: Int?): Int { + if (since == null) return ALL_HISTORY_WINDOW_MINUTES + val ageSeconds = (nowProvider().epochSeconds - since.toLong()).coerceAtLeast(0) + return ((ageSeconds + 59) / 60) + .coerceIn(1, ALL_HISTORY_WINDOW_MINUTES.toLong()) + .toInt() + } + + private fun StoreAndForward.Statistics.toSdkStats(): StoreForwardStats = StoreForwardStats( + messagesStored = messages_saved, + messagesMax = messages_max, + uptime = up_time, + requests = requests_history, + requestsFailed = 0, + heartbeat = heartbeat, + ) + + private sealed interface InternalSignal { + data class Packet(val packet: MeshPacket) : InternalSignal + data class Node(val change: NodeChange) : InternalSignal + data class Connection(val state: ConnectionState) : InternalSignal + } + + private data class ReplayProgress(val expected: Int, val delivered: Int = 0) + + private companion object { + const val ALL_HISTORY_WINDOW_MINUTES: Int = 60 * 24 * 365 * 100 + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionEmissionTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionEmissionTest.kt new file mode 100644 index 0000000..20f936b --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionEmissionTest.kt @@ -0,0 +1,163 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Telemetry +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CongestionEmissionTest { + private fun TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:congestion-emission"), + autoHandshake = true, + nodeNum = 1, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .build() + return transport to client + } + + private fun FakeRadioTransport.injectTelemetry(fromNode: Int = nodeNum, deviceMetrics: DeviceMetrics) { + val payload = Telemetry.ADAPTER.encode(Telemetry(device_metrics = deviceMetrics)).toByteString() + injectPacket( + MeshPacket( + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.TELEMETRY_APP, + payload = payload, + ), + ), + ) + } + + @Test + fun criticalMetricsResolveToCriticalLevel() { + val metrics = CongestionMetrics(airUtilTx = 80f, channelUtil = 30f) + + assertEquals(CongestionLevel.CRITICAL, metrics.level) + } + + @Test + fun levelTransitionsEmitOnlyWhenCrossingThresholds() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { event -> + if (event is MeshEvent.CongestionWarning) { + events += event + } + } + } + runCurrent() + + transport.injectTelemetry(deviceMetrics = DeviceMetrics(air_util_tx = 10f, channel_utilization = 10f)) + runCurrent() + transport.injectTelemetry(deviceMetrics = DeviceMetrics(air_util_tx = 55f, channel_utilization = 10f)) + runCurrent() + transport.injectTelemetry(deviceMetrics = DeviceMetrics(air_util_tx = 60f, channel_utilization = 15f)) + runCurrent() + + assertEquals(listOf(CongestionLevel.LOW, CongestionLevel.HIGH), events.map { it.metrics.level }) + assertEquals(55f, events.last().metrics.airUtilTx) + + collectJob.cancel() + client.disconnect() + } + + @Test + fun zeroMetricsDoNotEmitWarnings() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { event -> + if (event is MeshEvent.CongestionWarning) { + events += event + } + } + } + runCurrent() + + transport.injectTelemetry(deviceMetrics = DeviceMetrics(air_util_tx = 0f, channel_utilization = 0f)) + runCurrent() + transport.injectTelemetry(deviceMetrics = DeviceMetrics(air_util_tx = 55f, channel_utilization = 0f)) + runCurrent() + + assertEquals(1, events.size) + assertEquals(CongestionLevel.HIGH, events.single().metrics.level) + + collectJob.cancel() + client.disconnect() + } + + @Test + fun multipleNodesAreTrackedIndependently() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { event -> + if (event is MeshEvent.CongestionWarning) { + events += event + } + } + } + runCurrent() + + transport.injectTelemetry( + fromNode = 0x10101010, + deviceMetrics = DeviceMetrics(air_util_tx = 55f, channel_utilization = 10f), + ) + runCurrent() + transport.injectTelemetry( + fromNode = 0x10101010, + deviceMetrics = DeviceMetrics(air_util_tx = 60f, channel_utilization = 15f), + ) + runCurrent() + transport.injectTelemetry( + fromNode = 0x20202020, + deviceMetrics = DeviceMetrics(air_util_tx = 65f, channel_utilization = 12f), + ) + runCurrent() + + assertEquals(2, events.size) + assertEquals(55f, events[0].metrics.airUtilTx) + assertEquals(65f, events[1].metrics.airUtilTx) + assertTrue(events.all { it.metrics.level == CongestionLevel.HIGH }) + + collectJob.cancel() + client.disconnect() + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MessageHandleRetryTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MessageHandleRetryTest.kt index 6a33b6b..d8b3089 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MessageHandleRetryTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MessageHandleRetryTest.kt @@ -5,32 +5,197 @@ * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) * SPDX-License-Identifier: GPL-3.0-or-later */ -package org.meshtastic.sdk +package org.meshtastic.sdk.ext -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.meshtastic.sdk.RadioClient -import org.meshtastic.sdk.TransportIdentity -import org.meshtastic.sdk.testing.FakeRadioTransport -import org.meshtastic.sdk.testing.InMemoryStorageProvider +import org.meshtastic.proto.MeshPacket +import org.meshtastic.sdk.MessageHandle +import org.meshtastic.sdk.MessageId +import org.meshtastic.sdk.RetryPolicy +import org.meshtastic.sdk.SendFailure +import org.meshtastic.sdk.SendOutcome +import org.meshtastic.sdk.SendState +import org.meshtastic.sdk.retryWith import kotlin.test.Test -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.time.Duration.Companion.milliseconds -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class) class MessageHandleRetryTest { - private fun TestScope.buildClient(): RadioClient = RadioClient.Builder() - .transport(FakeRadioTransport(identity = TransportIdentity("fake:test"), autoHandshake = true)) - .storage(InMemoryStorageProvider()) - .coroutineContext(backgroundScope.coroutineContext) - .build() - - @Test fun retryReturnsFreshHandle() = runTest { - val client = buildClient() - client.connect() - val first = client.sendText("retry") - val second = first.retry() - assertNotNull(second) - assertNotEquals(first.id, second.id) + private fun fakeHandle( + terminal: SendState, + packet: MeshPacket? = MeshPacket(id = 1), + resendFn: ((MeshPacket) -> MessageHandle)? = null, + ): MessageHandle { + val state = MutableStateFlow(terminal) + return MessageHandle( + id = MessageId(1), + _state = state, + cancelFn = {}, + packet = packet, + resendFn = resendFn, + ) + } + + @Test + fun successOnFirstAttemptDoesNotRetry() = runTest { + var resendCalled = false + val handle = fakeHandle( + SendState.Acked, + resendFn = { + resendCalled = true + fakeHandle(SendState.Acked) + }, + ) + + val result = handle.retryWith(RetryPolicy.Fixed(maxAttempts = 3, delay = 100.milliseconds)) + + assertEquals(SendOutcome.Success, result) + assertFalse(resendCalled) + } + + @Test + fun retriesOnAckTimeoutAfterDelay() = runTest { + var attempts = 0 + lateinit var resendFn: (MeshPacket) -> MessageHandle + resendFn = { pkt -> + attempts++ + fakeHandle(SendState.Acked, pkt, resendFn) + } + + val deferred = async { + fakeHandle( + SendState.Failed(SendFailure.AckTimeout), + resendFn = resendFn, + ).retryWith(RetryPolicy.Fixed(maxAttempts = 3, delay = 10.milliseconds)) + } + + runCurrent() + assertEquals(0, attempts) + + advanceTimeBy(9.milliseconds) + runCurrent() + assertEquals(0, attempts) + + advanceTimeBy(1.milliseconds) + runCurrent() + assertEquals(1, attempts) + assertEquals(SendOutcome.Success, deferred.await()) + } + + @Test + fun doesNotRetryOnDisconnected() = runTest { + var resendCalled = false + val handle = fakeHandle( + SendState.Failed(SendFailure.Disconnected), + resendFn = { + resendCalled = true + fakeHandle(SendState.Acked) + }, + ) + val result = handle.retryWith(RetryPolicy.Fixed(maxAttempts = 3, delay = 10.milliseconds)) + assertEquals(SendOutcome.Failure(SendFailure.Disconnected), result) + assertFalse(resendCalled) + } + + @Test + fun maxAttemptsExhaustedReturnsLastFailure() = runTest { + var attempts = 0 + lateinit var resendFn: (MeshPacket) -> MessageHandle + resendFn = { pkt -> + attempts++ + when (attempts) { + 1 -> fakeHandle(SendState.Failed(SendFailure.Timeout), pkt, resendFn) + else -> throw AssertionError("retryWith exceeded configured retry limit") + } + } + + val result = fakeHandle( + SendState.Failed(SendFailure.AckTimeout), + resendFn = resendFn, + ).retryWith(RetryPolicy.Fixed(maxAttempts = 1, delay = 10.milliseconds)) + + assertEquals(SendOutcome.Failure(SendFailure.Timeout), result) + assertEquals(1, attempts) + } + + @Test + fun nonePolicyDoesNotRetry() = runTest { + var resendCalled = false + val handle = fakeHandle( + SendState.Failed(SendFailure.AckTimeout), + resendFn = { + resendCalled = true + fakeHandle(SendState.Acked) + }, + ) + + val result = handle.retryWith(RetryPolicy.None) + + assertEquals(SendOutcome.Failure(SendFailure.AckTimeout), result) + assertFalse(resendCalled) + } + + @Test + fun exponentialBackoffDelaysIncrease() = runTest { + var attempts = 0 + lateinit var resendFn: (MeshPacket) -> MessageHandle + resendFn = { pkt -> + attempts++ + if (attempts >= 3) { + fakeHandle(SendState.Acked, pkt, resendFn) + } else { + fakeHandle(SendState.Failed(SendFailure.AckTimeout), pkt, resendFn) + } + } + + val deferred = async { + fakeHandle( + SendState.Failed(SendFailure.AckTimeout), + resendFn = resendFn, + ).retryWith( + RetryPolicy.ExponentialBackoff( + maxAttempts = 3, + initialDelay = 10.milliseconds, + maxDelay = 100.milliseconds, + multiplier = 2.0, + jitterFactor = 0.0, + ), + ) + } + + runCurrent() + assertEquals(0, attempts) + + advanceTimeBy(9.milliseconds) + runCurrent() + assertEquals(0, attempts) + + advanceTimeBy(1.milliseconds) + runCurrent() + assertEquals(1, attempts) + + advanceTimeBy(19.milliseconds) + runCurrent() + assertEquals(1, attempts) + + advanceTimeBy(1.milliseconds) + runCurrent() + assertEquals(2, attempts) + + advanceTimeBy(39.milliseconds) + runCurrent() + assertEquals(2, attempts) + + advanceTimeBy(1.milliseconds) + runCurrent() + assertEquals(3, attempts) + assertEquals(SendOutcome.Success, deferred.await()) } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt new file mode 100644 index 0000000..e526792 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt @@ -0,0 +1,163 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class PresenceTimerTest { + private fun TestScope.connectedClient( + storage: StorageProvider, + myNodeNum: Int = 0x11111111, + presenceTimeout: Duration = 1.seconds, + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:presence-timer"), + autoHandshake = true, + nodeNum = myNodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(storage) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .presenceTimeout(presenceTimeout) + .build() + return transport to client + } + + @Test + fun staleHeartbeatEmitsWentOfflineAndNewTrafficEmitsCameOnline() = runTest { + val remoteNode = NodeId(0x22222222) + val staleHeartbeatMs = Clock.System.now().toEpochMilliseconds() - 5.seconds.inWholeMilliseconds + val storage = SeededHeartbeatStorageProvider(mapOf(remoteNode to staleHeartbeatMs)) + val (transport, client) = connectedClient(storage) + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.nodes.collect { change -> + when (change) { + is NodeChange.WentOffline, is NodeChange.CameOnline -> observed += change + else -> Unit + } + } + } + + client.connect() + runCurrent() + + advanceTimeBy(30.seconds) + runCurrent() + + val wentOffline = observed.single { it is NodeChange.WentOffline } + assertIs(wentOffline) + assertEquals(remoteNode, wentOffline.nodeId) + assertEquals((staleHeartbeatMs / 1000).toInt(), wentOffline.lastHeard) + + transport.injectPacket( + MeshPacket( + from = remoteNode.raw, + to = 0, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP), + ), + ) + runCurrent() + + val cameOnline = observed.last() + assertIs(cameOnline) + assertEquals(remoteNode, cameOnline.nodeId) + + collector.cancel() + client.disconnect() + } + + @Test + fun selfNodeIsNeverMarkedOffline() = runTest { + val myNode = NodeId(0x11111111) + val staleHeartbeatMs = Clock.System.now().toEpochMilliseconds() - 5.seconds.inWholeMilliseconds + val storage = SeededHeartbeatStorageProvider(mapOf(myNode to staleHeartbeatMs)) + val (_, client) = connectedClient(storage, myNodeNum = myNode.raw) + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.nodes.collect { change -> + when (change) { + is NodeChange.WentOffline, is NodeChange.CameOnline -> observed += change + else -> Unit + } + } + } + + client.connect() + runCurrent() + advanceTimeBy(30.seconds) + runCurrent() + + assertTrue(observed.isEmpty()) + + collector.cancel() + client.disconnect() + } + + @Test + fun freshNodesAreNotMarkedOffline() = runTest { + val remoteNode = NodeId(0x33333333) + val freshHeartbeatMs = Clock.System.now().toEpochMilliseconds() + val storage = SeededHeartbeatStorageProvider(mapOf(remoteNode to freshHeartbeatMs)) + val (_, client) = connectedClient(storage, presenceTimeout = 60.seconds) + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.nodes.collect { change -> + when (change) { + is NodeChange.WentOffline, is NodeChange.CameOnline -> observed += change + else -> Unit + } + } + } + + client.connect() + runCurrent() + advanceTimeBy(30.seconds) + runCurrent() + + assertTrue(observed.isEmpty()) + + collector.cancel() + client.disconnect() + } +} + +private class SeededHeartbeatStorageProvider( + private val heartbeats: Map, +) : StorageProvider { + override suspend fun activate(identity: TransportIdentity): DeviceStorage = + InMemoryStorage().also { storage -> + heartbeats.forEach { (nodeId, heartbeatMs) -> + storage.saveHeartbeat(nodeId, heartbeatMs) + } + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardImplTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardImplTest.kt new file mode 100644 index 0000000..0ffcde6 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardImplTest.kt @@ -0,0 +1,192 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.ext + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.sdk.AdminResult +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.StoreForwardEvent +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class StoreForwardImplTest { + + private fun kotlinx.coroutines.test.TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:p2-store-forward"), + autoHandshake = true, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .rpcTimeout(60.seconds) + .sendTimeout(60.seconds) + .build() + return transport to client + } + + @Test + fun storeForwardStartsEmptyAndEventsFlowIsCollectible() = runTest { + val (_, client) = connectedClient() + client.connect() + runCurrent() + + val storeForward = client.storeForward + assertNotNull(storeForward) + assertEquals(emptyList(), storeForward.servers.value) + + val collected = mutableListOf() + val collector = backgroundScope.launch { + storeForward.events.collect { collected += it } + } + runCurrent() + + assertTrue(collector.isActive) + assertTrue(collected.isEmpty()) + + collector.cancel() + client.disconnect() + } + + @Test + fun storeForwardTracksServersAndHeartbeats() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val collected = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { collected += it } + } + runCurrent() + + val server = NodeId(0x10203040) + transport.injectStoreForwardResponse( + requestId = 0, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 300), + ), + fromNode = server.raw, + ) + runCurrent() + + assertEquals(listOf(server), client.storeForward.servers.value) + assertEquals(StoreForwardEvent.ServerDiscovered(server), collected.first()) + assertEquals(StoreForwardEvent.Heartbeat(server), collected.last()) + + collector.cancel() + client.disconnect() + } + + @Test + fun requestHistoryUsesKnownServerAndReturnsPendingCount() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val server = NodeId(0x55667788) + transport.injectStoreForwardResponse( + requestId = 0, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = server.raw, + ) + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestHistory(server = null) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(server.raw, request.to) + assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, payload.rr) + + transport.injectStoreForwardResponse( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 3, window = 120000), + ), + fromNode = server.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(3, result.value) + client.disconnect() + } + + @Test + fun requestStatsMapsProtoStatistics() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val server = NodeId(0x12345678) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.storeForward.requestStats(server) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(StoreAndForward.RequestResponse.CLIENT_STATS, payload.rr) + + transport.injectStoreForwardResponse( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_STATS, + stats = StoreAndForward.Statistics( + messages_saved = 9, + messages_max = 64, + up_time = 3600, + requests = 12, + requests_history = 7, + heartbeat = true, + ), + ), + fromNode = server.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(9, result.value.messagesStored) + assertEquals(64, result.value.messagesMax) + assertEquals(3600, result.value.uptime) + assertEquals(7, result.value.requests) + assertEquals(0, result.value.requestsFailed) + assertEquals(true, result.value.heartbeat) + client.disconnect() + } +} diff --git a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt index ea5ba03..7d0d4ed 100644 --- a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt +++ b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt @@ -181,6 +181,25 @@ public class FakeRadioTransport( injectFromRadio(FromRadio(packet = packet)) } + /** Inject a Store-and-Forward response correlated to [requestId]. */ + public fun injectStoreForwardResponse( + requestId: Int, + message: org.meshtastic.proto.StoreAndForward, + fromNode: Int = nodeNum, + ) { + val payload = okio.ByteString.of(*org.meshtastic.proto.StoreAndForward.ADAPTER.encode(message)) + val packet = MeshPacket( + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = payload, + request_id = requestId, + ), + ) + injectFromRadio(FromRadio(packet = packet)) + } + /** Inject a Routing.Ack correlated to [requestId] (setter ack-style tests). */ public fun injectRoutingAck(requestId: Int, fromNode: Int = nodeNum) { val payload = okio.ByteString.of(*Routing.ADAPTER.encode(Routing(error_reason = Routing.Error.NONE))) From 42d65cf3018379a1c5fa305460df304c1bb78c86 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 16:06:15 -0500 Subject: [PATCH 13/36] feat: SFPP protocol handling + MeshTopology graph utility - Add SfppLinkProvided and SfppCanonAnnounced events to StoreForwardApi - StoreForwardApiImpl: parse SFPP packets, compute hashes, emit events - MeshTopology: incremental graph from NeighborInfo (BFS shortest path, SNR edges) - Tests for SFPP event emission and topology graph operations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/MeshTopology.kt | 124 +++++++++++ .../org/meshtastic/sdk/StoreForwardApi.kt | 31 +++ .../sdk/internal/StoreForwardApiImpl.kt | 86 +++++++- .../org/meshtastic/sdk/MeshTopologyTest.kt | 195 +++++++++++++++++ .../internal/StoreForwardApiImplSfppTest.kt | 202 ++++++++++++++++++ 5 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt create mode 100644 core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt create mode 100644 core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt new file mode 100644 index 0000000..2481389 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt @@ -0,0 +1,124 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +/** + * Incremental mesh topology graph built from [NeighborInfo] reports. + * + * Thread-safe for concurrent reads; mutations are intended to be single-writer. + * The graph is directed — if node A reports node B as a neighbor, that's a directed edge A→B. + * Undirected queries consider both directions. + */ +public class MeshTopology { + /** Edge from a reporting node to its neighbor, with signal quality. */ + public data class Edge( + val from: NodeId, + val to: NodeId, + val snr: Float, + val lastUpdated: Int = 0, + ) + + // Internal adjacency: Map> + private val adjacency = mutableMapOf>() + + /** + * Ingest a [NeighborInfo] report, replacing all edges from the reporting node. + */ + public fun addNeighborInfo(info: NeighborInfo) { + val edges = mutableMapOf() + info.neighbors.forEach { neighbor -> + edges[neighbor.nodeId] = Edge( + from = info.nodeId, + to = neighbor.nodeId, + snr = neighbor.snr, + lastUpdated = info.lastUpdated, + ) + } + adjacency[info.nodeId] = edges + } + + /** + * Remove a node and all edges referencing it. + */ + public fun removeNode(nodeId: NodeId) { + adjacency.remove(nodeId) + adjacency.values.forEach { it.remove(nodeId) } + } + + /** All nodes that have reported neighbors or been reported as a neighbor. */ + public val nodes: Set + get() { + val result = mutableSetOf() + adjacency.forEach { (reporter, neighbors) -> + result.add(reporter) + result.addAll(neighbors.keys) + } + return result + } + + /** Get all outgoing edges from a node (nodes it reported as neighbors). */ + public fun getNeighbors(nodeId: NodeId): List = + adjacency[nodeId]?.values?.toList() ?: emptyList() + + /** Check if there's a direct edge in either direction between two nodes. */ + public fun isDirectReach(a: NodeId, b: NodeId): Boolean = + adjacency[a]?.containsKey(b) == true || adjacency[b]?.containsKey(a) == true + + /** Get the edge from [from] to [to] (if [from] reported [to] as neighbor). */ + public fun getEdge(from: NodeId, to: NodeId): Edge? = + adjacency[from]?.get(to) + + /** + * Find shortest path between two nodes using BFS on the undirected graph. + * Returns the path as a list of [NodeId]s (including start and end), or empty if unreachable. + */ + public fun shortestPath(from: NodeId, to: NodeId): List { + if (from == to) return listOf(from) + val visited = mutableSetOf(from) + val queue = ArrayDeque>() + queue.add(listOf(from)) + + while (queue.isNotEmpty()) { + val path = queue.removeFirst() + val current = path.last() + for (neighbor in undirectedNeighbors(current)) { + if (neighbor == to) return path + neighbor + if (visited.add(neighbor)) { + queue.add(path + neighbor) + } + } + } + return emptyList() + } + + /** + * Get all edges in the topology graph. + */ + public fun allEdges(): List = + adjacency.values.flatMap { it.values } + + /** + * Number of directed edges. + */ + public val edgeCount: Int + get() = adjacency.values.sumOf { it.size } + + /** Clear all topology data. */ + public fun clear() { + adjacency.clear() + } + + private fun undirectedNeighbors(nodeId: NodeId): Set { + val result = mutableSetOf() + adjacency[nodeId]?.keys?.let { result.addAll(it) } + adjacency.forEach { (reporter, neighbors) -> + if (neighbors.containsKey(nodeId)) result.add(reporter) + } + return result + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt index f005db7..3267922 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt @@ -120,4 +120,35 @@ public sealed interface StoreForwardEvent { * Heartbeat received from a S&F server (indicates it's still active). */ public data class Heartbeat(val server: NodeId) : StoreForwardEvent + + /** An SFPP link was provided — message is being routed or confirmed. */ + public data class SfppLinkProvided( + val packetId: Int, + val from: Int, + val to: Int, + val messageHash: ByteArray?, + val confirmed: Boolean, + ) : StoreForwardEvent { + override fun equals(other: Any?): Boolean = other is SfppLinkProvided && + packetId == other.packetId && + from == other.from && + to == other.to && + confirmed == other.confirmed && + messageHash.contentEquals(other.messageHash) + + override fun hashCode(): Int = (((packetId * 31 + from) * 31 + to) * 31 + confirmed.hashCode()) * 31 + + messageHash.contentHashCode() + } + + /** An SFPP canon announce — message is confirmed on the chain. */ + public data class SfppCanonAnnounced( + val messageHash: ByteArray, + val rxTime: Long, + ) : StoreForwardEvent { + override fun equals(other: Any?): Boolean = other is SfppCanonAnnounced && + messageHash.contentEquals(other.messageHash) && + rxTime == other.rxTime + + override fun hashCode(): Int = messageHash.contentHashCode() * 31 + rxTime.hashCode() + } } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt index b47ef79..7576f57 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt @@ -22,10 +22,12 @@ import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.StoreAndForward +import org.meshtastic.proto.StoreForwardPlusPlus import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.ConnectionState import org.meshtastic.sdk.NodeChange import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.SfppHash import org.meshtastic.sdk.StoreForwardApi import org.meshtastic.sdk.StoreForwardEvent import org.meshtastic.sdk.StoreForwardStats @@ -128,12 +130,28 @@ internal class StoreForwardApiImpl( private suspend fun handlePacket(packet: MeshPacket) { val decoded = packet.decoded ?: return if (decoded.portnum != PortNum.STORE_FORWARD_APP || packet.from == 0) return - val message = try { + + val legacy = try { StoreAndForward.ADAPTER.decode(decoded.payload) } catch (_: Exception) { + null + } + if (legacy != null && legacy.unknownFields.size == 0 && looksLikeLegacyStoreForward(legacy)) { + handleLegacySf(legacy, NodeId(packet.from)) return } - val server = NodeId(packet.from) + + val sfpp = try { + StoreForwardPlusPlus.ADAPTER.decode(decoded.payload) + } catch (_: Exception) { + null + } + if (sfpp != null) { + handleSfpp(sfpp) + } + } + + private suspend fun handleLegacySf(message: StoreAndForward, server: NodeId) { when (message.rr) { StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, StoreAndForward.RequestResponse.ROUTER_PONG, @@ -178,6 +196,70 @@ internal class StoreForwardApiImpl( } } + private fun looksLikeLegacyStoreForward(message: StoreAndForward): Boolean = when (message.rr) { + StoreAndForward.RequestResponse.ROUTER_HEARTBEAT -> message.heartbeat != null + StoreAndForward.RequestResponse.ROUTER_HISTORY -> message.history != null + StoreAndForward.RequestResponse.ROUTER_STATS -> message.stats != null + StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST, + StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, + -> message.text != null + + StoreAndForward.RequestResponse.ROUTER_PONG, + StoreAndForward.RequestResponse.ROUTER_BUSY, + StoreAndForward.RequestResponse.ROUTER_ERROR, + StoreAndForward.RequestResponse.ROUTER_PING, + -> message.stats == null && message.history == null && message.heartbeat == null && message.text == null + + else -> false + } + + private suspend fun handleSfpp(sfpp: StoreForwardPlusPlus) { + when (sfpp.sfpp_message_type) { + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, + -> handleLinkProvide(sfpp) + + StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp) + else -> Unit + } + } + + private suspend fun handleLinkProvide(sfpp: StoreForwardPlusPlus) { + val confirmed = sfpp.commit_hash.size != 0 + val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE + val hash = when { + sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray() + !isFragment && sfpp.message.size != 0 -> SfppHash.compute( + payload = sfpp.message.toByteArray(), + to = if (sfpp.encapsulated_to == 0) NodeId.BROADCAST.raw else sfpp.encapsulated_to, + from = sfpp.encapsulated_from, + id = sfpp.encapsulated_id, + ) + + else -> null + } + _events.emit( + StoreForwardEvent.SfppLinkProvided( + packetId = sfpp.encapsulated_id, + from = sfpp.encapsulated_from, + to = sfpp.encapsulated_to, + messageHash = hash, + confirmed = confirmed, + ), + ) + } + + private suspend fun handleCanonAnnounce(sfpp: StoreForwardPlusPlus) { + if (sfpp.message_hash.size == 0) return + _events.emit( + StoreForwardEvent.SfppCanonAnnounced( + messageHash = sfpp.message_hash.toByteArray(), + rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, + ), + ) + } + private suspend fun handleNodeChange(change: NodeChange) { if (change is NodeChange.Removed) { forgetServer(change.nodeId) diff --git a/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt b/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt new file mode 100644 index 0000000..d5eb505 --- /dev/null +++ b/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt @@ -0,0 +1,195 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MeshTopologyTest { + @Test + fun `addNeighborInfo populates nodes and edges`() { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf( + NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 7.5f), + NeighborInfo.Neighbor(nodeId = NodeId(3), snr = -1.0f), + ), + lastUpdated = 99, + ), + ) + + assertEquals(setOf(NodeId(1), NodeId(2), NodeId(3)), topology.nodes) + assertEquals(2, topology.edgeCount) + assertEquals( + setOf( + MeshTopology.Edge(NodeId(1), NodeId(2), 7.5f, 99), + MeshTopology.Edge(NodeId(1), NodeId(3), -1.0f, 99), + ), + topology.getNeighbors(NodeId(1)).toSet(), + ) + assertEquals(MeshTopology.Edge(NodeId(1), NodeId(2), 7.5f, 99), topology.getEdge(NodeId(1), NodeId(2))) + } + + @Test + fun `getNeighbors returns empty for unknown node`() { + val topology = MeshTopology() + + assertTrue(topology.getNeighbors(NodeId(404)).isEmpty()) + } + + @Test + fun `isDirectReach works bidirectionally`() { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 4.0f)), + ), + ) + + assertTrue(topology.isDirectReach(NodeId(1), NodeId(2))) + assertTrue(topology.isDirectReach(NodeId(2), NodeId(1))) + assertFalse(topology.isDirectReach(NodeId(1), NodeId(3))) + } + + @Test + fun `shortestPath finds multi-hop route`() { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 5.0f)), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(2), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(3), snr = 3.0f)), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(3), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(4), snr = 1.0f)), + ), + ) + + assertEquals( + listOf(NodeId(1), NodeId(2), NodeId(3), NodeId(4)), + topology.shortestPath(NodeId(1), NodeId(4)), + ) + } + + @Test + fun `shortestPath returns empty when unreachable`() { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 5.0f)), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(4), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(5), snr = 2.0f)), + ), + ) + + assertTrue(topology.shortestPath(NodeId(1), NodeId(5)).isEmpty()) + } + + @Test + fun `removeNode clears all references`() { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf( + NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 5.0f), + NeighborInfo.Neighbor(nodeId = NodeId(3), snr = 1.0f), + ), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(4), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 2.0f)), + ), + ) + + topology.removeNode(NodeId(2)) + + assertFalse(NodeId(2) in topology.nodes) + assertNull(topology.getEdge(NodeId(1), NodeId(2))) + assertNull(topology.getEdge(NodeId(4), NodeId(2))) + assertFalse(topology.isDirectReach(NodeId(1), NodeId(2))) + assertEquals(listOf(MeshTopology.Edge(NodeId(1), NodeId(3), 1.0f, 0)), topology.allEdges()) + } + + @Test + fun `addNeighborInfo replaces existing edges from same reporter`() { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf( + NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 5.0f), + NeighborInfo.Neighbor(nodeId = NodeId(3), snr = 4.0f), + ), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(4), snr = 9.0f)), + lastUpdated = 10, + ), + ) + + assertEquals(listOf(MeshTopology.Edge(NodeId(1), NodeId(4), 9.0f, 10)), topology.getNeighbors(NodeId(1))) + assertNull(topology.getEdge(NodeId(1), NodeId(2))) + assertNull(topology.getEdge(NodeId(1), NodeId(3))) + assertEquals(setOf(NodeId(1), NodeId(4)), topology.nodes) + } + + @Test + fun `allEdges returns correct count`() { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf( + NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 1.0f), + NeighborInfo.Neighbor(nodeId = NodeId(3), snr = 2.0f), + ), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(4), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(1), snr = 3.0f)), + ), + ) + + assertEquals(3, topology.allEdges().size) + assertEquals(3, topology.edgeCount) + } +} diff --git a/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt b/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt new file mode 100644 index 0000000..4d761d3 --- /dev/null +++ b/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt @@ -0,0 +1,202 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreForwardPlusPlus +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.SfppHash +import org.meshtastic.sdk.StoreForwardEvent +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class StoreForwardApiImplSfppTest { + + private fun kotlinx.coroutines.test.TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:p2-store-forward-sfpp"), + autoHandshake = true, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .rpcTimeout(60.seconds) + .sendTimeout(60.seconds) + .build() + return transport to client + } + + @Test + fun linkProvidePacketEmitsSfppLinkProvidedEvent() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expectedHash = byteArrayOf(1, 2, 3, 4) + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + message_hash = expectedHash.toByteString(), + commit_hash = byteArrayOf(9, 8, 7).toByteString(), + encapsulated_id = 0x1234, + encapsulated_to = 0x01020304, + encapsulated_from = 0x55667788, + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + assertEquals(0x1234, event.packetId) + assertEquals(0x55667788, event.from) + assertEquals(0x01020304, event.to) + assertEquals(true, event.confirmed) + assertContentEquals(expectedHash, assertNotNull(event.messageHash)) + + client.disconnect() + } + + @Test + fun canonAnnouncePacketEmitsSfppCanonAnnouncedEvent() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expectedHash = byteArrayOf(7, 6, 5, 4) + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppCanonAnnounced } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE, + message_hash = expectedHash.toByteString(), + encapsulated_rxtime = 0xFEDCBA98.toInt(), + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + assertContentEquals(expectedHash, event.messageHash) + assertEquals(0xFEDCBA98L, event.rxTime) + + client.disconnect() + } + + @Test + fun fragmentPacketsStillEmitSfppLinkProvidedEvents() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expectedHash = byteArrayOf(0xA, 0xB, 0xC) + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, + message_hash = expectedHash.toByteString(), + encapsulated_id = 77, + encapsulated_to = 88, + encapsulated_from = 99, + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + assertEquals(77, event.packetId) + assertEquals(99, event.from) + assertEquals(88, event.to) + assertEquals(false, event.confirmed) + assertContentEquals(expectedHash, assertNotNull(event.messageHash)) + + client.disconnect() + } + + @Test + fun linkProvideComputesHashWhenMessageHashMissing() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val message = "payload".encodeToByteArray() + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + message = message.toByteString(), + encapsulated_id = 42, + encapsulated_to = 0, + encapsulated_from = 0x0BADF00D, + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + val expectedHash = SfppHash.compute( + payload = message, + to = NodeId.BROADCAST.raw, + from = 0x0BADF00D, + id = 42, + ) + assertEquals(42, event.packetId) + assertEquals(0x0BADF00D, event.from) + assertEquals(0, event.to) + assertContentEquals(expectedHash, assertNotNull(event.messageHash)) + + client.disconnect() + } + + private fun FakeRadioTransport.injectSfpp( + message: StoreForwardPlusPlus, + fromNode: Int = 0x10203040, + ) { + injectPacket( + MeshPacket( + id = 1, + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = StoreForwardPlusPlus.ADAPTER.encode(message).toByteString(), + ), + ), + ) + } +} From 68b92dd61cfc50bc431db2fcb78f3424359d4627 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 16:46:54 -0500 Subject: [PATCH 14/36] docs: KDoc improvements + edge case tests - MeshTopology: usage example, Edge KDoc, shortestPath return docs - RetryPolicy: document delayForAttempt null return - MeshTopologyTest: clear(), self-loop, disconnected components - StoreForwardApiImplSfppTest: malformed payloads, null hash, unknown type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/MeshTopology.kt | 17 ++++- .../kotlin/org/meshtastic/sdk/RetryPolicy.kt | 2 + .../org/meshtastic/sdk/MeshTopologyTest.kt | 42 ++++++++++ .../internal/StoreForwardApiImplSfppTest.kt | 76 ++++++++++++++++++- 4 files changed, 134 insertions(+), 3 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt index 2481389..3f406e6 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt @@ -10,12 +10,23 @@ package org.meshtastic.sdk /** * Incremental mesh topology graph built from [NeighborInfo] reports. * + * Usage: + * ```kotlin + * val topology = MeshTopology() + * topology.addNeighborInfo(neighborInfo) + * val path = topology.shortestPath(nodeA, nodeB) + * val neighbors = topology.getNeighbors(nodeA) + * ``` + * * Thread-safe for concurrent reads; mutations are intended to be single-writer. * The graph is directed — if node A reports node B as a neighbor, that's a directed edge A→B. * Undirected queries consider both directions. */ public class MeshTopology { - /** Edge from a reporting node to its neighbor, with signal quality. */ + /** + * Directed edge from a reporting node [from] to a neighbor [to], carrying the reported signal + * quality ([snr]) and the [NeighborInfo.lastUpdated] value from the source report. + */ public data class Edge( val from: NodeId, val to: NodeId, @@ -75,7 +86,9 @@ public class MeshTopology { /** * Find shortest path between two nodes using BFS on the undirected graph. - * Returns the path as a list of [NodeId]s (including start and end), or empty if unreachable. + * Returns the path as a list of [NodeId]s including start and end. + * Returns `listOf(from)` when [from] == [to]. + * Returns an empty list when no path exists. */ public fun shortestPath(from: NodeId, to: NodeId): List { if (from == to) return listOf(from) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt index 27e2443..7346779 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt @@ -59,6 +59,8 @@ public sealed class RetryPolicy { /** * Computes the delay before the Nth retry attempt (0-indexed). * Returns null if the attempt exceeds maxAttempts. + * + * @return the delay before the next attempt, or null if max attempts exceeded */ public fun delayForAttempt(attempt: Int): Duration? = when (this) { is None -> null diff --git a/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt b/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt index d5eb505..2d8f975 100644 --- a/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt +++ b/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt @@ -169,6 +169,48 @@ class MeshTopologyTest { assertEquals(setOf(NodeId(1), NodeId(4)), topology.nodes) } + @Test + fun `clear removes all nodes and edges`() { + val topo = MeshTopology() + + topo.addNeighborInfo(NeighborInfo(NodeId(1), listOf(NeighborInfo.Neighbor(NodeId(2), 5f)))) + topo.clear() + + assertEquals(emptySet(), topo.nodes) + assertEquals(0, topo.edgeCount) + assertEquals(emptyList(), topo.shortestPath(NodeId(1), NodeId(2))) + } + + @Test + fun `self-loop does not break graph operations`() { + val topo = MeshTopology() + + topo.addNeighborInfo( + NeighborInfo( + NodeId(1), + listOf( + NeighborInfo.Neighbor(NodeId(1), 10f), + NeighborInfo.Neighbor(NodeId(2), 5f), + ), + ), + ) + + assertEquals(listOf(NodeId(1)), topo.shortestPath(NodeId(1), NodeId(1))) + assertEquals(listOf(NodeId(1), NodeId(2)), topo.shortestPath(NodeId(1), NodeId(2))) + assertTrue(topo.isDirectReach(NodeId(1), NodeId(1))) + } + + @Test + fun `disconnected components have no path between them`() { + val topo = MeshTopology() + + topo.addNeighborInfo(NeighborInfo(NodeId(1), listOf(NeighborInfo.Neighbor(NodeId(2), 5f)))) + topo.addNeighborInfo(NeighborInfo(NodeId(3), listOf(NeighborInfo.Neighbor(NodeId(4), 7f)))) + + assertEquals(emptyList(), topo.shortestPath(NodeId(1), NodeId(4))) + assertFalse(topo.isDirectReach(NodeId(1), NodeId(4))) + } + @Test fun `allEdges returns correct count`() { val topology = MeshTopology() diff --git a/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt b/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt index 4d761d3..248776d 100644 --- a/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt +++ b/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt @@ -27,8 +27,10 @@ import org.meshtastic.sdk.testing.InMemoryStorageProvider import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) @@ -183,9 +185,81 @@ class StoreForwardApiImplSfppTest { client.disconnect() } + @Test + fun `malformed SFPP payload does not crash`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val eventDeferred = async { client.storeForward.events.first() } + runCurrent() + + transport.injectStoreForwardPayload(byteArrayOf(0x0A)) + runCurrent() + + assertFalse(eventDeferred.isCompleted) + eventDeferred.cancel() + client.disconnect() + } + + @Test + fun `SFPP LINK_PROVIDE with no hash and no message emits event with null hash`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + encapsulated_id = 99, + encapsulated_to = 0x11111111, + encapsulated_from = 0x22222222, + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + assertEquals(99, event.packetId) + assertEquals(0x22222222, event.from) + assertEquals(0x11111111, event.to) + assertNull(event.messageHash) + assertFalse(event.confirmed) + + client.disconnect() + } + + @Test + fun `SFPP packet with unknown message type is ignored`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val eventDeferred = async { client.storeForward.events.first() } + runCurrent() + + transport.injectStoreForwardPayload(byteArrayOf(0x08, 0x63)) + runCurrent() + + assertFalse(eventDeferred.isCompleted) + eventDeferred.cancel() + client.disconnect() + } + private fun FakeRadioTransport.injectSfpp( message: StoreForwardPlusPlus, fromNode: Int = 0x10203040, + ) { + injectStoreForwardPayload(StoreForwardPlusPlus.ADAPTER.encode(message), fromNode) + } + + private fun FakeRadioTransport.injectStoreForwardPayload( + payload: ByteArray, + fromNode: Int = 0x10203040, ) { injectPacket( MeshPacket( @@ -194,7 +268,7 @@ class StoreForwardApiImplSfppTest { to = 0, decoded = Data( portnum = PortNum.STORE_FORWARD_APP, - payload = StoreForwardPlusPlus.ADAPTER.encode(message).toByteString(), + payload = payload.toByteString(), ), ), ) From 8ebf8d25010b8f4ad5820ade7cd2c9a5f7fde9c0 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 17:22:42 -0500 Subject: [PATCH 15/36] fix: normalize SFPP destination in SfppLinkProvided event + fix MeshTopology doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SfppLinkProvided.to now emits normalized destination (broadcast 0 → NodeId.BROADCAST) so consumers can reliably correlate/recompute hashes from event data - MeshTopology KDoc: remove false thread-safety claim (unsynchronized mutableMap) - Update SFPP test to assert normalized broadcast destination Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt | 2 +- .../kotlin/org/meshtastic/sdk/StoreForwardApi.kt | 7 ++++++- .../org/meshtastic/sdk/internal/StoreForwardApiImpl.kt | 5 +++-- .../meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt index 3f406e6..b28ca6e 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt @@ -18,7 +18,7 @@ package org.meshtastic.sdk * val neighbors = topology.getNeighbors(nodeA) * ``` * - * Thread-safe for concurrent reads; mutations are intended to be single-writer. + * **Not thread-safe** — callers must synchronize externally if mutating and reading concurrently. * The graph is directed — if node A reports node B as a neighbor, that's a directed edge A→B. * Undirected queries consider both directions. */ diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt index 3267922..24925d0 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt @@ -121,7 +121,12 @@ public sealed interface StoreForwardEvent { */ public data class Heartbeat(val server: NodeId) : StoreForwardEvent - /** An SFPP link was provided — message is being routed or confirmed. */ + /** + * An SFPP link was provided — message is being routed or confirmed. + * + * @property to The normalized destination node num (broadcast 0 is replaced with [NodeId.BROADCAST]). + * @property messageHash The computed or provided hash for correlation (null if unavailable). + */ public data class SfppLinkProvided( val packetId: Int, val from: Int, diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt index 7576f57..4933442 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt @@ -228,11 +228,12 @@ internal class StoreForwardApiImpl( private suspend fun handleLinkProvide(sfpp: StoreForwardPlusPlus) { val confirmed = sfpp.commit_hash.size != 0 val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE + val normalizedTo = if (sfpp.encapsulated_to == 0) NodeId.BROADCAST.raw else sfpp.encapsulated_to val hash = when { sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray() !isFragment && sfpp.message.size != 0 -> SfppHash.compute( payload = sfpp.message.toByteArray(), - to = if (sfpp.encapsulated_to == 0) NodeId.BROADCAST.raw else sfpp.encapsulated_to, + to = normalizedTo, from = sfpp.encapsulated_from, id = sfpp.encapsulated_id, ) @@ -243,7 +244,7 @@ internal class StoreForwardApiImpl( StoreForwardEvent.SfppLinkProvided( packetId = sfpp.encapsulated_id, from = sfpp.encapsulated_from, - to = sfpp.encapsulated_to, + to = normalizedTo, messageHash = hash, confirmed = confirmed, ), diff --git a/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt b/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt index 248776d..0592609 100644 --- a/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt +++ b/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt @@ -179,7 +179,7 @@ class StoreForwardApiImplSfppTest { ) assertEquals(42, event.packetId) assertEquals(0x0BADF00D, event.from) - assertEquals(0, event.to) + assertEquals(NodeId.BROADCAST.raw, event.to) assertContentEquals(expectedHash, assertNotNull(event.messageHash)) client.disconnect() From 2a670998097179f74b6301cf350a7d6908b968cf Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 22:05:12 -0500 Subject: [PATCH 16/36] feat: add remote admin API (forNode), sendReaction, getDeviceMetadata - AdminApi.forNode(dest): returns a remote-targeting AdminApi instance that routes all admin calls to the specified mesh node - AdminApi.getDeviceMetadata(): request DeviceMetadata from target node - RadioClient.sendReaction(): convenience for emoji reactions with proper emoji indicator and reply_id These additions eliminate the need for consumers to manually construct AdminMessage + MeshPacket envelopes for remote admin operations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/AdminApi.kt | 39 ++++++++++++++++ .../kotlin/org/meshtastic/sdk/RadioClient.kt | 45 ++++++++++++++++++- .../meshtastic/sdk/internal/AdminApiImpl.kt | 18 +++++++- 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index d80e029..aacdfd7 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -11,6 +11,7 @@ import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.HamParameters import org.meshtastic.proto.KeyVerificationAdmin @@ -37,6 +38,44 @@ import kotlin.time.Instant */ public interface AdminApi { + // ── Remote targeting ──────────────────────────────────────────────────── + + /** + * Return an [AdminApi] instance that targets [dest] instead of the local device. + * + * All calls on the returned instance route admin messages to the specified remote node + * over the mesh. Note: `editSettings`, `getDeviceConnectionStatus`, and lifecycle commands + * (`reboot`, `shutdown`, `factoryReset`, `nodeDbReset`) work identically — the firmware + * handles admin-over-mesh transparently. + * + * ```kotlin + * val remoteAdmin = client.admin.forNode(NodeId(0x12345678.toInt())) + * remoteAdmin.setConfig(config) // → sent to remote node + * ``` + * + * @param dest the target node's [NodeId] + * @return a remote-targeting [AdminApi] instance + * @since 0.2.0 + */ + public fun forNode(dest: NodeId): AdminApi + + // ── Device info ───────────────────────────────────────────────────────── + + /** + * Request [DeviceMetadata] from the device (firmware version, hardware model, etc.). + * + * For the local node, this is cached during handshake and available via + * [RadioClient.deviceConfig]. For remote nodes, use [forNode] to target the desired node: + * + * ```kotlin + * val metadata = client.admin.forNode(remoteNodeId).getDeviceMetadata() + * ``` + * + * @return the device's metadata + * @since 0.2.0 + */ + public suspend fun getDeviceMetadata(): AdminResult + // ── Configs ───────────────────────────────────────────────────────────── /** Read a single [Config] section from the device. */ diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index b93ff30..f1aa0cd 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -345,7 +345,47 @@ public class RadioClient internal constructor( } /** - * Convenience: build a [MeshPacket] for the given [portnum] with [payload] and enqueue it. + * Convenience: send an emoji reaction to an existing message. + * + * Wraps the [emoji] in a [MeshPacket] with `decoded.portnum = TEXT_MESSAGE_APP`, + * `decoded.emoji = 1` (indicating this is a reaction, not a standalone message), and + * `decoded.reply_id` set to [replyId] (the packet ID of the message being reacted to). + * + * @param emoji the emoji string (single emoji character or sequence) + * @param to the destination [NodeId] (the sender of the original message, or broadcast) + * @param channel the channel index the reaction should be sent on + * @param replyId the packet ID of the original message being reacted to + * @return a handle tracking delivery state + * @throws MeshtasticException.NotConnected if not currently connected + * @throws MeshtasticException.PayloadTooLarge if the encoded emoji exceeds the device limit + * @since 0.2.0 + */ + @Throws(MeshtasticException::class) + public fun sendReaction( + emoji: String, + to: NodeId = NodeId.BROADCAST, + channel: ChannelIndex = ChannelIndex(0), + replyId: Int, + ): MessageHandle { + val payload = emoji.encodeToByteArray() + if (payload.size > DATA_PAYLOAD_LEN) { + throw MeshtasticException.PayloadTooLarge(DATA_PAYLOAD_LEN) + } + val packet = MeshPacket( + to = to.raw, + channel = channel.raw, + want_ack = true, + decoded = org.meshtastic.proto.Data( + portnum = org.meshtastic.proto.PortNum.TEXT_MESSAGE_APP, + payload = payload.toByteString(), + emoji = EMOJI_INDICATOR, + reply_id = replyId, + ), + ) + return send(packet) + } + + /** * * Constructs a packet with `decoded = Data(portnum, payload, want_response = false)` and * forwards to [send]. Exists so callers do not need to import `org.meshtastic.proto.Data` @@ -511,6 +551,9 @@ public class RadioClient internal constructor( // ── Builder ───────────────────────────────────────────────────────────── public companion object { + /** Indicates a reaction (emoji) rather than a standalone text message. */ + private const val EMOJI_INDICATOR: Int = 1 + /** Create a new [Builder]. */ public fun Builder(): Builder = Builder() } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 1b352f9..06a2fd5 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -15,6 +15,7 @@ import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.Data import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.HamParameters import org.meshtastic.proto.KeyVerificationAdmin @@ -52,8 +53,23 @@ internal class AdminApiImpl( private val engine: MeshEngine, private val rpcTimeout: Duration, private val nowProvider: () -> Instant = { Clock.System.now() }, + private val targetNode: NodeId? = null, ) : AdminApi { + override fun forNode(dest: NodeId): AdminApi = AdminApiImpl( + engine = engine, + rpcTimeout = rpcTimeout, + nowProvider = nowProvider, + targetNode = dest, + ) + + override suspend fun getDeviceMetadata(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_device_metadata_request = true), + kind = ResponseKind.AdminDeviceMetadata, + ) + } + /** * Returns `true` if the device is in managed mode, meaning all admin commands from non-zero * `from` addresses are silently dropped by firmware. The SDK always sends with @@ -426,7 +442,7 @@ internal class AdminApiImpl( return block() } - private fun localNode(): NodeId = NodeId(engine.myNodeNumOrNull() ?: 0) + private fun localNode(): NodeId = targetNode ?: NodeId(engine.myNodeNumOrNull() ?: 0) private inner class AdminEditImpl : AdminEdit { val writtenConfigs = mutableListOf() From 0f4d6b90222db34764d3b56fee1ecfdc262e6ef4 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 22:19:25 -0500 Subject: [PATCH 17/36] feat: add requestNodeInfo, fix editSettings remote routing - RadioClient.requestNodeInfo(node): sends NODEINFO_APP with want_response=true to request a remote node's identity - Fix AdminEditImpl.enqueueOrThrow to route through localNode() (respects targetNode for remote editSettings) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 25 +++++++++++++++++++ .../meshtastic/sdk/internal/AdminApiImpl.kt | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index f1aa0cd..85fe340 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -190,6 +190,31 @@ public class RadioClient internal constructor( return engine.nodeSnapshot() } + /** + * Request a remote node to send its [NodeInfo] (user identity + position). + * + * Sends an empty `NODEINFO_APP` packet with `want_response = true`. The remote node will + * reply with its [User] information, which the engine processes and emits via [nodes]. + * + * @param node the target node to request info from + * @return a [MessageHandle] tracking delivery state + * @throws MeshtasticException.NotConnected if not currently connected + * @since 0.2.0 + */ + @Throws(MeshtasticException::class) + public fun requestNodeInfo(node: NodeId): MessageHandle { + val packet = MeshPacket( + to = node.raw, + want_ack = true, + decoded = org.meshtastic.proto.Data( + portnum = org.meshtastic.proto.PortNum.NODEINFO_APP, + payload = okio.ByteString.EMPTY, + want_response = true, + ), + ) + return send(packet) + } + // ── Lifecycle ─────────────────────────────────────────────────────────── /** diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 06a2fd5..2eb2fdf 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -490,7 +490,7 @@ internal class AdminApiImpl( val packet = MeshPacket( id = id.raw, from = engine.myNodeNumOrNull() ?: 0, - to = engine.myNodeNumOrNull() ?: 0, + to = localNode().raw, decoded = Data( portnum = PortNum.ADMIN_APP, payload = payload, From 82584ac9a12274ef425934cd496877b111e5cbb8 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 12:37:35 -0500 Subject: [PATCH 18/36] feat: AdminResult extensions, MeshTopology cache, close() safety docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getOrNull, getOrElse, isSuccess, map, fold, onSuccess, onFailure extensions to AdminResult for ergonomic API usage - Cache MeshTopology.nodes with invalidation on addNeighborInfo/removeNode/clear to avoid O(n) allocation on every access - Document threading risk on RadioClient.close() (runBlocking can ANR on main thread — prefer suspend disconnect() from coroutines) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/MeshTopology.kt | 7 ++- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 4 ++ .../kotlin/org/meshtastic/sdk/Result.kt | 50 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt index b28ca6e..3691e0f 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt @@ -36,6 +36,7 @@ public class MeshTopology { // Internal adjacency: Map> private val adjacency = mutableMapOf>() + private var cachedNodes: Set? = null /** * Ingest a [NeighborInfo] report, replacing all edges from the reporting node. @@ -51,6 +52,7 @@ public class MeshTopology { ) } adjacency[info.nodeId] = edges + cachedNodes = null } /** @@ -59,17 +61,19 @@ public class MeshTopology { public fun removeNode(nodeId: NodeId) { adjacency.remove(nodeId) adjacency.values.forEach { it.remove(nodeId) } + cachedNodes = null } /** All nodes that have reported neighbors or been reported as a neighbor. */ public val nodes: Set get() { + cachedNodes?.let { return it } val result = mutableSetOf() adjacency.forEach { (reporter, neighbors) -> result.add(reporter) result.addAll(neighbors.keys) } - return result + return result.also { cachedNodes = it } } /** Get all outgoing edges from a node (nodes it reported as neighbors). */ @@ -124,6 +128,7 @@ public class MeshTopology { /** Clear all topology data. */ public fun clear() { adjacency.clear() + cachedNodes = null } private fun undirectedNeighbors(nodeId: NodeId): Set { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index 85fe340..3caf92f 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -289,6 +289,10 @@ public class RadioClient internal constructor( * Delegates to [disconnect] via `runBlocking`. Prefer the suspending [disconnect] when * already inside a coroutine — this overload exists so `RadioClient` works with Kotlin's * `use { }` idiom and Java's try-with-resources. + * + * **Warning:** Do not call from the main/UI thread — `runBlocking` will block the caller + * until disconnection completes, which can trigger ANR on Android or deadlock on iOS main. + * Use [disconnect] directly from a coroutine scope instead. */ override fun close() { runBlocking { disconnect() } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt index f28b79f..f6988be 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt @@ -290,3 +290,53 @@ public sealed interface AdminResult { */ public data class Failed(val routingError: Routing.Error) : AdminResult } + +// ── AdminResult extensions ────────────────────────────────────────────────── + +/** Returns the [Success] value, or `null` if the result is not a success. */ +public fun AdminResult.getOrNull(): T? = (this as? AdminResult.Success)?.value + +/** Returns the [Success] value, or [default] if the result is not a success. */ +public fun AdminResult.getOrElse(default: T): T = getOrNull() ?: default + +/** Returns the [Success] value, or the result of [block] if the result is not a success. */ +public inline fun AdminResult.getOrElse(block: (AdminResult) -> T): T = + when (this) { + is AdminResult.Success -> value + else -> block(this) + } + +/** Returns `true` if this is [AdminResult.Success]. */ +public val AdminResult.isSuccess: Boolean get() = this is AdminResult.Success + +/** Transforms the [Success] value with [transform], propagating failures unchanged. */ +public inline fun AdminResult.map(transform: (T) -> R): AdminResult = + when (this) { + is AdminResult.Success -> AdminResult.Success(transform(value)) + is AdminResult.SessionKeyExpired -> this + is AdminResult.Unauthorized -> this + is AdminResult.Timeout -> this + is AdminResult.NodeUnreachable -> this + is AdminResult.Failed -> this + } + +/** Applies [onSuccess] or [onFailure] depending on the result. */ +public inline fun AdminResult.fold( + onSuccess: (T) -> R, + onFailure: (AdminResult) -> R, +): R = when (this) { + is AdminResult.Success -> onSuccess(value) + else -> onFailure(this) +} + +/** Performs [action] if this is a [Success]. Returns the original result for chaining. */ +public inline fun AdminResult.onSuccess(action: (T) -> Unit): AdminResult { + if (this is AdminResult.Success) action(value) + return this +} + +/** Performs [action] if this is not a [Success]. Returns the original result for chaining. */ +public inline fun AdminResult.onFailure(action: (AdminResult) -> Unit): AdminResult { + if (this !is AdminResult.Success) action(this) + return this +} From 91c8bd5aa3c11fcc800571801553d28c7aa0b501 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 13:52:12 -0500 Subject: [PATCH 19/36] =?UTF-8?q?fix:=20critical=20SDK=20improvements=20?= =?UTF-8?q?=E2=80=94=20thread-safety,=20timeout,=20flow=20caching,=20error?= =?UTF-8?q?=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 critical fixes: 1. MeshTopology thread-safety: All methods are now suspend with internal Mutex synchronization. Safe for concurrent engine/UI access. Property 'nodes' → function nodes(), 'edgeCount' → edgeCount(). 2. submitAdminAck timeout: Wrap stateFlow.first{} with withTimeoutOrNull(rpcTimeout) so ACK-based admin ops cannot block forever if transport dies without emitting a terminal state. 3. Flow caching: RadioClient's public flow properties (connection, ownNode, configBundle, channels, nodes, packets, events) are now initialized once at construction — no new flow/StateFlow wrapper created per property access. 4. Routing error mapping: Added AdminResult.RateLimited variant for RATE_LIMIT_EXCEEDED. Fixed ACK-path mapSendFailureToAdminResult to properly map ADMIN_BAD_SESSION_KEY → SessionKeyExpired and ADMIN_PUBLIC_KEY_UNAUTHORIZED → Unauthorized (previously fell through to generic Failed). CommandDispatcher also maps RateLimited. 5. Session passkey: Verified already fully implemented (engine stamps all outgoing admin msgs, seeds from handshake, retryOnSessionExpiry handles refresh). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/MeshTopology.kt | 61 +++++++++++-------- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 21 +++---- .../kotlin/org/meshtastic/sdk/Result.kt | 8 +++ .../meshtastic/sdk/internal/AdminApiImpl.kt | 20 ++++-- .../sdk/internal/CommandDispatcher.kt | 2 + .../sdk/internal/StoreForwardApiImpl.kt | 2 + .../sdk/internal/TelemetryApiImpl.kt | 2 + .../org/meshtastic/sdk/P2AdminRpcTest.kt | 2 +- .../org/meshtastic/sdk/MeshTopologyTest.kt | 37 +++++------ 9 files changed, 93 insertions(+), 62 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt index 3691e0f..676cfe9 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt @@ -7,6 +7,9 @@ */ package org.meshtastic.sdk +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + /** * Incremental mesh topology graph built from [NeighborInfo] reports. * @@ -18,7 +21,8 @@ package org.meshtastic.sdk * val neighbors = topology.getNeighbors(nodeA) * ``` * - * **Not thread-safe** — callers must synchronize externally if mutating and reading concurrently. + * **Thread-safe** — all mutations and reads are guarded by an internal [Mutex]. Safe to call + * concurrently from the engine actor and UI collectors. * The graph is directed — if node A reports node B as a neighbor, that's a directed edge A→B. * Undirected queries consider both directions. */ @@ -34,6 +38,8 @@ public class MeshTopology { val lastUpdated: Int = 0, ) + private val mutex = Mutex() + // Internal adjacency: Map> private val adjacency = mutableMapOf>() private var cachedNodes: Set? = null @@ -41,7 +47,7 @@ public class MeshTopology { /** * Ingest a [NeighborInfo] report, replacing all edges from the reporting node. */ - public fun addNeighborInfo(info: NeighborInfo) { + public suspend fun addNeighborInfo(info: NeighborInfo): Unit = mutex.withLock { val edges = mutableMapOf() info.neighbors.forEach { neighbor -> edges[neighbor.nodeId] = Edge( @@ -58,35 +64,37 @@ public class MeshTopology { /** * Remove a node and all edges referencing it. */ - public fun removeNode(nodeId: NodeId) { + public suspend fun removeNode(nodeId: NodeId): Unit = mutex.withLock { adjacency.remove(nodeId) adjacency.values.forEach { it.remove(nodeId) } cachedNodes = null } /** All nodes that have reported neighbors or been reported as a neighbor. */ - public val nodes: Set - get() { - cachedNodes?.let { return it } - val result = mutableSetOf() - adjacency.forEach { (reporter, neighbors) -> - result.add(reporter) - result.addAll(neighbors.keys) - } - return result.also { cachedNodes = it } + public suspend fun nodes(): Set = mutex.withLock { + cachedNodes?.let { return@withLock it } + val result = mutableSetOf() + adjacency.forEach { (reporter, neighbors) -> + result.add(reporter) + result.addAll(neighbors.keys) } + result.also { cachedNodes = it } + } /** Get all outgoing edges from a node (nodes it reported as neighbors). */ - public fun getNeighbors(nodeId: NodeId): List = + public suspend fun getNeighbors(nodeId: NodeId): List = mutex.withLock { adjacency[nodeId]?.values?.toList() ?: emptyList() + } /** Check if there's a direct edge in either direction between two nodes. */ - public fun isDirectReach(a: NodeId, b: NodeId): Boolean = + public suspend fun isDirectReach(a: NodeId, b: NodeId): Boolean = mutex.withLock { adjacency[a]?.containsKey(b) == true || adjacency[b]?.containsKey(a) == true + } /** Get the edge from [from] to [to] (if [from] reported [to] as neighbor). */ - public fun getEdge(from: NodeId, to: NodeId): Edge? = + public suspend fun getEdge(from: NodeId, to: NodeId): Edge? = mutex.withLock { adjacency[from]?.get(to) + } /** * Find shortest path between two nodes using BFS on the undirected graph. @@ -94,8 +102,8 @@ public class MeshTopology { * Returns `listOf(from)` when [from] == [to]. * Returns an empty list when no path exists. */ - public fun shortestPath(from: NodeId, to: NodeId): List { - if (from == to) return listOf(from) + public suspend fun shortestPath(from: NodeId, to: NodeId): List = mutex.withLock { + if (from == to) return@withLock listOf(from) val visited = mutableSetOf(from) val queue = ArrayDeque>() queue.add(listOf(from)) @@ -103,35 +111,38 @@ public class MeshTopology { while (queue.isNotEmpty()) { val path = queue.removeFirst() val current = path.last() - for (neighbor in undirectedNeighbors(current)) { - if (neighbor == to) return path + neighbor + for (neighbor in undirectedNeighborsLocked(current)) { + if (neighbor == to) return@withLock path + neighbor if (visited.add(neighbor)) { queue.add(path + neighbor) } } } - return emptyList() + emptyList() } /** * Get all edges in the topology graph. */ - public fun allEdges(): List = + public suspend fun allEdges(): List = mutex.withLock { adjacency.values.flatMap { it.values } + } /** * Number of directed edges. */ - public val edgeCount: Int - get() = adjacency.values.sumOf { it.size } + public suspend fun edgeCount(): Int = mutex.withLock { + adjacency.values.sumOf { it.size } + } /** Clear all topology data. */ - public fun clear() { + public suspend fun clear(): Unit = mutex.withLock { adjacency.clear() cachedNodes = null } - private fun undirectedNeighbors(nodeId: NodeId): Set { + /** Must be called while holding [mutex]. */ + private fun undirectedNeighborsLocked(nodeId: NodeId): Set { val result = mutableSetOf() adjacency[nodeId]?.keys?.let { result.addAll(it) } adjacency.forEach { (reporter, neighbors) -> diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index 3caf92f..5076e69 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -93,16 +93,14 @@ public class RadioClient internal constructor( * [ConnectionState.Configuring] → [ConnectionState.Connected]. May cycle through * [ConnectionState.Reconnecting] on transport drops. */ - public val connection: StateFlow - get() = engine.connectionState.asStateFlow() + public val connection: StateFlow = engine.connectionState.asStateFlow() /** * The local node's [NodeInfo], populated after handshake completes. * * `null` until [ConnectionState.Connected] is reached; remains non-null while connected. */ - public val ownNode: StateFlow - get() = engine.ownNode.asStateFlow() + public val ownNode: StateFlow = engine.ownNode.asStateFlow() /** * Most recently committed [ConfigBundle] for the active session. @@ -110,8 +108,7 @@ public class RadioClient internal constructor( * `null` until [ConnectionState.Connected] is reached. Cleared on disconnect / cleanup * so a stale bundle from a prior session can never leak into a new one. */ - public val configBundle: StateFlow - get() = engine.configBundleState.asStateFlow() + public val configBundle: StateFlow = engine.configBundleState.asStateFlow() /** * Channel list for the active session. @@ -123,8 +120,7 @@ public class RadioClient internal constructor( * Updated in-memory (and persisted) when [AdminApi.setChannel] succeeds. Call * [AdminApi.listChannels] to force a full device re-read (8 RPCs). */ - public val channels: StateFlow?> - get() = engine.channelsState.asStateFlow() + public val channels: StateFlow?> = engine.channelsState.asStateFlow() /** * Node-change deltas. Late subscribers receive a [NodeChange.Snapshot] immediately @@ -137,8 +133,7 @@ public class RadioClient internal constructor( * flow. If the engine inbox itself fills as a result, drops surface as * [MeshEvent.PacketsDropped] on [events] — see ADR-005 §"Backpressure policy". */ - public val nodes: Flow - get() = engine.nodes.hide() + public val nodes: Flow = engine.nodes.hide() /** * Inbound decoded packets. @@ -156,8 +151,7 @@ public class RadioClient internal constructor( * * Populated by the HandshakeMachine after entering [ConnectionState.Connected]. */ - public val packets: Flow - get() = engine.packets.hide() + public val packets: Flow = engine.packets.hide() /** * Side-channel advisory events: queue status, transport errors, key-verification prompts, @@ -165,8 +159,7 @@ public class RadioClient internal constructor( * ([MeshEvent.IdentityRebound] — emitted before the engine clears storage when the * device reports a different NodeNum than the one previously persisted). */ - public val events: Flow - get() = engine.events.hide() + public val events: Flow = engine.events.hide() // ── On-demand query ───────────────────────────────────────────────────── diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt index f6988be..25c6051 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt @@ -278,6 +278,13 @@ public sealed interface AdminResult { */ public data object Timeout : AdminResult + /** + * The device rate-limited the request; back off before retrying. + * + * (From `Routing.Error.RATE_LIMIT_EXCEEDED`.) + */ + public data object RateLimited : AdminResult + /** * Destination node is unreachable. */ @@ -316,6 +323,7 @@ public inline fun AdminResult.map(transform: (T) -> R): AdminResult is AdminResult.SessionKeyExpired -> this is AdminResult.Unauthorized -> this is AdminResult.Timeout -> this + is AdminResult.RateLimited -> this is AdminResult.NodeUnreachable -> this is AdminResult.Failed -> this } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 2eb2fdf..c636f37 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -9,6 +9,7 @@ package org.meshtastic.sdk.internal import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel @@ -24,6 +25,7 @@ import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.PortNum import org.meshtastic.proto.Position +import org.meshtastic.proto.Routing import org.meshtastic.proto.SensorConfig import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User @@ -145,6 +147,7 @@ internal class AdminApiImpl( AdminResult.Timeout, AdminResult.NodeUnreachable, AdminResult.SessionKeyExpired, AdminResult.Unauthorized, + AdminResult.RateLimited, is AdminResult.Failed, -> return result.let { @Suppress("UNCHECKED_CAST") @@ -417,9 +420,11 @@ internal class AdminApiImpl( ) val stateFlow = MutableStateFlow(SendState.Queued) engine.trySend(packet, id, stateFlow) - val terminal = stateFlow.first { - it is SendState.Failed || it == SendState.Acked || it == SendState.Delivered - } + val terminal = withTimeoutOrNull(rpcTimeout) { + stateFlow.first { + it is SendState.Failed || it == SendState.Acked || it == SendState.Delivered + } + } ?: return AdminResult.Timeout return when (terminal) { SendState.Acked, SendState.Delivered -> AdminResult.Success(Unit) is SendState.Failed -> mapSendFailureToAdminResult(terminal.reason) @@ -520,7 +525,14 @@ private fun mapSendFailureToAdminResult(reason: SendFailure): AdminResult SendFailure.Cancelled, SendFailure.IdCollision -> AdminResult.NodeUnreachable - is SendFailure.Other -> AdminResult.Failed(reason.routingError) + is SendFailure.Other -> when (reason.routingError) { + Routing.Error.ADMIN_BAD_SESSION_KEY -> AdminResult.SessionKeyExpired + Routing.Error.NOT_AUTHORIZED, + Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED, + -> AdminResult.Unauthorized + Routing.Error.RATE_LIMIT_EXCEEDED -> AdminResult.RateLimited + else -> AdminResult.Failed(reason.routingError) + } is SendFailure.Unknown -> AdminResult.Timeout } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt index e36d32e..128c6d5 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt @@ -264,6 +264,8 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED, -> AdminResult.Unauthorized + Routing.Error.RATE_LIMIT_EXCEEDED -> AdminResult.RateLimited + Routing.Error.NO_ROUTE, Routing.Error.GOT_NAK, Routing.Error.MAX_RETRANSMIT, diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt index 4933442..f303469 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt @@ -96,6 +96,7 @@ internal class StoreForwardApiImpl( AdminResult.NodeUnreachable -> AdminResult.NodeUnreachable AdminResult.SessionKeyExpired -> AdminResult.SessionKeyExpired AdminResult.Unauthorized -> AdminResult.Unauthorized + AdminResult.RateLimited -> AdminResult.RateLimited is AdminResult.Failed -> result } } @@ -123,6 +124,7 @@ internal class StoreForwardApiImpl( AdminResult.NodeUnreachable -> AdminResult.NodeUnreachable AdminResult.SessionKeyExpired -> AdminResult.SessionKeyExpired AdminResult.Unauthorized -> AdminResult.Unauthorized + AdminResult.RateLimited -> AdminResult.RateLimited is AdminResult.Failed -> result } } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt index a97096e..a85e09e 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt @@ -122,6 +122,8 @@ internal class TelemetryApiImpl( AdminResult.Unauthorized -> AdminResult.Unauthorized + AdminResult.RateLimited -> AdminResult.RateLimited + is AdminResult.Failed -> result } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt index 7e817f3..48aae14 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt @@ -312,10 +312,10 @@ class P2AdminRpcTest { val begin = outbound.last { adminOf(it)?.begin_edit_settings == true } transport.injectRoutingAck(requestId = begin.id) runCurrent() - advanceUntilIdle() // Step 2: the inner setFavorite packet was enqueued without want_ack (engine path stops // tracking after Sent). The block returns. The commit packet is then sent and acked. + runCurrent() outbound = transport.outboundPackets().drop(outboundBefore) val commit = outbound.last { adminOf(it)?.commit_edit_settings == true } transport.injectRoutingAck(requestId = commit.id) diff --git a/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt b/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt index 2d8f975..2dacea0 100644 --- a/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt +++ b/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt @@ -7,6 +7,7 @@ */ package org.meshtastic.sdk +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -15,7 +16,7 @@ import kotlin.test.assertTrue class MeshTopologyTest { @Test - fun `addNeighborInfo populates nodes and edges`() { + fun `addNeighborInfo populates nodes and edges`() = runTest { val topology = MeshTopology() topology.addNeighborInfo( @@ -29,8 +30,8 @@ class MeshTopologyTest { ), ) - assertEquals(setOf(NodeId(1), NodeId(2), NodeId(3)), topology.nodes) - assertEquals(2, topology.edgeCount) + assertEquals(setOf(NodeId(1), NodeId(2), NodeId(3)), topology.nodes()) + assertEquals(2, topology.edgeCount()) assertEquals( setOf( MeshTopology.Edge(NodeId(1), NodeId(2), 7.5f, 99), @@ -42,14 +43,14 @@ class MeshTopologyTest { } @Test - fun `getNeighbors returns empty for unknown node`() { + fun `getNeighbors returns empty for unknown node`() = runTest { val topology = MeshTopology() assertTrue(topology.getNeighbors(NodeId(404)).isEmpty()) } @Test - fun `isDirectReach works bidirectionally`() { + fun `isDirectReach works bidirectionally`() = runTest { val topology = MeshTopology() topology.addNeighborInfo( @@ -65,7 +66,7 @@ class MeshTopologyTest { } @Test - fun `shortestPath finds multi-hop route`() { + fun `shortestPath finds multi-hop route`() = runTest { val topology = MeshTopology() topology.addNeighborInfo( @@ -94,7 +95,7 @@ class MeshTopologyTest { } @Test - fun `shortestPath returns empty when unreachable`() { + fun `shortestPath returns empty when unreachable`() = runTest { val topology = MeshTopology() topology.addNeighborInfo( @@ -114,7 +115,7 @@ class MeshTopologyTest { } @Test - fun `removeNode clears all references`() { + fun `removeNode clears all references`() = runTest { val topology = MeshTopology() topology.addNeighborInfo( @@ -135,7 +136,7 @@ class MeshTopologyTest { topology.removeNode(NodeId(2)) - assertFalse(NodeId(2) in topology.nodes) + assertFalse(NodeId(2) in topology.nodes()) assertNull(topology.getEdge(NodeId(1), NodeId(2))) assertNull(topology.getEdge(NodeId(4), NodeId(2))) assertFalse(topology.isDirectReach(NodeId(1), NodeId(2))) @@ -143,7 +144,7 @@ class MeshTopologyTest { } @Test - fun `addNeighborInfo replaces existing edges from same reporter`() { + fun `addNeighborInfo replaces existing edges from same reporter`() = runTest { val topology = MeshTopology() topology.addNeighborInfo( @@ -166,23 +167,23 @@ class MeshTopologyTest { assertEquals(listOf(MeshTopology.Edge(NodeId(1), NodeId(4), 9.0f, 10)), topology.getNeighbors(NodeId(1))) assertNull(topology.getEdge(NodeId(1), NodeId(2))) assertNull(topology.getEdge(NodeId(1), NodeId(3))) - assertEquals(setOf(NodeId(1), NodeId(4)), topology.nodes) + assertEquals(setOf(NodeId(1), NodeId(4)), topology.nodes()) } @Test - fun `clear removes all nodes and edges`() { + fun `clear removes all nodes and edges`() = runTest { val topo = MeshTopology() topo.addNeighborInfo(NeighborInfo(NodeId(1), listOf(NeighborInfo.Neighbor(NodeId(2), 5f)))) topo.clear() - assertEquals(emptySet(), topo.nodes) - assertEquals(0, topo.edgeCount) + assertEquals(emptySet(), topo.nodes()) + assertEquals(0, topo.edgeCount()) assertEquals(emptyList(), topo.shortestPath(NodeId(1), NodeId(2))) } @Test - fun `self-loop does not break graph operations`() { + fun `self-loop does not break graph operations`() = runTest { val topo = MeshTopology() topo.addNeighborInfo( @@ -201,7 +202,7 @@ class MeshTopologyTest { } @Test - fun `disconnected components have no path between them`() { + fun `disconnected components have no path between them`() = runTest { val topo = MeshTopology() topo.addNeighborInfo(NeighborInfo(NodeId(1), listOf(NeighborInfo.Neighbor(NodeId(2), 5f)))) @@ -212,7 +213,7 @@ class MeshTopologyTest { } @Test - fun `allEdges returns correct count`() { + fun `allEdges returns correct count`() = runTest { val topology = MeshTopology() topology.addNeighborInfo( @@ -232,6 +233,6 @@ class MeshTopologyTest { ) assertEquals(3, topology.allEdges().size) - assertEquals(3, topology.edgeCount) + assertEquals(3, topology.edgeCount()) } } From a81276742f2be71bb13ed9993fde6f8590146adb Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:12:15 -0500 Subject: [PATCH 20/36] feat: AdminResult.getOrThrow() extension + AdminResultException hierarchy - getOrThrow() maps failure variants to typed exceptions for callers preferring exception-based error handling - AdminResultException sealed hierarchy with SessionKeyExpired, Unauthorized, Timeout, RateLimited, NodeUnreachable, RoutingFailed(error) - Improved toggleMuted KDoc (documents toggle-not-set firmware semantics) - Tests for all getOrThrow() failure paths, fold, and map Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/AdminApi.kt | 7 +- .../kotlin/org/meshtastic/sdk/Result.kt | 26 ++++++ .../sdk/AdminResultExtensionsTest.kt | 89 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/AdminResultExtensionsTest.kt diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index aacdfd7..4e69986 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -122,7 +122,12 @@ public interface AdminApi { /** Mark [node] as ignored — packets from it are filtered before reaching apps. */ public suspend fun setIgnored(node: NodeId, ignored: Boolean): AdminResult - /** Toggle mute state on [node] — muted nodes do not forward packets. */ + /** + * Toggle mute state on [node] — muted nodes do not forward packets. + * + * Note: The firmware uses a toggle primitive (`toggle_muted_node`), so calling this + * always flips the current state. Track local mute state if you need idempotent behavior. + */ public suspend fun toggleMuted(node: NodeId): AdminResult // ── Position ──────────────────────────────────────────────────────────── diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt index 25c6051..81cbda3 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt @@ -348,3 +348,29 @@ public inline fun AdminResult.onFailure(action: (AdminResult) -> Unit) if (this !is AdminResult.Success) action(this) return this } + +/** + * Returns the [Success] value or throws [AdminResultException] describing the failure. + * + * Useful for callers who prefer exception-based error handling over sealed-type matching. + */ +public fun AdminResult.getOrThrow(): T = when (this) { + is AdminResult.Success -> value + is AdminResult.SessionKeyExpired -> throw AdminResultException.SessionKeyExpired() + is AdminResult.Unauthorized -> throw AdminResultException.Unauthorized() + is AdminResult.Timeout -> throw AdminResultException.Timeout() + is AdminResult.RateLimited -> throw AdminResultException.RateLimited() + is AdminResult.NodeUnreachable -> throw AdminResultException.NodeUnreachable() + is AdminResult.Failed -> throw AdminResultException.RoutingFailed(routingError) +} + +/** Exception hierarchy thrown by [getOrThrow]. */ +public sealed class AdminResultException(message: String) : Exception(message) { + public class SessionKeyExpired : AdminResultException("Admin session key expired") + public class Unauthorized : AdminResultException("Not authorized for this operation") + public class Timeout : AdminResultException("Operation timed out waiting for device response") + public class RateLimited : AdminResultException("Device rate-limited the request") + public class NodeUnreachable : AdminResultException("Destination node is unreachable") + public class RoutingFailed(public val error: Routing.Error) : + AdminResultException("Routing error: ${error.name}") +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminResultExtensionsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminResultExtensionsTest.kt new file mode 100644 index 0000000..a153676 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminResultExtensionsTest.kt @@ -0,0 +1,89 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.Routing +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class AdminResultExtensionsTest { + + @Test + fun getOrThrow_returns_value_on_success() { + val result: AdminResult = AdminResult.Success("hello") + assertEquals("hello", result.getOrThrow()) + } + + @Test + fun getOrThrow_throws_SessionKeyExpired() { + val result: AdminResult = AdminResult.SessionKeyExpired + assertFailsWith { result.getOrThrow() } + } + + @Test + fun getOrThrow_throws_Unauthorized() { + val result: AdminResult = AdminResult.Unauthorized + assertFailsWith { result.getOrThrow() } + } + + @Test + fun getOrThrow_throws_Timeout() { + val result: AdminResult = AdminResult.Timeout + assertFailsWith { result.getOrThrow() } + } + + @Test + fun getOrThrow_throws_RateLimited() { + val result: AdminResult = AdminResult.RateLimited + assertFailsWith { result.getOrThrow() } + } + + @Test + fun getOrThrow_throws_NodeUnreachable() { + val result: AdminResult = AdminResult.NodeUnreachable + assertFailsWith { result.getOrThrow() } + } + + @Test + fun getOrThrow_throws_RoutingFailed_with_error() { + val result: AdminResult = AdminResult.Failed(Routing.Error.TOO_LARGE) + val ex = assertFailsWith { result.getOrThrow() } + assertEquals(Routing.Error.TOO_LARGE, ex.error) + } + + @Test + fun fold_invokes_onSuccess() { + val result: AdminResult = AdminResult.Success(42) + val folded = result.fold(onSuccess = { it * 2 }, onFailure = { -1 }) + assertEquals(84, folded) + } + + @Test + fun fold_invokes_onFailure() { + val result: AdminResult = AdminResult.Timeout + val folded = result.fold(onSuccess = { it * 2 }, onFailure = { -1 }) + assertEquals(-1, folded) + } + + @Test + fun map_transforms_success() { + val result: AdminResult = AdminResult.Success(5) + val mapped = result.map { it.toString() } + assertIs>(mapped) + assertEquals("5", mapped.value) + } + + @Test + fun map_propagates_failure() { + val result: AdminResult = AdminResult.RateLimited + val mapped = result.map { it.toString() } + assertIs(mapped) + } +} From b32f7de74e9e2b5a74d72fb5bdfe4e4a9ef9dbec Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:24:33 -0500 Subject: [PATCH 21/36] Add missing admin time operation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/AdminApi.kt | 15 ++++- .../meshtastic/sdk/internal/AdminApiImpl.kt | 23 +++++-- .../org/meshtastic/sdk/internal/MeshEngine.kt | 4 ++ .../org/meshtastic/sdk/P2AdminRpcTest.kt | 61 ++++++++++++++++++- 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index 4e69986..36daa26 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -177,7 +177,12 @@ public interface AdminApi { // ── DFU / file management ─────────────────────────────────────────────── - /** Enter DFU (firmware update) mode. The device will reboot into its bootloader. */ + /** + * Enter DFU (firmware update) mode. The device will reboot into its bootloader. + * + * This is a fire-and-forget admin write; [AdminResult.Success] means the request was queued + * locally, not that the device stayed connected long enough to acknowledge the reboot. + */ public suspend fun enterDfuMode(): AdminResult /** Delete a file from the device's filesystem. */ @@ -272,6 +277,14 @@ public interface AdminApi { // ── Time ──────────────────────────────────────────────────────────────── + /** + * Set the device's wall clock from raw Unix time seconds without sending position data. + * + * This uses `AdminMessage.set_time_only` directly and is fire-and-forget; [AdminResult.Success] + * means the packet was queued locally. + */ + public suspend fun setTimeOnly(unixTime: Int): AdminResult + /** * Set the device's wall clock to [at] (default: `Clock.System.now()`). * diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index c636f37..84d008c 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -253,8 +253,9 @@ internal class AdminApiImpl( // ── DFU / file management ─────────────────────────────────────────────── - override suspend fun enterDfuMode(): AdminResult = retryOnSessionExpiry { - submitAdminAck(AdminMessage(enter_dfu_mode_request = true)) + override suspend fun enterDfuMode(): AdminResult { + if (isDeviceManaged()) return AdminResult.Unauthorized + return submitAdminFireAndForget(AdminMessage(enter_dfu_mode_request = true)) } override suspend fun deleteFile(path: String): AdminResult = retryOnSessionExpiry { @@ -350,10 +351,14 @@ internal class AdminApiImpl( submitAdminAck(AdminMessage(nodedb_reset = true)) } - override suspend fun setTime(at: Instant?): AdminResult = retryOnSessionExpiry { + override suspend fun setTimeOnly(unixTime: Int): AdminResult { + if (isDeviceManaged()) return AdminResult.Unauthorized + return submitAdminFireAndForget(AdminMessage(set_time_only = unixTime)) + } + + override suspend fun setTime(at: Instant?): AdminResult { val instant = at ?: nowProvider() - val seconds = instant.epochSeconds.toInt() - submitAdminAck(AdminMessage(set_time_only = seconds)) + return setTimeOnly(instant.epochSeconds.toInt()) } override suspend fun editSettings(block: suspend AdminEdit.() -> T): AdminResult { @@ -432,6 +437,14 @@ internal class AdminApiImpl( } } + /** + * Send an admin packet without waiting for a firmware reply or routing ACK. + */ + private fun submitAdminFireAndForget(adminMsg: AdminMessage, to: NodeId = localNode()): AdminResult { + engine.sendAdmin(adminMsg = adminMsg, to = to.raw) + return AdminResult.Success(Unit) + } + /** * Single-shot retry on `SessionKeyExpired`: re-issue `get_owner_request` to refresh the * session passkey, then replay the original [block] once. The retry result is returned as-is diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index 6a0237d..f760cd6 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -1984,6 +1984,10 @@ internal class MeshEngine( } } + internal fun sendAdmin(adminMsg: AdminMessage, wantResponse: Boolean = false, to: Int = myNodeNum) { + sendAdminPacket(adminMsg, wantResponse, to) + } + private fun sendAdminPacket(adminMsg: AdminMessage, wantResponse: Boolean = false, to: Int = myNodeNum) { if (myNodeNum == 0) return // attach the cached session passkey when targeting a *remote* node. Local admin diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt index 48aae14..8f3c345 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt @@ -257,6 +257,45 @@ class P2AdminRpcTest { client.disconnect() } + @Test + fun enterDfuModeIsFireAndForget() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val result = client.admin.enterDfuMode() + runCurrent() + + val enterDfu = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.enter_dfu_mode_request == true } + assertIs>(result) + assertEquals(false, enterDfu.want_ack) + assertEquals(false, enterDfu.decoded?.want_response) + client.disconnect() + } + + @Test + fun deleteFileAckSurfacesAsSuccess() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.deleteFile("logs/app.txt") } + runCurrent() + + val deleteFile = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.delete_file_request == "logs/app.txt" } + assertTrue(deleteFile.want_ack, "deleteFile must request a wire-level ack") + transport.injectRoutingAck(requestId = deleteFile.id) + runCurrent() + + val result = deferred.await() + assertIs>(result) + client.disconnect() + } + @Test fun setTimeUsesInjectedClock() = runTest { val frozen = kotlin.time.Instant.fromEpochSeconds(1_700_000_000L) @@ -279,17 +318,33 @@ class P2AdminRpcTest { runCurrent() val outboundBefore = transport.outboundPackets().size - val deferred = async { client.admin.setTime() } + val result = client.admin.setTime() runCurrent() val setTime = transport.outboundPackets().drop(outboundBefore) .last { adminOf(it)?.set_time_only != null } + assertIs>(result) assertEquals(frozen.epochSeconds.toInt(), adminOf(setTime)!!.set_time_only) - transport.injectRoutingAck(requestId = setTime.id) + assertEquals(false, setTime.want_ack) + assertEquals(false, setTime.decoded?.want_response) + client.disconnect() + } + + @Test + fun setTimeOnlyUsesProvidedUnixTimeAndIsFireAndForget() = runTest { + val (transport, client) = connectedClient() + client.connect() runCurrent() - val result = deferred.await() + val outboundBefore = transport.outboundPackets().size + val result = client.admin.setTimeOnly(1_700_000_123) + runCurrent() + + val setTimeOnly = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.set_time_only == 1_700_000_123 } assertIs>(result) + assertEquals(false, setTimeOnly.want_ack) + assertEquals(false, setTimeOnly.decoded?.want_response) client.disconnect() } From 81b1cf599d8fb9330a1fbb364b00577cfef41cf7 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:27:26 -0500 Subject: [PATCH 22/36] Add admin config DSL builders Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/sdk/ConfigBuilders.kt | 171 ++++++++++++++ .../org/meshtastic/sdk/ConfigBuildersTest.kt | 210 ++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt new file mode 100644 index 0000000..c681494 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt @@ -0,0 +1,171 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.Config +import org.meshtastic.proto.ModuleConfig + +/** + * Convenience builders for common admin config writes. + * + * ```kotlin + * client.admin.setDeviceConfig { + * copy(role = Config.DeviceConfig.Role.CLIENT) + * } + * + * client.admin.setMqttConfig { + * copy(enabled = true) + * } + * ``` + */ +private suspend fun AdminApi.setConfigSection( + initial: T, + block: T.() -> T, + wrap: (T) -> Config, +): AdminResult = setConfig(wrap(initial.block())) + +private suspend fun AdminApi.setModuleConfigSection( + initial: T, + block: T.() -> T, + wrap: (T) -> ModuleConfig, +): AdminResult = setModuleConfig(wrap(initial.block())) + +/** Convenience: build and send a [Config.DeviceConfig] in a single call. */ +public suspend fun AdminApi.setDeviceConfig( + block: Config.DeviceConfig.() -> Config.DeviceConfig, +): AdminResult = setConfigSection(Config.DeviceConfig(), block) { Config(device = it) } + +/** Convenience: build and send a [Config.PositionConfig] in a single call. */ +public suspend fun AdminApi.setPositionConfig( + block: Config.PositionConfig.() -> Config.PositionConfig, +): AdminResult = setConfigSection(Config.PositionConfig(), block) { Config(position = it) } + +/** Convenience: build and send a [Config.PowerConfig] in a single call. */ +public suspend fun AdminApi.setPowerConfig( + block: Config.PowerConfig.() -> Config.PowerConfig, +): AdminResult = setConfigSection(Config.PowerConfig(), block) { Config(power = it) } + +/** Convenience: build and send a [Config.NetworkConfig] in a single call. */ +public suspend fun AdminApi.setNetworkConfig( + block: Config.NetworkConfig.() -> Config.NetworkConfig, +): AdminResult = setConfigSection(Config.NetworkConfig(), block) { Config(network = it) } + +/** Convenience: build and send a [Config.DisplayConfig] in a single call. */ +public suspend fun AdminApi.setDisplayConfig( + block: Config.DisplayConfig.() -> Config.DisplayConfig, +): AdminResult = setConfigSection(Config.DisplayConfig(), block) { Config(display = it) } + +/** Convenience: build and send a [Config.LoRaConfig] in a single call. */ +public suspend fun AdminApi.setLoraConfig( + block: Config.LoRaConfig.() -> Config.LoRaConfig, +): AdminResult = setConfigSection(Config.LoRaConfig(), block) { Config(lora = it) } + +/** Convenience: build and send a [Config.BluetoothConfig] in a single call. */ +public suspend fun AdminApi.setBluetoothConfig( + block: Config.BluetoothConfig.() -> Config.BluetoothConfig, +): AdminResult = setConfigSection(Config.BluetoothConfig(), block) { Config(bluetooth = it) } + +/** Convenience: build and send a [Config.SecurityConfig] in a single call. */ +public suspend fun AdminApi.setSecurityConfig( + block: Config.SecurityConfig.() -> Config.SecurityConfig, +): AdminResult = setConfigSection(Config.SecurityConfig(), block) { Config(security = it) } + +/** Convenience: build and send a [ModuleConfig.MQTTConfig] in a single call. */ +public suspend fun AdminApi.setMqttConfig( + block: ModuleConfig.MQTTConfig.() -> ModuleConfig.MQTTConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.MQTTConfig(), block) { ModuleConfig(mqtt = it) } + +/** Convenience: build and send a [ModuleConfig.SerialConfig] in a single call. */ +public suspend fun AdminApi.setSerialConfig( + block: ModuleConfig.SerialConfig.() -> ModuleConfig.SerialConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.SerialConfig(), block) { ModuleConfig(serial = it) } + +/** Convenience: build and send a [ModuleConfig.ExternalNotificationConfig] in a single call. */ +public suspend fun AdminApi.setExternalNotificationConfig( + block: ModuleConfig.ExternalNotificationConfig.() -> ModuleConfig.ExternalNotificationConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.ExternalNotificationConfig(), block) { + ModuleConfig(external_notification = it) +} + +/** Convenience: build and send a [ModuleConfig.StoreForwardConfig] in a single call. */ +public suspend fun AdminApi.setStoreForwardConfig( + block: ModuleConfig.StoreForwardConfig.() -> ModuleConfig.StoreForwardConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.StoreForwardConfig(), block) { + ModuleConfig(store_forward = it) +} + +/** Convenience: build and send a [ModuleConfig.RangeTestConfig] in a single call. */ +public suspend fun AdminApi.setRangeTestConfig( + block: ModuleConfig.RangeTestConfig.() -> ModuleConfig.RangeTestConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.RangeTestConfig(), block) { ModuleConfig(range_test = it) } + +/** Convenience: build and send a [ModuleConfig.TelemetryConfig] in a single call. */ +public suspend fun AdminApi.setTelemetryConfig( + block: ModuleConfig.TelemetryConfig.() -> ModuleConfig.TelemetryConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.TelemetryConfig(), block) { ModuleConfig(telemetry = it) } + +/** Convenience: build and send a [ModuleConfig.CannedMessageConfig] in a single call. */ +public suspend fun AdminApi.setCannedMessageConfig( + block: ModuleConfig.CannedMessageConfig.() -> ModuleConfig.CannedMessageConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.CannedMessageConfig(), block) { + ModuleConfig(canned_message = it) +} + +/** Convenience: build and send a [ModuleConfig.AudioConfig] in a single call. */ +public suspend fun AdminApi.setAudioConfig( + block: ModuleConfig.AudioConfig.() -> ModuleConfig.AudioConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.AudioConfig(), block) { ModuleConfig(audio = it) } + +/** Convenience: build and send a [ModuleConfig.RemoteHardwareConfig] in a single call. */ +public suspend fun AdminApi.setRemoteHardwareConfig( + block: ModuleConfig.RemoteHardwareConfig.() -> ModuleConfig.RemoteHardwareConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.RemoteHardwareConfig(), block) { + ModuleConfig(remote_hardware = it) +} + +/** Convenience: build and send a [ModuleConfig.NeighborInfoConfig] in a single call. */ +public suspend fun AdminApi.setNeighborInfoConfig( + block: ModuleConfig.NeighborInfoConfig.() -> ModuleConfig.NeighborInfoConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.NeighborInfoConfig(), block) { + ModuleConfig(neighbor_info = it) +} + +/** Convenience: build and send a [ModuleConfig.AmbientLightingConfig] in a single call. */ +public suspend fun AdminApi.setAmbientLightingConfig( + block: ModuleConfig.AmbientLightingConfig.() -> ModuleConfig.AmbientLightingConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.AmbientLightingConfig(), block) { + ModuleConfig(ambient_lighting = it) +} + +/** Convenience: build and send a [ModuleConfig.DetectionSensorConfig] in a single call. */ +public suspend fun AdminApi.setDetectionSensorConfig( + block: ModuleConfig.DetectionSensorConfig.() -> ModuleConfig.DetectionSensorConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.DetectionSensorConfig(), block) { + ModuleConfig(detection_sensor = it) +} + +/** Convenience: build and send a [ModuleConfig.PaxcounterConfig] in a single call. */ +public suspend fun AdminApi.setPaxcounterConfig( + block: ModuleConfig.PaxcounterConfig.() -> ModuleConfig.PaxcounterConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.PaxcounterConfig(), block) { + ModuleConfig(paxcounter = it) +} + +/** Convenience: build and send a [ModuleConfig.StatusMessageConfig] in a single call. */ +public suspend fun AdminApi.setStatusMessageConfig( + block: ModuleConfig.StatusMessageConfig.() -> ModuleConfig.StatusMessageConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.StatusMessageConfig(), block) { + ModuleConfig(statusmessage = it) +} + +/** Convenience: build and send a [ModuleConfig.TrafficManagementConfig] in a single call. */ +public suspend fun AdminApi.setTrafficManagementConfig( + block: ModuleConfig.TrafficManagementConfig.() -> ModuleConfig.TrafficManagementConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.TrafficManagementConfig(), block) { + ModuleConfig(traffic_management = it) +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt new file mode 100644 index 0000000..7036d2e --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt @@ -0,0 +1,210 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.KeyVerificationAdmin +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse +import org.meshtastic.proto.Position +import org.meshtastic.proto.SensorConfig +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Instant + +class ConfigBuildersTest { + + @Test + fun configBuildersWrapExpectedSections() = runTest { + val admin = CapturingAdminApi() + val expectedResult = AdminResult.Success(Unit) + + assertEquals(expectedResult, admin.setDeviceConfig { copy(role = Config.DeviceConfig.Role.CLIENT) }) + assertEquals(expectedResult, admin.setPositionConfig { copy(position_broadcast_secs = 300) }) + assertEquals(expectedResult, admin.setPowerConfig { copy(is_power_saving = true) }) + assertEquals(expectedResult, admin.setNetworkConfig { copy(wifi_enabled = true, wifi_ssid = "mesh") }) + assertEquals(expectedResult, admin.setDisplayConfig { copy(screen_on_secs = 45) }) + assertEquals(expectedResult, admin.setLoraConfig { copy(use_preset = true) }) + assertEquals(expectedResult, admin.setBluetoothConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setSecurityConfig { copy(is_managed = true) }) + + assertEquals( + listOf( + Config(device = Config.DeviceConfig().copy(role = Config.DeviceConfig.Role.CLIENT)), + Config(position = Config.PositionConfig().copy(position_broadcast_secs = 300)), + Config(power = Config.PowerConfig().copy(is_power_saving = true)), + Config(network = Config.NetworkConfig().copy(wifi_enabled = true, wifi_ssid = "mesh")), + Config(display = Config.DisplayConfig().copy(screen_on_secs = 45)), + Config(lora = Config.LoRaConfig().copy(use_preset = true)), + Config(bluetooth = Config.BluetoothConfig().copy(enabled = true)), + Config(security = Config.SecurityConfig().copy(is_managed = true)), + ), + admin.configs, + ) + } + + @Test + fun moduleConfigBuildersWrapExpectedSections() = runTest { + val admin = CapturingAdminApi() + val expectedResult = AdminResult.Success(Unit) + + assertEquals(expectedResult, admin.setMqttConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setSerialConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setExternalNotificationConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setStoreForwardConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setRangeTestConfig { copy(sender = 7) }) + assertEquals(expectedResult, admin.setTelemetryConfig { copy(device_update_interval = 60) }) + assertEquals(expectedResult, admin.setCannedMessageConfig { copy(rotary1_enabled = true) }) + assertEquals(expectedResult, admin.setAudioConfig { copy(codec2_enabled = true) }) + assertEquals(expectedResult, admin.setRemoteHardwareConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setNeighborInfoConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setAmbientLightingConfig { copy(led_state = true) }) + assertEquals(expectedResult, admin.setDetectionSensorConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setPaxcounterConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setStatusMessageConfig { copy(node_status = "ready") }) + assertEquals(expectedResult, admin.setTrafficManagementConfig { copy(enabled = true) }) + + assertEquals( + listOf( + ModuleConfig(mqtt = ModuleConfig.MQTTConfig().copy(enabled = true)), + ModuleConfig(serial = ModuleConfig.SerialConfig().copy(enabled = true)), + ModuleConfig(external_notification = ModuleConfig.ExternalNotificationConfig().copy(enabled = true)), + ModuleConfig(store_forward = ModuleConfig.StoreForwardConfig().copy(enabled = true)), + ModuleConfig(range_test = ModuleConfig.RangeTestConfig().copy(sender = 7)), + ModuleConfig(telemetry = ModuleConfig.TelemetryConfig().copy(device_update_interval = 60)), + ModuleConfig(canned_message = ModuleConfig.CannedMessageConfig().copy(rotary1_enabled = true)), + ModuleConfig(audio = ModuleConfig.AudioConfig().copy(codec2_enabled = true)), + ModuleConfig(remote_hardware = ModuleConfig.RemoteHardwareConfig().copy(enabled = true)), + ModuleConfig(neighbor_info = ModuleConfig.NeighborInfoConfig().copy(enabled = true)), + ModuleConfig(ambient_lighting = ModuleConfig.AmbientLightingConfig().copy(led_state = true)), + ModuleConfig(detection_sensor = ModuleConfig.DetectionSensorConfig().copy(enabled = true)), + ModuleConfig(paxcounter = ModuleConfig.PaxcounterConfig().copy(enabled = true)), + ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig().copy(node_status = "ready")), + ModuleConfig(traffic_management = ModuleConfig.TrafficManagementConfig().copy(enabled = true)), + ), + admin.moduleConfigs, + ) + } +} + +private class CapturingAdminApi : AdminApi { + val configs = mutableListOf() + val moduleConfigs = mutableListOf() + + override fun forNode(dest: NodeId): AdminApi = this + + override suspend fun getDeviceMetadata(): AdminResult = unused() + + override suspend fun getConfig(type: AdminMessage.ConfigType): AdminResult = unused() + + override suspend fun setConfig(config: Config): AdminResult { + configs += config + return AdminResult.Success(Unit) + } + + override suspend fun getModuleConfig(type: AdminMessage.ModuleConfigType): AdminResult = unused() + + override suspend fun setModuleConfig(config: ModuleConfig): AdminResult { + moduleConfigs += config + return AdminResult.Success(Unit) + } + + override suspend fun getOwner(): AdminResult = unused() + + override suspend fun setOwner(user: User): AdminResult = unused() + + override suspend fun getChannel(index: ChannelIndex): AdminResult = unused() + + override suspend fun setChannel(channel: Channel): AdminResult = unused() + + override suspend fun listChannels(): AdminResult> = unused() + + override suspend fun setFavorite(node: NodeId, favorite: Boolean): AdminResult = unused() + + override suspend fun setIgnored(node: NodeId, ignored: Boolean): AdminResult = unused() + + override suspend fun toggleMuted(node: NodeId): AdminResult = unused() + + override suspend fun setFixedPosition(position: Position): AdminResult = unused() + + override suspend fun removeFixedPosition(): AdminResult = unused() + + override suspend fun getUIConfig(): AdminResult = unused() + + override suspend fun storeUIConfig(config: DeviceUIConfig): AdminResult = unused() + + override suspend fun getCannedMessages(): AdminResult = unused() + + override suspend fun setCannedMessages(messages: String): AdminResult = unused() + + override suspend fun getRingtone(): AdminResult = unused() + + override suspend fun setRingtone(rtttl: String): AdminResult = unused() + + override suspend fun getDeviceConnectionStatus(): AdminResult = unused() + + override suspend fun getRemoteHardwarePins(): AdminResult = unused() + + override suspend fun setHamMode(params: HamParameters): AdminResult = unused() + + override suspend fun enterDfuMode(): AdminResult = unused() + + override suspend fun deleteFile(path: String): AdminResult = unused() + + override suspend fun backupPreferences(location: AdminMessage.BackupLocation): AdminResult = unused() + + override suspend fun restorePreferences(location: AdminMessage.BackupLocation): AdminResult = unused() + + override suspend fun removeBackupPreferences(location: AdminMessage.BackupLocation): AdminResult = unused() + + override suspend fun removeNode(node: NodeId): AdminResult = unused() + + override suspend fun setScale(scale: Int): AdminResult = unused() + + override suspend fun sendInputEvent(event: AdminMessage.InputEvent): AdminResult = unused() + + override suspend fun addContact(contact: SharedContact): AdminResult = unused() + + override suspend fun keyVerification(verification: KeyVerificationAdmin): AdminResult = unused() + + override suspend fun rebootOta(after: Duration): AdminResult = unused() + + override suspend fun otaRequest(event: AdminMessage.OTAEvent): AdminResult = unused() + + override suspend fun setSensorConfig(config: SensorConfig): AdminResult = unused() + + override suspend fun exitSimulator(): AdminResult = unused() + + override suspend fun reboot(after: Duration): AdminResult = unused() + + override suspend fun shutdown(after: Duration): AdminResult = unused() + + override suspend fun factoryReset(preserveBleBonds: Boolean): AdminResult = unused() + + override suspend fun nodeDbReset(): AdminResult = unused() + + override suspend fun setTimeOnly(unixTime: Int): AdminResult = unused() + + override suspend fun setTime(at: Instant?): AdminResult = unused() + + override suspend fun editSettings(block: suspend AdminEdit.() -> T): AdminResult = unused() + + override suspend fun batch(block: suspend AdminBatchScope.() -> T): T = unused() +} + +private fun unused(): Nothing = error("unused in ConfigBuildersTest") From 866bb8f8fdc3c3ca2adbb10143feb2c8c683a866 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:28:54 -0500 Subject: [PATCH 23/36] feat: add admin batch getters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/AdminApi.kt | 26 +++- .../meshtastic/sdk/internal/AdminApiImpl.kt | 39 +++++- .../org/meshtastic/sdk/P2AdminRpcTest.kt | 119 ++++++++++++++++++ 3 files changed, 179 insertions(+), 5 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index 36daa26..dd1bbb7 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -44,9 +44,9 @@ public interface AdminApi { * Return an [AdminApi] instance that targets [dest] instead of the local device. * * All calls on the returned instance route admin messages to the specified remote node - * over the mesh. Note: `editSettings`, `getDeviceConnectionStatus`, and lifecycle commands - * (`reboot`, `shutdown`, `factoryReset`, `nodeDbReset`) work identically — the firmware - * handles admin-over-mesh transparently. + * over the mesh. Note: `editSettings`, `batch`, `getDeviceConnectionStatus`, and lifecycle + * commands (`reboot`, `shutdown`, `factoryReset`, `nodeDbReset`) work identically — the + * firmware handles admin-over-mesh transparently. * * ```kotlin * val remoteAdmin = client.admin.forNode(NodeId(0x12345678.toInt())) @@ -303,6 +303,14 @@ public interface AdminApi { * or commit fails, the result reflects that failure and the block's return value is discarded. */ public suspend fun editSettings(block: suspend AdminEdit.() -> T): AdminResult + + /** + * Exception-based counterpart to [editSettings] that also exposes batched getter helpers. + * + * Getter failures throw [AdminResultException] via [getOrThrow]. If [block] throws, the SDK + * does not send `commit_edit_settings`; firmware eventually discards the buffered edits. + */ + public suspend fun batch(block: suspend AdminBatchScope.() -> T): T } /** @@ -322,3 +330,15 @@ public interface AdminEdit { public suspend fun setFavorite(node: NodeId, favorite: Boolean) public suspend fun setIgnored(node: NodeId, ignored: Boolean) } + +/** + * Receiver type for [AdminApi.batch] — combines [AdminEdit] setters with getter helpers. + * + * Getter failures throw [AdminResultException] via [getOrThrow]. Setters share the same deferred + * commit semantics as [AdminApi.editSettings]. + */ +public interface AdminBatchScope : AdminEdit { + public suspend fun getConfig(type: AdminMessage.ConfigType): Config + public suspend fun getModuleConfig(type: AdminMessage.ModuleConfigType): ModuleConfig + public suspend fun listChannels(): List +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 84d008c..2f664d2 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -30,8 +30,10 @@ import org.meshtastic.proto.SensorConfig import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import org.meshtastic.sdk.AdminApi +import org.meshtastic.sdk.AdminBatchScope import org.meshtastic.sdk.AdminEdit import org.meshtastic.sdk.AdminResult +import org.meshtastic.sdk.getOrThrow import org.meshtastic.sdk.ChannelIndex import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.SendFailure @@ -362,7 +364,7 @@ internal class AdminApiImpl( } override suspend fun editSettings(block: suspend AdminEdit.() -> T): AdminResult { - val begin = retryOnSessionExpiry { submitAdminAck(AdminMessage(begin_edit_settings = true)) } + val begin = beginEditSettings() if (begin !is AdminResult.Success) return begin.cast() val edit = AdminEditImpl() val payload = try { @@ -370,7 +372,7 @@ internal class AdminApiImpl( } catch (e: AdminEditFailure) { return e.result.cast() } - val commit = retryOnSessionExpiry { submitAdminAck(AdminMessage(commit_edit_settings = true)) } + val commit = commitEditSettings() if (commit !is AdminResult.Success) return commit.cast() // Gap G: optimistically update configBundle with written values after successful commit. @@ -379,6 +381,20 @@ internal class AdminApiImpl( return AdminResult.Success(payload) } + override suspend fun batch(block: suspend AdminBatchScope.() -> T): T { + beginEditSettings().getOrThrow() + val edit = AdminEditImpl() + val payload = try { + AdminBatchScopeImpl(edit).block() + } catch (e: AdminEditFailure) { + e.result.getOrThrow() + } + commitEditSettings().getOrThrow() + + engine.applyConfigEdits(edit.writtenConfigs, edit.writtenModuleConfigs) + return payload + } + // ── Internal helpers ──────────────────────────────────────────────────── /** @@ -445,6 +461,12 @@ internal class AdminApiImpl( return AdminResult.Success(Unit) } + private suspend fun beginEditSettings(): AdminResult = + retryOnSessionExpiry { submitAdminAck(AdminMessage(begin_edit_settings = true)) } + + private suspend fun commitEditSettings(): AdminResult = + retryOnSessionExpiry { submitAdminAck(AdminMessage(commit_edit_settings = true)) } + /** * Single-shot retry on `SessionKeyExpired`: re-issue `get_owner_request` to refresh the * session passkey, then replay the original [block] once. The retry result is returned as-is @@ -462,6 +484,19 @@ internal class AdminApiImpl( private fun localNode(): NodeId = targetNode ?: NodeId(engine.myNodeNumOrNull() ?: 0) + private inner class AdminBatchScopeImpl( + edit: AdminEditImpl, + ) : AdminBatchScope, AdminEdit by edit { + override suspend fun getConfig(type: AdminMessage.ConfigType): Config = + this@AdminApiImpl.getConfig(type).getOrThrow() + + override suspend fun getModuleConfig(type: AdminMessage.ModuleConfigType): ModuleConfig = + this@AdminApiImpl.getModuleConfig(type).getOrThrow() + + override suspend fun listChannels(): List = + this@AdminApiImpl.listChannels().getOrThrow() + } + private inner class AdminEditImpl : AdminEdit { val writtenConfigs = mutableListOf() val writtenModuleConfigs = mutableListOf() diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt index 8f3c345..d71d0a6 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.Routing import org.meshtastic.proto.User @@ -389,6 +390,124 @@ class P2AdminRpcTest { client.disconnect() } + @Test + fun batchSupportsGettersAndSettersAndReturnsBlockValue() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val expectedConfig = Config( + device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT), + ) + val updatedChannel = Channel(index = 3, role = Channel.Role.SECONDARY) + val expectedChannels = listOf( + Channel(index = 0, role = Channel.Role.PRIMARY), + Channel(index = 1, role = Channel.Role.SECONDARY), + ) + val deferred = async { + client.admin.batch { + val device = getConfig(AdminMessage.ConfigType.DEVICE_CONFIG) + setChannel(updatedChannel) + val channels = listChannels() + device to channels + } + } + + runCurrent() + val begin = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.begin_edit_settings == true } + transport.injectRoutingAck(requestId = begin.id) + runCurrent() + + val getConfig = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.get_config_request == AdminMessage.ConfigType.DEVICE_CONFIG } + transport.injectAdminResponse( + requestId = getConfig.id, + response = AdminMessage(get_config_response = expectedConfig), + ) + + repeat(3) { + runCurrent() + val req = transport.outboundPackets().drop(outboundBefore) + .lastOrNull { adminOf(it)?.get_channel_request != null } + assertNotNull(req) + val wireIndex = adminOf(req)!!.get_channel_request!! + val realIndex = wireIndex - 1 + val channel = when (realIndex) { + 0 -> expectedChannels[0] + 1 -> expectedChannels[1] + else -> Channel(index = realIndex, role = Channel.Role.DISABLED) + } + transport.injectAdminResponse( + requestId = req.id, + response = AdminMessage(get_channel_response = channel), + ) + } + runCurrent() + + val commit = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.commit_edit_settings == true } + transport.injectRoutingAck(requestId = commit.id) + runCurrent() + advanceUntilIdle() + + val result = deferred.await() + assertEquals(expectedConfig to expectedChannels, result) + + val orderedAdmin = transport.outboundPackets().drop(outboundBefore) + .mapNotNull { packet -> adminOf(packet)?.let { packet to it } } + val beginIdx = orderedAdmin.indexOfFirst { it.second.begin_edit_settings == true } + val getConfigIdx = orderedAdmin.indexOfFirst { + it.second.get_config_request == AdminMessage.ConfigType.DEVICE_CONFIG + } + val setChannelIdx = orderedAdmin.indexOfFirst { it.second.set_channel == updatedChannel } + val firstListIdx = orderedAdmin.indexOfFirst { it.second.get_channel_request == 1 } + val commitIdx = orderedAdmin.indexOfFirst { it.second.commit_edit_settings == true } + assertTrue(beginIdx in 0..(result.exceptionOrNull()) + assertTrue( + transport.outboundPackets().drop(outboundBefore) + .none { adminOf(it)?.commit_edit_settings == true }, + "batch must not commit after a getter failure", + ) + client.disconnect() + } + // ── Helpers ──────────────────────────────────────────────────────────── private fun adminOf(packet: org.meshtastic.proto.MeshPacket): AdminMessage? { From 53204346b996645d56cd39f75e2d93e02d4f0918 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:48:48 -0500 Subject: [PATCH 24/36] test: CommandDispatcher RPC and TelemetryApi coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/sdk/CommandDispatcherTest.kt | 282 ++++++++++++++ .../org/meshtastic/sdk/TelemetryApiTest.kt | 344 ++++++++++++++++++ 2 files changed, 626 insertions(+) create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/CommandDispatcherTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/CommandDispatcherTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/CommandDispatcherTest.kt new file mode 100644 index 0000000..8b45e9a --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/CommandDispatcherTest.kt @@ -0,0 +1,282 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Routing +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User +import org.meshtastic.sdk.internal.CommandDispatcher +import org.meshtastic.sdk.internal.ResponseKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CommandDispatcherTest { + + @Test + fun matchingTelemetryResponseCompletesPendingRequest() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 101) + val timeoutJob = Job() + dispatcher.attachTimeoutJob(101, timeoutJob) + + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 87, voltage = 4.1f)) + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 101, telemetry = expected))) + + val result = deferred.await() + val success = result as AdminResult.Success<*> + assertEquals(expected, success.value) + assertEquals(0, dispatcher.size()) + assertTrue(timeoutJob.isCancelled) + } + + @Test + fun timeoutResolvesPendingRequest() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 202) + + dispatcher.timeout(202) + + assertEquals(AdminResult.Timeout, deferred.await()) + assertEquals(0, dispatcher.size()) + } + + @Test + fun concurrentRequestsResolveIndependentlyOutOfOrder() = runTest { + val dispatcher = CommandDispatcher() + val telemetryDeferred = register(dispatcher, requestId = 301) + val ownerDeferred = register(dispatcher, requestId = 302, kind = ResponseKind.AdminOwner) + + val expectedOwner = User(long_name = "Remote Node", short_name = "RN") + assertTrue( + dispatcher.tryComplete( + adminPacket( + requestId = 302, + response = AdminMessage(get_owner_response = expectedOwner), + ), + ), + ) + + val expectedTelemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 45, uptime_seconds = 99)) + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 301, telemetry = expectedTelemetry))) + + val ownerResult = ownerDeferred.await() as AdminResult.Success<*> + val telemetryResult = telemetryDeferred.await() as AdminResult.Success<*> + assertEquals(expectedOwner, ownerResult.value) + assertEquals(expectedTelemetry, telemetryResult.value) + assertEquals(0, dispatcher.size()) + } + + @Test + fun routingErrorResolvesOnlyMatchingPendingRequest() = runTest { + val dispatcher = CommandDispatcher() + val failedDeferred = register(dispatcher, requestId = 401) + val successDeferred = register(dispatcher, requestId = 402) + val timeoutJob = Job() + dispatcher.attachTimeoutJob(401, timeoutJob) + + assertTrue(dispatcher.tryFailFromRouting(401, Routing.Error.NOT_AUTHORIZED)) + assertEquals(AdminResult.Unauthorized, failedDeferred.await()) + assertFalse(successDeferred.isCompleted) + assertTrue(timeoutJob.isCancelled) + + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 64)) + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 402, telemetry = expected))) + val success = successDeferred.await() as AdminResult.Success<*> + assertEquals(expected, success.value) + } + + @Test + fun duplicateResponseIsIgnoredAfterCompletion() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 501) + val first = Telemetry(device_metrics = DeviceMetrics(battery_level = 12)) + + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 501, telemetry = first))) + assertFalse( + dispatcher.tryComplete( + telemetryPacket( + requestId = 501, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 99)), + ), + ), + ) + + val success = deferred.await() as AdminResult.Success<*> + assertEquals(first, success.value) + assertEquals(0, dispatcher.size()) + } + + @Test + fun unmatchedRequestIdIsIgnored() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 601) + + assertFalse( + dispatcher.tryComplete( + telemetryPacket( + requestId = 999, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 1)), + ), + ), + ) + + assertFalse(deferred.isCompleted) + assertEquals(1, dispatcher.size()) + } + + @Test + fun wrongPortDoesNotConsumePendingTelemetryRequest() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 701) + + assertFalse( + dispatcher.tryComplete( + adminPacket( + requestId = 701, + response = AdminMessage(get_owner_response = User(long_name = "Wrong Port")), + ), + ), + ) + assertFalse(deferred.isCompleted) + assertEquals(1, dispatcher.size()) + + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 22)) + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 701, telemetry = expected))) + val success = deferred.await() as AdminResult.Success<*> + assertEquals(expected, success.value) + } + + @Test + fun invalidPayloadDoesNotConsumePendingRequest() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 801) + + assertFalse(dispatcher.tryComplete(invalidPacket(requestId = 801))) + assertFalse(deferred.isCompleted) + assertEquals(1, dispatcher.size()) + } + + @Test + fun routingAckNoneLeavesPendingRequestWaiting() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 901) + + assertFalse(dispatcher.tryFailFromRouting(901, Routing.Error.NONE)) + + assertFalse(deferred.isCompleted) + assertEquals(1, dispatcher.size()) + } + + @Test + fun reRegisteringSameIdTimesOutPriorCaller() = runTest { + val dispatcher = CommandDispatcher() + val oldDeferred = register(dispatcher, requestId = 1001) + val oldTimeoutJob = Job() + dispatcher.attachTimeoutJob(1001, oldTimeoutJob) + + val replacementDeferred = CompletableDeferred>() + dispatcher.register(1001, ResponseKind.Telemetry, replacementDeferred) + + assertEquals(AdminResult.Timeout, oldDeferred.await()) + assertTrue(oldTimeoutJob.isCancelled) + assertEquals(1, dispatcher.size()) + + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 73)) + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 1001, telemetry = expected))) + val success = replacementDeferred.await() as AdminResult.Success<*> + assertEquals(expected, success.value) + } + + @Test + fun cancelAllFailsEveryPendingRequest() = runTest { + val dispatcher = CommandDispatcher() + val first = register(dispatcher, requestId = 1101) + val second = register(dispatcher, requestId = 1102) + val firstJob = Job() + val secondJob = Job() + dispatcher.attachTimeoutJob(1101, firstJob) + dispatcher.attachTimeoutJob(1102, secondJob) + + dispatcher.cancelAll(AdminResult.NodeUnreachable) + + assertEquals(AdminResult.NodeUnreachable, first.await()) + assertEquals(AdminResult.NodeUnreachable, second.await()) + assertTrue(firstJob.isCancelled) + assertTrue(secondJob.isCancelled) + assertEquals(0, dispatcher.size()) + } + + @Test + fun zeroRequestIdResponseIsIgnored() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 1201) + + assertFalse( + dispatcher.tryComplete( + telemetryPacket( + requestId = 0, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 99)), + ), + ), + ) + + assertFalse(deferred.isCompleted) + assertEquals(1, dispatcher.size()) + } + + private fun register( + dispatcher: CommandDispatcher, + requestId: Int, + kind: ResponseKind<*> = ResponseKind.Telemetry, + ): CompletableDeferred> { + val deferred = CompletableDeferred>() + dispatcher.register(requestId, kind, deferred) + return deferred + } + + private fun telemetryPacket( + requestId: Int, + telemetry: Telemetry, + portnum: PortNum = PortNum.TELEMETRY_APP, + ): MeshPacket = MeshPacket( + decoded = Data( + portnum = portnum, + payload = Telemetry.ADAPTER.encode(telemetry).toByteString(), + request_id = requestId, + ), + ) + + private fun adminPacket(requestId: Int, response: AdminMessage): MeshPacket = MeshPacket( + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = AdminMessage.ADAPTER.encode(response).toByteString(), + request_id = requestId, + ), + ) + + private fun invalidPacket(requestId: Int): MeshPacket = MeshPacket( + decoded = Data( + portnum = PortNum.TELEMETRY_APP, + payload = byteArrayOf(0x80.toByte()).toByteString(), + request_id = requestId, + ), + ) +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt new file mode 100644 index 0000000..481355d --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt @@ -0,0 +1,344 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.AirQualityMetrics +import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.HealthMetrics +import org.meshtastic.proto.HostMetrics +import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.PowerMetrics +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.TrafficManagementStats +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class TelemetryApiTest { + + @Test + fun requestDeviceUsesResolvedLocalNodeAndReturnsDeviceMetrics() = runTest { + val localNodeNum = 12345 + val (transport, client) = connectedClient(nodeNum = localNodeNum) + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestDevice() } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + assertEquals(localNodeNum, request.from) + assertEquals(localNodeNum, request.to) + assertTrue(request.decoded?.want_response == true) + + val expected = DeviceMetrics(battery_level = 87, voltage = 4.1f, uptime_seconds = 3600) + transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(device_metrics = expected)) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestEnvironmentReturnsTemperatureHumidityAndPressure() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(2222) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestEnvironment(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + assertEquals(node.raw, request.to) + + val expected = EnvironmentMetrics( + temperature = 21.5f, + relative_humidity = 62.0f, + barometric_pressure = 1013.2f, + ) + transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(environment_metrics = expected), fromNode = node.raw) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestAirQualityReturnsPmValues() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(3333) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestAirQuality(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + val expected = AirQualityMetrics( + pm10_standard = 5, + pm25_standard = 12, + pm100_standard = 20, + particles_03um = 41, + ) + transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(air_quality_metrics = expected), fromNode = node.raw) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestPowerReturnsVoltageAndCurrentMetrics() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(4444) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestPower(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + val expected = PowerMetrics(ch1_voltage = 4.18f, ch1_current = 0.42f, ch2_voltage = 5.0f) + transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(power_metrics = expected), fromNode = node.raw) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestLocalStatsReturnsLocalStatsTelemetry() = runTest { + val localNodeNum = 54321 + val (transport, client) = connectedClient(nodeNum = localNodeNum) + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestLocalStats() } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + assertEquals(localNodeNum, request.to) + + val expected = LocalStats( + uptime_seconds = 55, + num_packets_tx = 12, + num_packets_rx = 9, + num_online_nodes = 3, + ) + transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(local_stats = expected), fromNode = localNodeNum) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestHealthReturnsHealthMetrics() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(5555) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestHealth(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + val expected = HealthMetrics(heart_bpm = 72, spO2 = 98, temperature = 36.7f) + transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(health_metrics = expected), fromNode = node.raw) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestHostReturnsHostMetrics() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(6666) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestHost(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + val expected = HostMetrics( + uptime_seconds = 1000, + freemem_bytes = 2048, + diskfree1_bytes = 4096, + load1 = 23, + load5 = 17, + load15 = 11, + ) + transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(host_metrics = expected), fromNode = node.raw) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestTrafficManagementReturnsTrafficStats() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(7777) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestTrafficManagement(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + val expected = TrafficManagementStats( + packets_inspected = 100, + position_dedup_drops = 2, + rate_limit_drops = 3, + router_hops_preserved = 4, + ) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(traffic_management_stats = expected), + fromNode = node.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun observeEmitsMatchingTelemetryPacketsInOrder() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(8888) + val expected = listOf( + Telemetry(environment_metrics = EnvironmentMetrics(temperature = 19.8f)), + Telemetry(power_metrics = PowerMetrics(ch1_voltage = 4.05f, ch1_current = 0.31f)), + ) + val collected = backgroundScope.async { + client.telemetry.observe(node).take(expected.size).toList() + } + runCurrent() + + transport.injectTelemetryResponse(requestId = 0, telemetry = expected[0], fromNode = node.raw) + transport.injectTelemetryResponse(requestId = 0, telemetry = expected[1], fromNode = node.raw) + runCurrent() + + assertEquals(expected, collected.await()) + client.disconnect() + } + + @Test + fun observeIgnoresOtherNodesWrongPortsAndInvalidPayload() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(9999) + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 15)) + val collected = backgroundScope.async { + client.telemetry.observe(node).take(1).toList() + } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = 30.0f)), + fromNode = 1111, + ) + transport.injectPacket( + MeshPacket( + from = node.raw, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = okio.ByteString.of(*"ignored".encodeToByteArray()), + ), + ), + ) + transport.injectPacket( + MeshPacket( + from = node.raw, + decoded = Data( + portnum = PortNum.TELEMETRY_APP, + payload = okio.ByteString.of(0x80.toByte()), + ), + ), + ) + transport.injectTelemetryResponse(requestId = 0, telemetry = expected, fromNode = node.raw) + runCurrent() + + assertEquals(listOf(expected), collected.await()) + client.disconnect() + } + + private fun TestScope.connectedClient( + nodeNum: Int = 1234, + rpcTimeout: Duration = 60.seconds, + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:telemetry-api"), + autoHandshake = true, + nodeNum = nodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .rpcTimeout(rpcTimeout) + .sendTimeout(60.seconds) + .build() + return transport to client + } + + private fun FakeRadioTransport.lastTelemetryRequest(outboundBefore: Int): MeshPacket = + outboundPackets().drop(outboundBefore).last { it.decoded?.portnum == PortNum.TELEMETRY_APP } +} From a02b05c0abb45f1d430731a8293de003e109721d Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:53:31 -0500 Subject: [PATCH 25/36] test: expanded Config DSL and AdminBatch coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/sdk/ConfigBuildersTest.kt | 299 +++++++++++++++++- .../org/meshtastic/sdk/P2AdminRpcTest.kt | 228 +++++++++++++ 2 files changed, 510 insertions(+), 17 deletions(-) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt index 7036d2e..89ef94d 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + /* * Meshtastic — open source mesh radio * Copyright © 2026 Meshtastic LLC @@ -8,6 +10,7 @@ package org.meshtastic.sdk import kotlinx.coroutines.test.runTest +import okio.ByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -24,35 +27,284 @@ import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue import kotlin.time.Duration import kotlin.time.Instant class ConfigBuildersTest { @Test - fun configBuildersWrapExpectedSections() = runTest { + fun deviceConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + device = Config.DeviceConfig().copy( + role = Config.DeviceConfig.Role.TRACKER, + serial_enabled = true, + button_gpio = 23, + buzzer_gpio = 12, + ), + ), + ) { + setDeviceConfig { + copy( + role = Config.DeviceConfig.Role.TRACKER, + serial_enabled = true, + button_gpio = 23, + buzzer_gpio = 12, + ) + } + } + } + + @Test + fun loraConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + lora = Config.LoRaConfig().copy( + use_preset = true, + region = Config.LoRaConfig.RegionCode.EU_868, + modem_preset = Config.LoRaConfig.ModemPreset.SHORT_FAST, + bandwidth = 250, + spread_factor = 9, + ), + ), + ) { + setLoraConfig { + copy( + use_preset = true, + region = Config.LoRaConfig.RegionCode.EU_868, + modem_preset = Config.LoRaConfig.ModemPreset.SHORT_FAST, + bandwidth = 250, + spread_factor = 9, + ) + } + } + } + + @Test + fun bluetoothConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + bluetooth = Config.BluetoothConfig().copy( + enabled = true, + fixed_pin = 123456, + mode = Config.BluetoothConfig.PairingMode.FIXED_PIN, + ), + ), + ) { + setBluetoothConfig { + copy( + enabled = true, + fixed_pin = 123456, + mode = Config.BluetoothConfig.PairingMode.FIXED_PIN, + ) + } + } + } + + @Test + fun displayConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + display = Config.DisplayConfig().copy( + screen_on_secs = 45, + gps_format = Config.DisplayConfig.DeprecatedGpsCoordinateFormat.UNUSED, + units = Config.DisplayConfig.DisplayUnits.IMPERIAL, + flip_screen = true, + ), + ), + ) { + setDisplayConfig { + copy( + screen_on_secs = 45, + gps_format = Config.DisplayConfig.DeprecatedGpsCoordinateFormat.UNUSED, + units = Config.DisplayConfig.DisplayUnits.IMPERIAL, + flip_screen = true, + ) + } + } + } + + @Test + fun networkConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + network = Config.NetworkConfig().copy( + wifi_enabled = true, + wifi_ssid = "mesh-wifi", + wifi_psk = "super-secret", + eth_enabled = true, + ), + ), + ) { + setNetworkConfig { + copy( + wifi_enabled = true, + wifi_ssid = "mesh-wifi", + wifi_psk = "super-secret", + eth_enabled = true, + ) + } + } + } + + @Test + fun positionConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + position = Config.PositionConfig().copy( + gps_enabled = true, + fixed_position = true, + position_broadcast_secs = 300, + gps_mode = Config.PositionConfig.GpsMode.ENABLED, + ), + ), + ) { + setPositionConfig { + copy( + gps_enabled = true, + fixed_position = true, + position_broadcast_secs = 300, + gps_mode = Config.PositionConfig.GpsMode.ENABLED, + ) + } + } + } + + @Test + fun powerConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + power = Config.PowerConfig().copy( + is_power_saving = true, + on_battery_shutdown_after_secs = 90, + wait_bluetooth_secs = 15, + ), + ), + ) { + setPowerConfig { + copy( + is_power_saving = true, + on_battery_shutdown_after_secs = 90, + wait_bluetooth_secs = 15, + ) + } + } + } + + @Test + fun securityConfigBuilderWrapsExpectedFields() = runTest { + val publicKey = bytes(1, 2, 3) + val privateKey = bytes(4, 5, 6) + val adminKey = bytes(7, 8, 9) + + assertConfigWrite( + Config( + security = Config.SecurityConfig().copy( + public_key = publicKey, + private_key = privateKey, + admin_key = listOf(adminKey), + serial_enabled = true, + ), + ), + ) { + setSecurityConfig { + copy( + public_key = publicKey, + private_key = privateKey, + admin_key = listOf(adminKey), + serial_enabled = true, + ) + } + } + } + + @Test + fun multipleConfigBuilderCallsComposeExpectedConfigs() = runTest { val admin = CapturingAdminApi() val expectedResult = AdminResult.Success(Unit) - assertEquals(expectedResult, admin.setDeviceConfig { copy(role = Config.DeviceConfig.Role.CLIENT) }) - assertEquals(expectedResult, admin.setPositionConfig { copy(position_broadcast_secs = 300) }) - assertEquals(expectedResult, admin.setPowerConfig { copy(is_power_saving = true) }) - assertEquals(expectedResult, admin.setNetworkConfig { copy(wifi_enabled = true, wifi_ssid = "mesh") }) - assertEquals(expectedResult, admin.setDisplayConfig { copy(screen_on_secs = 45) }) - assertEquals(expectedResult, admin.setLoraConfig { copy(use_preset = true) }) - assertEquals(expectedResult, admin.setBluetoothConfig { copy(enabled = true) }) - assertEquals(expectedResult, admin.setSecurityConfig { copy(is_managed = true) }) + assertEquals( + expectedResult, + admin.setDeviceConfig { + copy( + role = Config.DeviceConfig.Role.CLIENT_HIDDEN, + button_gpio = 5, + ) + }, + ) + assertEquals( + expectedResult, + admin.setNetworkConfig { + copy( + wifi_enabled = true, + wifi_ssid = "mesh", + wifi_psk = "secret", + eth_enabled = true, + ) + }, + ) + assertEquals( + expectedResult, + admin.setLoraConfig { + copy( + region = Config.LoRaConfig.RegionCode.US, + modem_preset = Config.LoRaConfig.ModemPreset.LONG_TURBO, + bandwidth = 500, + spread_factor = 7, + ) + }, + ) assertEquals( listOf( - Config(device = Config.DeviceConfig().copy(role = Config.DeviceConfig.Role.CLIENT)), - Config(position = Config.PositionConfig().copy(position_broadcast_secs = 300)), - Config(power = Config.PowerConfig().copy(is_power_saving = true)), - Config(network = Config.NetworkConfig().copy(wifi_enabled = true, wifi_ssid = "mesh")), - Config(display = Config.DisplayConfig().copy(screen_on_secs = 45)), - Config(lora = Config.LoRaConfig().copy(use_preset = true)), - Config(bluetooth = Config.BluetoothConfig().copy(enabled = true)), - Config(security = Config.SecurityConfig().copy(is_managed = true)), + Config( + device = Config.DeviceConfig().copy( + role = Config.DeviceConfig.Role.CLIENT_HIDDEN, + button_gpio = 5, + ), + ), + Config( + network = Config.NetworkConfig().copy( + wifi_enabled = true, + wifi_ssid = "mesh", + wifi_psk = "secret", + eth_enabled = true, + ), + ), + Config( + lora = Config.LoRaConfig().copy( + region = Config.LoRaConfig.RegionCode.US, + modem_preset = Config.LoRaConfig.ModemPreset.LONG_TURBO, + bandwidth = 500, + spread_factor = 7, + ), + ), + ), + admin.configs, + ) + } + + @Test + fun configBuildersAllowOutOfRangeScalarValuesWithoutCrashing() = runTest { + val admin = CapturingAdminApi() + val expectedResult = AdminResult.Success(Unit) + + assertEquals(expectedResult, admin.setDeviceConfig { copy(button_gpio = -1) }) + assertEquals(expectedResult, admin.setLoraConfig { copy(bandwidth = -1, spread_factor = -7) }) + assertEquals(expectedResult, admin.setBluetoothConfig { copy(fixed_pin = -1) }) + assertEquals(expectedResult, admin.setDisplayConfig { copy(screen_on_secs = -1) }) + assertEquals(expectedResult, admin.setPositionConfig { copy(position_broadcast_secs = -1) }) + assertEquals(expectedResult, admin.setPowerConfig { copy(on_battery_shutdown_after_secs = -1) }) + + assertEquals( + listOf( + Config(device = Config.DeviceConfig().copy(button_gpio = -1)), + Config(lora = Config.LoRaConfig().copy(bandwidth = -1, spread_factor = -7)), + Config(bluetooth = Config.BluetoothConfig().copy(fixed_pin = -1)), + Config(display = Config.DisplayConfig().copy(screen_on_secs = -1)), + Config(position = Config.PositionConfig().copy(position_broadcast_secs = -1)), + Config(power = Config.PowerConfig().copy(on_battery_shutdown_after_secs = -1)), ), admin.configs, ) @@ -100,6 +352,16 @@ class ConfigBuildersTest { admin.moduleConfigs, ) } + + private suspend fun assertConfigWrite( + expected: Config, + call: suspend CapturingAdminApi.() -> AdminResult, + ) { + val admin = CapturingAdminApi() + assertEquals(AdminResult.Success(Unit), admin.call()) + assertEquals(listOf(expected), admin.configs) + assertTrue(admin.moduleConfigs.isEmpty()) + } } private class CapturingAdminApi : AdminApi { @@ -208,3 +470,6 @@ private class CapturingAdminApi : AdminApi { } private fun unused(): Nothing = error("unused in ConfigBuildersTest") + +private fun bytes(vararg values: Int): ByteString = + ByteString.of(*ByteArray(values.size) { index -> values[index].toByte() }) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt index d71d0a6..170435e 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt @@ -17,6 +17,7 @@ import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.Routing import org.meshtastic.proto.User import org.meshtastic.sdk.testing.FakeRadioTransport @@ -477,11 +478,15 @@ class P2AdminRpcTest { client.connect() runCurrent() + val favoriteNode = NodeId(0x11111111) + val ignoredNode = NodeId(0x22222222) val outboundBefore = transport.outboundPackets().size val deferred = async { runCatching { client.admin.batch { + setFavorite(favoriteNode, favorite = true) getConfig(AdminMessage.ConfigType.DEVICE_CONFIG) + setIgnored(ignoredNode, ignored = true) } } } @@ -491,9 +496,17 @@ class P2AdminRpcTest { .last { adminOf(it)?.begin_edit_settings == true } transport.injectRoutingAck(requestId = begin.id) runCurrent() + runCurrent() + transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.set_favorite_node == favoriteNode.raw } transport.outboundPackets().drop(outboundBefore) .last { adminOf(it)?.get_config_request == AdminMessage.ConfigType.DEVICE_CONFIG } + assertTrue( + transport.outboundPackets().drop(outboundBefore) + .none { adminOf(it)?.set_ignored_node == ignoredNode.raw }, + "operations after the failing getter must not be enqueued", + ) advanceTimeBy(70.seconds) runCurrent() @@ -505,6 +518,214 @@ class P2AdminRpcTest { .none { adminOf(it)?.commit_edit_settings == true }, "batch must not commit after a getter failure", ) + assertTrue( + transport.outboundPackets().drop(outboundBefore) + .none { adminOf(it)?.set_ignored_node == ignoredNode.raw }, + "batch must stop before later writes when a getter fails", + ) + client.disconnect() + } + + @Test + fun batchCommitsMultipleQueuedWritesTogether() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val favoriteNode = NodeId(0x01020304) + val ignoredNode = NodeId(0x05060708) + val updatedChannel = Channel(index = 4, role = Channel.Role.SECONDARY) + val outboundBefore = transport.outboundPackets().size + val deferred = async { + client.admin.batch { + setFavorite(favoriteNode, favorite = true) + setIgnored(ignoredNode, ignored = true) + setChannel(updatedChannel) + "committed" + } + } + + runCurrent() + val begin = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.begin_edit_settings == true } + transport.injectRoutingAck(requestId = begin.id) + runCurrent() + runCurrent() + + val orderedAdmin = adminPacketsSince(transport, outboundBefore) + val setFavorite = orderedAdmin.first { it.second.set_favorite_node == favoriteNode.raw }.first + val setIgnored = orderedAdmin.first { it.second.set_ignored_node == ignoredNode.raw }.first + val setChannel = orderedAdmin.first { it.second.set_channel == updatedChannel }.first + assertEquals(false, setFavorite.want_ack) + assertEquals(false, setFavorite.decoded?.want_response) + assertEquals(false, setIgnored.want_ack) + assertEquals(false, setChannel.want_ack) + + val commit = orderedAdmin.last { it.second.commit_edit_settings == true }.first + transport.injectRoutingAck(requestId = commit.id) + runCurrent() + advanceUntilIdle() + + assertEquals("committed", deferred.await()) + + val beginIdx = orderedAdmin.indexOfFirst { it.second.begin_edit_settings == true } + val favoriteIdx = orderedAdmin.indexOfFirst { it.second.set_favorite_node == favoriteNode.raw } + val ignoredIdx = orderedAdmin.indexOfFirst { it.second.set_ignored_node == ignoredNode.raw } + val channelIdx = orderedAdmin.indexOfFirst { it.second.set_channel == updatedChannel } + val commitIdx = orderedAdmin.indexOfFirst { it.second.commit_edit_settings == true } + assertTrue(beginIdx in 0..> = + transport.outboundPackets().drop(outboundBefore) + .mapNotNull { packet -> adminOf(packet)?.let { packet to it } } + private fun buildRoutingErrorFrame(requestId: Int, error: Routing.Error): Frame { val payload = okio.ByteString.of(*Routing.ADAPTER.encode(Routing(error_reason = error))) val packet = org.meshtastic.proto.MeshPacket( From 70d38dba38d7c9f85dcceee5e377708194f3bf60 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 16:04:39 -0500 Subject: [PATCH 26/36] =?UTF-8?q?test:=20comprehensive=20SDK=20test=20cove?= =?UTF-8?q?rage=20=E2=80=94=20AdminApi,=20Engine,=20Handshake,=20S&F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminApiImplComprehensiveTest: 75 tests covering all config/channel/device ops - MeshEngineEdgeCasesTest: 28 tests for malformed packets, dedup, encryption - HandshakeAndReconnectTest: handshake FSM, backoff, session key lifecycle - StoreForwardProtocolTest: server discovery, history, SFPP, multi-chunk Also hardens MeshEngine with frame validation and duplicate detection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/sdk/internal/MeshEngine.kt | 188 ++- .../sdk/AdminApiImplComprehensiveTest.kt | 1256 +++++++++++++++++ .../sdk/HandshakeAndReconnectTest.kt | 855 +++++++++++ .../meshtastic/sdk/MeshEngineEdgeCasesTest.kt | 730 ++++++++++ .../sdk/StoreForwardProtocolTest.kt | 759 ++++++++++ 5 files changed, 3754 insertions(+), 34 deletions(-) create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index f760cd6..6f9a922 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -64,6 +64,7 @@ import org.meshtastic.sdk.SessionPasskey import org.meshtastic.sdk.StorageProvider import org.meshtastic.sdk.TransportState import org.meshtastic.sdk.WireCodec +import org.meshtastic.sdk.WireFraming import org.meshtastic.sdk.debug import org.meshtastic.sdk.error import org.meshtastic.sdk.info @@ -91,6 +92,7 @@ internal class MeshEngine( private val logger: LogSink, private val bleHeartbeatEnabled: Boolean, private val parentContext: CoroutineContext, + private val clock: Clock = Clock.System, private val sendTimeout: Duration = 30.seconds, private val presenceTimeout: Duration = 2.hours, private val autoReconnectConfig: AutoReconnectConfig = AutoReconnectConfig.Disabled, @@ -245,6 +247,10 @@ internal class MeshEngine( /** rate-limit clock for the encrypted-packet ACK-skip warning. */ private var lastEncryptedSkipWarningAtMs: Long = 0L + /** recent inbound packet ids to suppress duplicate packet delivery. */ + private val recentInboundPacketKeys = ArrayDeque(INBOUND_PACKET_DEDUP_CAP) + private val recentInboundPacketKeySet = mutableSetOf() + /** * Connect-phase deferred. Stored so [cleanup] can fail it if the engine is cancelled before * the handshake completes. @@ -567,6 +573,8 @@ internal class MeshEngine( stage2WatchdogJob = null stage2ProgressCounter = 0L lastEncryptedSkipWarningAtMs = 0L + recentInboundPacketKeys.clear() + recentInboundPacketKeySet.clear() reconnectJob?.cancel() reconnectJob = null @@ -639,7 +647,7 @@ internal class MeshEngine( val decoded = packet.decoded ?: return packet if (decoded.portnum != PortNum.ADMIN_APP) return packet if (packet.to == 0 || packet.to == myNodeNum) return packet - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() val passkey = sessionPasskeyMem if (passkey == null || passkey.isEmpty() || now >= sessionPasskeyExpiresAtMs) { events.tryEmit( @@ -808,9 +816,59 @@ internal class MeshEngine( private fun handleFrameRx(msg: EngineMessage.FrameRx) { val rawBytes = msg.frame.bytes.toByteArray() - if (rawBytes.size < 4) return // malformed frame + when { + rawBytes.size < WireFraming.HEADER_SIZE -> { + reportMalformedFrame( + message = "Frame shorter than wire header", + details = mapOf("bytes" to rawBytes.size), + ) + return + } + + rawBytes[0] != WireFraming.MAGIC_0 || rawBytes[1] != WireFraming.MAGIC_1 -> { + reportMalformedFrame( + message = "Frame has invalid wire header", + details = mapOf( + "magic0" to (rawBytes[0].toInt() and 0xFF), + "magic1" to (rawBytes[1].toInt() and 0xFF), + ), + ) + return + } + } + + val declaredPayloadSize = ((rawBytes[2].toInt() and 0xFF) shl 8) or (rawBytes[3].toInt() and 0xFF) + if (declaredPayloadSize == 0) { + reportMalformedFrame( + message = "Frame declares empty payload", + details = emptyMap(), + ) + return + } + if (declaredPayloadSize > WireFraming.MAX_PAYLOAD_SIZE) { + reportMalformedFrame( + message = "Frame exceeds max payload size", + details = mapOf( + "declared_payload_bytes" to declaredPayloadSize, + "max_payload_bytes" to WireFraming.MAX_PAYLOAD_SIZE, + ), + ) + return + } - val protoBytes = rawBytes.copyOfRange(4, rawBytes.size) + val actualPayloadSize = rawBytes.size - WireFraming.HEADER_SIZE + if (declaredPayloadSize != actualPayloadSize) { + reportMalformedFrame( + message = "Frame payload length mismatch", + details = mapOf( + "declared_payload_bytes" to declaredPayloadSize, + "actual_payload_bytes" to actualPayloadSize, + ), + ) + return + } + + val protoBytes = rawBytes.copyOfRange(WireFraming.HEADER_SIZE, rawBytes.size) val fromRadio = try { WireCodec.decodeFromRadio(protoBytes) } catch (e: Exception) { @@ -1126,7 +1184,7 @@ internal class MeshEngine( // mark every pending node as heard now (before the async block above flushes). run { - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() for ((nodeId, _) in pendingNodes) { lastHeartbeatAt[nodeId] = now } @@ -1165,36 +1223,33 @@ internal class MeshEngine( } if (adminMsg.get_owner_response != null) { - // Latch session passkey in memory so admin RPCs can use it immediately. - if (adminMsg.session_passkey.size > 0) { - val bytes = adminMsg.session_passkey.toByteArray() - sessionPasskeyMem = bytes - val expiresAtMs = Clock.System.now().toEpochMilliseconds() + SESSION_PASSKEY_TTL.inWholeMilliseconds - // also remember the expiry in memory so [sendAdminPacket] can decide - // whether to attach the passkey to outbound remote-admin messages. - sessionPasskeyExpiresAtMs = expiresAtMs - logger.debug(TAG) { "Session passkey latched (${bytes.size} bytes)" } - // persist asynchronously so a reconnect can resume admin RPCs without a - // fresh get_owner_request. Failures are non-fatal — we still have the in-memory - // copy for this session. - engineScope?.launch { - if (storageDegraded) return@launch - try { - storage?.saveSessionPasskey(SessionPasskey(ByteString(bytes), expiresAtMs)) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - reportStorageDegraded( - "saveSessionPasskey failed: ${e.message ?: e::class.simpleName}", - e, - ) - } - } - } + latchSessionPasskey(adminMsg) transitionToReady() } } + private fun latchSessionPasskey(adminMsg: AdminMessage) { + if (adminMsg.session_passkey.size <= 0) return + val bytes = adminMsg.session_passkey.toByteArray() + sessionPasskeyMem = bytes + val expiresAtMs = clock.now().toEpochMilliseconds() + SESSION_PASSKEY_TTL.inWholeMilliseconds + sessionPasskeyExpiresAtMs = expiresAtMs + logger.debug(TAG) { "Session passkey latched (${bytes.size} bytes)" } + engineScope?.launch { + if (storageDegraded) return@launch + try { + storage?.saveSessionPasskey(SessionPasskey(ByteString(bytes), expiresAtMs)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + reportStorageDegraded( + "saveSessionPasskey failed: ${e.message ?: e::class.simpleName}", + e, + ) + } + } + } + private fun transitionToReady() { handshakeStage = HandshakeStage.Ready @@ -1275,7 +1330,33 @@ internal class MeshEngine( } // any frame from a node counts as presence activity. if (packet.from != 0) markNodeHeard(NodeId(packet.from)) + if (shouldDropDuplicateInboundPacket(packet)) return + val decoded = packet.decoded + if (decoded == null) { + maybeWarnEncryptedPacketSkipped() + return + } + if (decoded.portnum == PortNum.UNKNOWN_APP) { + logger.warn(TAG) { "Dropping packet id=${packet.id} with unknown port from=0x${packet.from.toString(16)}" } + events.tryEmit( + MeshEvent.ProtocolWarning( + "packet dropped for unknown port", + details = mapOf( + "id" to packet.id, + "from" to packet.from, + "port" to decoded.portnum.name, + ), + ), + ) + return + } emitPacketOrLog(packet) + if (decoded.portnum == PortNum.ADMIN_APP) { + runCatching { AdminMessage.ADAPTER.decode(decoded.payload) } + .getOrNull() + ?.takeIf { it.get_owner_response != null } + ?.let(::latchSessionPasskey) + } // RPC dispatch: complete any pending getter / traceRoute / neighborInfo // request matching this packet's request_id. Must run before processRoutingAck // so a route_reply doesn't get mistakenly classified as a generic Acked send. @@ -1631,9 +1712,10 @@ internal class MeshEngine( * the situation to consumers. */ private fun maybeWarnEncryptedPacketSkipped() { - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() if (now - lastEncryptedSkipWarningAtMs < ENCRYPTED_SKIP_WARNING_INTERVAL_MS) return lastEncryptedSkipWarningAtMs = now + logger.warn(TAG) { "Dropping encrypted packet without decoded payload" } events.tryEmit( MeshEvent.ProtocolWarning( "encrypted packet skipped for ACK correlation", @@ -1642,6 +1724,32 @@ internal class MeshEngine( ) } + private fun reportMalformedFrame(message: String, details: Map) { + logger.warn(TAG) { message } + events.tryEmit(MeshEvent.ProtocolWarning(message, details = details)) + } + + private fun shouldDropDuplicateInboundPacket(packet: MeshPacket): Boolean { + if (packet.id == 0 || packet.from == 0) return false + val key = InboundPacketKey( + from = packet.from, + to = packet.to, + channel = packet.channel, + id = packet.id, + ) + if (!recentInboundPacketKeySet.add(key)) { + logger.debug(TAG) { + "Dropping duplicate inbound packet id=${packet.id} from=0x${packet.from.toString(16)}" + } + return true + } + recentInboundPacketKeys.addLast(key) + if (recentInboundPacketKeys.size > INBOUND_PACKET_DEDUP_CAP) { + recentInboundPacketKeySet.remove(recentInboundPacketKeys.removeFirst()) + } + return false + } + /** * drain [preSendQueue] and complete every pending state flow with [failure]. * @@ -1725,6 +1833,8 @@ internal class MeshEngine( stage2WatchdogJob?.cancel() stage2WatchdogJob = null stage2ProgressCounter = 0L + recentInboundPacketKeys.clear() + recentInboundPacketKeySet.clear() livenessWatchdogJob?.cancel() livenessWatchdogJob = null @@ -1886,7 +1996,7 @@ internal class MeshEngine( private fun handlePresenceCheckTick() { if (handshakeStage != HandshakeStage.Ready) return - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() val timeoutMs = presenceTimeout.inWholeMilliseconds for ((nodeId, lastMs) in lastHeartbeatAt) { @@ -1996,7 +2106,7 @@ internal class MeshEngine( // a ProtocolWarning and proceed — the firmware will reject the packet, surfacing as a // SendFailure / AckTimeout to the caller. val outbound: AdminMessage = if (to != myNodeNum) { - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() val passkey = sessionPasskeyMem if (passkey != null && passkey.isNotEmpty() && now < sessionPasskeyExpiresAtMs) { adminMsg.copy(session_passkey = passkey.toByteString()) @@ -2148,7 +2258,7 @@ internal class MeshEngine( // timestamp + dirty-mark a per-node heartbeat observation. Flushed to storage on the // next heartbeat tick (handleHeartbeatTick) or at the Ready transition. private fun markNodeHeard(nodeId: NodeId) { - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() lastHeartbeatAt[nodeId] = now dirtyHeartbeats.add(nodeId) if (offlineNodes.remove(nodeId)) { @@ -2258,5 +2368,15 @@ internal class MeshEngine( // minimum interval between consecutive encrypted-packet skip warnings. const val ENCRYPTED_SKIP_WARNING_INTERVAL_MS: Long = 60_000L + + // bounded recent-history window for inbound packet deduplication. + const val INBOUND_PACKET_DEDUP_CAP: Int = 256 } + + private data class InboundPacketKey( + val from: Int, + val to: Int, + val channel: Int, + val id: Int, + ) } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt new file mode 100644 index 0000000..0397b80 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt @@ -0,0 +1,1256 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.KeyVerificationAdmin +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position +import org.meshtastic.proto.Routing +import org.meshtastic.proto.SensorConfig +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class AdminApiImplComprehensiveTest { + + @Test + fun getDeviceConfigReturnsDeviceSection() = runTest { + val expected = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.DEVICE_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.DEVICE_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getLoraConfigReturnsLoraSection() = runTest { + val expected = Config(lora = Config.LoRaConfig(use_preset = true)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.LORA_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.LORA_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getBluetoothConfigReturnsBluetoothSection() = runTest { + val expected = Config(bluetooth = Config.BluetoothConfig(enabled = true)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.BLUETOOTH_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.BLUETOOTH_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getDisplayConfigReturnsDisplaySection() = runTest { + val expected = Config(display = Config.DisplayConfig(screen_on_secs = 45)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.DISPLAY_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.DISPLAY_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getNetworkConfigReturnsNetworkSection() = runTest { + val expected = Config(network = Config.NetworkConfig(wifi_enabled = true, wifi_ssid = "mesh")) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.NETWORK_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.NETWORK_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getPositionConfigReturnsPositionSection() = runTest { + val expected = Config(position = Config.PositionConfig(position_broadcast_secs = 300)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.POSITION_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.POSITION_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getPowerConfigReturnsPowerSection() = runTest { + val expected = Config(power = Config.PowerConfig(is_power_saving = true)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.POWER_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.POWER_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getSecurityConfigReturnsSecuritySection() = runTest { + val expected = Config(security = Config.SecurityConfig(is_managed = true)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.SECURITY_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.SECURITY_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun setDeviceConfigBuilderSendsDeviceSection() = runTest { + val expected = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) + assertAckedOperation( + call = { it.setDeviceConfig { copy(role = Config.DeviceConfig.Role.CLIENT) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setLoraConfigBuilderSendsLoraSection() = runTest { + val expected = Config(lora = Config.LoRaConfig(use_preset = true)) + assertAckedOperation( + call = { it.setLoraConfig { copy(use_preset = true) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setBluetoothConfigBuilderSendsBluetoothSection() = runTest { + val expected = Config(bluetooth = Config.BluetoothConfig(enabled = true)) + assertAckedOperation( + call = { it.setBluetoothConfig { copy(enabled = true) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setDisplayConfigBuilderSendsDisplaySection() = runTest { + val expected = Config(display = Config.DisplayConfig(screen_on_secs = 45)) + assertAckedOperation( + call = { it.setDisplayConfig { copy(screen_on_secs = 45) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setNetworkConfigBuilderSendsNetworkSection() = runTest { + val expected = Config(network = Config.NetworkConfig(wifi_enabled = true, wifi_ssid = "mesh")) + assertAckedOperation( + call = { it.setNetworkConfig { copy(wifi_enabled = true, wifi_ssid = "mesh") } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setPositionConfigBuilderSendsPositionSection() = runTest { + val expected = Config(position = Config.PositionConfig(position_broadcast_secs = 300)) + assertAckedOperation( + call = { it.setPositionConfig { copy(position_broadcast_secs = 300) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setPowerConfigBuilderSendsPowerSection() = runTest { + val expected = Config(power = Config.PowerConfig(is_power_saving = true)) + assertAckedOperation( + call = { it.setPowerConfig { copy(is_power_saving = true) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setSecurityConfigBuilderSendsSecuritySection() = runTest { + val expected = Config(security = Config.SecurityConfig(is_managed = true)) + assertAckedOperation( + call = { it.setSecurityConfig { copy(is_managed = true) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun getDeviceMetadataReturnsResponse() = runTest { + val expected = DeviceMetadata(firmware_version = "2.5.0") + assertRpcOperation( + call = { it.getDeviceMetadata() }, + requestMatches = { it.get_device_metadata_request == true }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_device_metadata_response = expected)) + } + } + + @Test + fun getDeviceMetadataTimeoutReturnsTimeout() = runTest { + assertRpcOperation( + call = { it.getDeviceMetadata() }, + requestMatches = { it.get_device_metadata_request == true }, + expected = AdminResult.Timeout, + ) { _, _ -> + advanceTimeBy(70.seconds) + } + } + + @Test + fun getModuleConfigReturnsResponse() = runTest { + val expected = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) + assertRpcOperation( + call = { it.getModuleConfig(AdminMessage.ModuleConfigType.MQTT_CONFIG) }, + requestMatches = { it.get_module_config_request == AdminMessage.ModuleConfigType.MQTT_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_module_config_response = expected)) + } + } + + @Test + fun getModuleConfigRoutingErrorBecomesUnauthorized() = runTest { + assertRpcOperation( + call = { it.getModuleConfig(AdminMessage.ModuleConfigType.MQTT_CONFIG) }, + requestMatches = { it.get_module_config_request == AdminMessage.ModuleConfigType.MQTT_CONFIG }, + expected = AdminResult.Unauthorized, + ) { transport, packet -> + transport.injectFrame(buildRoutingErrorFrame(packet.id, Routing.Error.NOT_AUTHORIZED)) + } + } + + @Test + fun getUiConfigReturnsResponse() = runTest { + val expected = DeviceUIConfig(screen_brightness = 128) + assertRpcOperation( + call = { it.getUIConfig() }, + requestMatches = { it.get_ui_config_request == true }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_ui_config_response = expected)) + } + } + + @Test + fun getCannedMessagesReturnsResponse() = runTest { + assertRpcOperation( + call = { it.getCannedMessages() }, + requestMatches = { it.get_canned_message_module_messages_request == true }, + expected = AdminResult.Success("alpha|bravo"), + ) { transport, packet -> + transport.injectAdminResponse( + packet.id, + AdminMessage(get_canned_message_module_messages_response = "alpha|bravo"), + ) + } + } + + @Test + fun getRingtoneReturnsResponse() = runTest { + assertRpcOperation( + call = { it.getRingtone() }, + requestMatches = { it.get_ringtone_request == true }, + expected = AdminResult.Success("Test:d=4,o=5,b=100:c"), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_ringtone_response = "Test:d=4,o=5,b=100:c")) + } + } + + @Test + fun getDeviceConnectionStatusReturnsResponse() = runTest { + val expected = DeviceConnectionStatus() + assertRpcOperation( + call = { it.getDeviceConnectionStatus() }, + requestMatches = { it.get_device_connection_status_request == true }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_device_connection_status_response = expected)) + } + } + + @Test + fun getRemoteHardwarePinsReturnsResponse() = runTest { + val expected = NodeRemoteHardwarePinsResponse() + assertRpcOperation( + call = { it.getRemoteHardwarePins() }, + requestMatches = { it.get_node_remote_hardware_pins_request == true }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_node_remote_hardware_pins_response = expected)) + } + } + + @Test + fun getChannelUsesOneBasedWireIndex() = runTest { + val expected = Channel(index = 0, role = Channel.Role.PRIMARY) + assertRpcOperation( + call = { it.getChannel(ChannelIndex(0)) }, + requestMatches = { it.get_channel_request == 1 }, + expected = AdminResult.Success(expected), + assertPacket = { _, admin -> assertEquals(1, admin.get_channel_request) }, + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_channel_response = expected)) + } + } + + @Test + fun listChannelsPropagatesGetterFailure() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.listChannels() } + + runCurrent() + val first = latestAdminPacket(transport, outboundBefore) { it.get_channel_request == 1 } + transport.injectAdminResponse( + first.id, + AdminMessage(get_channel_response = Channel(index = 0, role = Channel.Role.PRIMARY)), + ) + runCurrent() + + val second = latestAdminPacket(transport, outboundBefore) { it.get_channel_request == 2 } + transport.injectFrame(buildRoutingErrorFrame(second.id, Routing.Error.NOT_AUTHORIZED)) + runCurrent() + + assertEquals(AdminResult.Unauthorized, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun setChannelSuccessUpdatesChannelsState() = runTest { + val channel = Channel(index = 3, role = Channel.Role.SECONDARY) + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.setChannel(channel) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore) { it.set_channel == channel } + transport.injectRoutingAck(packet.id) + runCurrent() + + assertIs>(deferred.await()) + val channels = client.channels.value + assertNotNull(channels) + assertEquals(channel, channels[3]) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun setChannelTimeoutReturnsTimeout() = runTest { + val channel = Channel(index = 1, role = Channel.Role.SECONDARY) + assertAckedOperation( + call = { it.setChannel(channel) }, + requestMatches = { it.set_channel == channel }, + expected = AdminResult.Timeout, + ) { _, _ -> + advanceTimeBy(70.seconds) + } + } + + @Test + fun setChannelRoutingErrorMapsToNodeUnreachable() = runTest { + val channel = Channel(index = 1, role = Channel.Role.SECONDARY) + assertAckedOperation( + call = { it.setChannel(channel) }, + requestMatches = { it.set_channel == channel }, + expected = AdminResult.NodeUnreachable, + ) { transport, packet -> + transport.injectFrame(buildRoutingErrorFrame(packet.id, Routing.Error.NO_ROUTE)) + } + } + + @Test + fun setFavoriteTrueSendsSetFavoriteNode() = runTest { + val node = NodeId(0x01020304) + assertAckedOperation( + call = { it.setFavorite(node, favorite = true) }, + requestMatches = { it.set_favorite_node == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setFavoriteFalseSendsRemoveFavoriteNode() = runTest { + val node = NodeId(0x01020304) + assertAckedOperation( + call = { it.setFavorite(node, favorite = false) }, + requestMatches = { it.remove_favorite_node == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setIgnoredTrueSendsSetIgnoredNode() = runTest { + val node = NodeId(0x05060708) + assertAckedOperation( + call = { it.setIgnored(node, ignored = true) }, + requestMatches = { it.set_ignored_node == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setIgnoredFalseSendsRemoveIgnoredNode() = runTest { + val node = NodeId(0x05060708) + assertAckedOperation( + call = { it.setIgnored(node, ignored = false) }, + requestMatches = { it.remove_ignored_node == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun toggleMutedSendsToggleCommand() = runTest { + val node = NodeId(0x0a0b0c0d) + assertAckedOperation( + call = { it.toggleMuted(node) }, + requestMatches = { it.toggle_muted_node == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setFixedPositionSendsPosition() = runTest { + val position = Position(latitude_i = 377749000, longitude_i = -1224194000, altitude = 12) + assertAckedOperation( + call = { it.setFixedPosition(position) }, + requestMatches = { it.set_fixed_position == position }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun removeFixedPositionSendsRemovalFlag() = runTest { + assertAckedOperation( + call = { it.removeFixedPosition() }, + requestMatches = { it.remove_fixed_position == true }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun storeUiConfigSendsConfig() = runTest { + val uiConfig = DeviceUIConfig(screen_brightness = 64) + assertAckedOperation( + call = { it.storeUIConfig(uiConfig) }, + requestMatches = { it.store_ui_config == uiConfig }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setCannedMessagesSendsString() = runTest { + assertAckedOperation( + call = { it.setCannedMessages("one|two") }, + requestMatches = { it.set_canned_message_module_messages == "one|two" }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setRingtoneSendsString() = runTest { + assertAckedOperation( + call = { it.setRingtone("Ring:d=4,o=5,b=120:c") }, + requestMatches = { it.set_ringtone_message == "Ring:d=4,o=5,b=120:c" }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun backupPreferencesSendsLocation() = runTest { + assertAckedOperation( + call = { it.backupPreferences(AdminMessage.BackupLocation.SD) }, + requestMatches = { it.backup_preferences == AdminMessage.BackupLocation.SD }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun restorePreferencesSendsLocation() = runTest { + assertAckedOperation( + call = { it.restorePreferences(AdminMessage.BackupLocation.SD) }, + requestMatches = { it.restore_preferences == AdminMessage.BackupLocation.SD }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun removeBackupPreferencesSendsLocation() = runTest { + assertAckedOperation( + call = { it.removeBackupPreferences(AdminMessage.BackupLocation.SD) }, + requestMatches = { it.remove_backup_preferences == AdminMessage.BackupLocation.SD }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun removeNodeSendsNodeNum() = runTest { + val node = NodeId(0x0f0e0d0c) + assertAckedOperation( + call = { it.removeNode(node) }, + requestMatches = { it.remove_by_nodenum == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setScaleSendsScale() = runTest { + assertAckedOperation( + call = { it.setScale(240) }, + requestMatches = { it.set_scale == 240 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun sendInputEventSendsInputEvent() = runTest { + val event = AdminMessage.InputEvent(event_code = 17, kb_char = 65) + assertAckedOperation( + call = { it.sendInputEvent(event) }, + requestMatches = { it.send_input_event == event }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun addContactSendsSharedContact() = runTest { + val contact = SharedContact(node_num = 77, user = User(id = "!0000004d", long_name = "Contact", short_name = "CT")) + assertAckedOperation( + call = { it.addContact(contact) }, + requestMatches = { it.add_contact == contact }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun keyVerificationSendsVerification() = runTest { + val verification = KeyVerificationAdmin(remote_nodenum = 99, nonce = 1234L) + assertAckedOperation( + call = { it.keyVerification(verification) }, + requestMatches = { it.key_verification == verification }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Suppress("DEPRECATION") + @Test + fun rebootOtaSendsDelaySeconds() = runTest { + assertAckedOperation( + call = { it.rebootOta(5.seconds) }, + requestMatches = { it.reboot_ota_seconds == 5 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun otaRequestSendsEvent() = runTest { + val event = AdminMessage.OTAEvent() + assertAckedOperation( + call = { it.otaRequest(event) }, + requestMatches = { it.ota_request == event }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setSensorConfigSendsConfig() = runTest { + val config = SensorConfig() + assertAckedOperation( + call = { it.setSensorConfig(config) }, + requestMatches = { it.sensor_config == config }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun exitSimulatorSendsFlag() = runTest { + assertAckedOperation( + call = { it.exitSimulator() }, + requestMatches = { it.exit_simulator == true }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun rebootSendsDelaySeconds() = runTest { + assertAckedOperation( + call = { it.reboot(3.seconds) }, + requestMatches = { it.reboot_seconds == 3 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun shutdownSendsDelaySeconds() = runTest { + assertAckedOperation( + call = { it.shutdown(4.seconds) }, + requestMatches = { it.shutdown_seconds == 4 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun enterDfuModeManagedDeviceIsUnauthorized() = runTest { + val (transport, client) = connectedClient( + frames = handshakeFrames( + org.meshtastic.proto.FromRadio(metadata = DeviceMetadata(firmware_version = "managed")), + org.meshtastic.proto.FromRadio(config = Config(security = Config.SecurityConfig(is_managed = true))), + ), + ) + client.connect() + runCurrent() + try { + assertNotNull(client.configBundle.value) + val outboundBefore = transport.outboundPackets().size + assertEquals(AdminResult.Unauthorized, client.admin.enterDfuMode()) + assertEquals(outboundBefore, transport.outboundPackets().size) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun deleteFileTimeoutReturnsTimeout() = runTest { + assertAckedOperation( + call = { it.deleteFile("logs/app.txt") }, + requestMatches = { it.delete_file_request == "logs/app.txt" }, + expected = AdminResult.Timeout, + ) { _, _ -> + advanceTimeBy(70.seconds) + } + } + + @Test + fun factoryResetPreserveBleBondsUsesConfigReset() = runTest { + assertAckedOperation( + call = { it.factoryReset(preserveBleBonds = true) }, + requestMatches = { it.factory_reset_config == 1 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun factoryResetWithoutPreserveBleBondsUsesDeviceReset() = runTest { + assertAckedOperation( + call = { it.factoryReset(preserveBleBonds = false) }, + requestMatches = { it.factory_reset_device == 1 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun nodeDbResetSendsFlag() = runTest { + assertAckedOperation( + call = { it.nodeDbReset() }, + requestMatches = { it.nodedb_reset == true }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setOwnerTimeoutReturnsTimeout() = runTest { + val user = User(id = "!00000001", long_name = "Owner", short_name = "OW") + assertAckedOperation( + call = { it.setOwner(user) }, + requestMatches = { it.set_owner == user }, + expected = AdminResult.Timeout, + ) { _, _ -> + advanceTimeBy(70.seconds) + } + } + + @Test + fun setOwnerRoutingErrorMapsToRateLimited() = runTest { + val user = User(id = "!00000001", long_name = "Owner", short_name = "OW") + assertAckedOperation( + call = { it.setOwner(user) }, + requestMatches = { it.set_owner == user }, + expected = AdminResult.RateLimited, + ) { transport, packet -> + transport.injectFrame(buildRoutingErrorFrame(packet.id, Routing.Error.RATE_LIMIT_EXCEEDED)) + } + } + + @Test + fun setTimeOnlyManagedDeviceIsUnauthorized() = runTest { + val (transport, client) = connectedClient( + frames = handshakeFrames( + org.meshtastic.proto.FromRadio(metadata = DeviceMetadata(firmware_version = "managed")), + org.meshtastic.proto.FromRadio(config = Config(security = Config.SecurityConfig(is_managed = true))), + ), + ) + client.connect() + runCurrent() + try { + assertNotNull(client.configBundle.value) + val outboundBefore = transport.outboundPackets().size + assertEquals(AdminResult.Unauthorized, client.admin.setTimeOnly(1_700_000_123)) + assertEquals(outboundBefore, transport.outboundPackets().size) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun setTimeUsesExplicitInstant() = runTest { + val instant = Instant.fromEpochSeconds(1_700_000_456L) + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val result = client.admin.setTime(instant) + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore) { it.set_time_only == instant.epochSeconds.toInt() } + assertIs>(result) + assertFalse(packet.want_ack) + assertEquals(false, packet.decoded?.want_response) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun setHamModeSuccess() = runTest { + val params = HamParameters(call_sign = "KD2ABC", tx_power = 20, frequency = 146.52f, short_name = "KD") + assertAckedOperation( + call = { it.setHamMode(params) }, + requestMatches = { it.set_ham_mode == params }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setHamModeTimeoutReturnsTimeout() = runTest { + val params = HamParameters(call_sign = "KD2ABC") + assertAckedOperation( + call = { it.setHamMode(params) }, + requestMatches = { it.set_ham_mode == params }, + expected = AdminResult.Timeout, + ) { _, _ -> + advanceTimeBy(70.seconds) + } + } + + @Test + fun forNodeTargetsRemoteDestination() = runTest { + val remote = NodeId(0x12345678) + val expected = DeviceMetadata(firmware_version = "remote") + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.forNode(remote).getDeviceMetadata() } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore) { it.get_device_metadata_request == true } + assertEquals(remote.raw, packet.to) + transport.injectAdminResponse(packet.id, AdminMessage(get_device_metadata_response = expected), fromNode = remote.raw) + runCurrent() + + assertEquals(AdminResult.Success(expected), deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun editSettingsSuccessfulWritesUpdateConfigBundle() = runTest { + val updatedConfig = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) + val updatedModule = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) + val (transport, client) = connectedClient( + frames = handshakeFrames( + org.meshtastic.proto.FromRadio(metadata = DeviceMetadata(firmware_version = "2.5.0")), + org.meshtastic.proto.FromRadio(config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))), + org.meshtastic.proto.FromRadio(moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = false))), + ), + ) + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { + client.admin.editSettings { + setConfig(updatedConfig) + setModuleConfig(updatedModule) + } + } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + val commit = latestAdminPacket(transport, outboundBefore) { it.commit_edit_settings == true } + transport.injectRoutingAck(commit.id) + runCurrent() + + assertIs>(deferred.await()) + val bundle = client.configBundle.value + assertNotNull(bundle) + assertTrue(bundle.configs.any { it.device?.role == Config.DeviceConfig.Role.ROUTER }) + assertTrue(bundle.moduleConfigs.any { it.mqtt?.enabled == true }) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun batchSuccessfulWritesUpdateConfigBundle() = runTest { + val updatedConfig = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) + val updatedModule = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) + val (transport, client) = connectedClient( + frames = handshakeFrames( + org.meshtastic.proto.FromRadio(metadata = DeviceMetadata(firmware_version = "2.5.0")), + org.meshtastic.proto.FromRadio(config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))), + org.meshtastic.proto.FromRadio(moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = false))), + ), + ) + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { + client.admin.batch { + setConfig(updatedConfig) + setModuleConfig(updatedModule) + "done" + } + } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + val commit = latestAdminPacket(transport, outboundBefore) { it.commit_edit_settings == true } + transport.injectRoutingAck(commit.id) + runCurrent() + + assertEquals("done", deferred.await()) + val bundle = client.configBundle.value + assertNotNull(bundle) + assertTrue(bundle.configs.any { it.device?.role == Config.DeviceConfig.Role.ROUTER }) + assertTrue(bundle.moduleConfigs.any { it.mqtt?.enabled == true }) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun batchBlockExceptionSkipsCommit() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { + runCatching { + client.admin.batch { + error("boom") + } + } + } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + val result = deferred.await() + assertEquals("boom", result.exceptionOrNull()?.message) + assertTrue( + transport.outboundPackets().drop(outboundBefore) + .none { adminOf(it)?.commit_edit_settings == true }, + ) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun batchBeginFailureThrowsNodeUnreachable() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { runCatching { client.admin.batch { Unit } } } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectFrame(buildRoutingErrorFrame(begin.id, Routing.Error.NO_ROUTE)) + runCurrent() + + assertIs(deferred.await().exceptionOrNull()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun batchCommitFailureThrowsUnauthorized() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { runCatching { client.admin.batch { Unit } } } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + val commit = latestAdminPacket(transport, outboundBefore) { it.commit_edit_settings == true } + transport.injectFrame(buildRoutingErrorFrame(commit.id, Routing.Error.NOT_AUTHORIZED)) + runCurrent() + + assertIs(deferred.await().exceptionOrNull()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun batchSetterDisconnectThrowsNodeUnreachable() = runTest { + val gate = CompletableDeferred() + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { + runCatching { + client.admin.batch { + gate.await() + setFavorite(NodeId(0x10101010), favorite = true) + } + } + } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + client.disconnect() + runCurrent() + gate.complete(Unit) + runCurrent() + + val result = deferred.await() + assertIs(result.exceptionOrNull()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun editSettingsBeginFailureReturnsNodeUnreachable() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.editSettings { Unit } } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectFrame(buildRoutingErrorFrame(begin.id, Routing.Error.NO_ROUTE)) + runCurrent() + + assertEquals(AdminResult.NodeUnreachable, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun editSettingsCommitFailureReturnsUnauthorized() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.editSettings { Unit } } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + val commit = latestAdminPacket(transport, outboundBefore) { it.commit_edit_settings == true } + transport.injectFrame(buildRoutingErrorFrame(commit.id, Routing.Error.NOT_AUTHORIZED)) + runCurrent() + + assertEquals(AdminResult.Unauthorized, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun editSettingsSetterDisconnectReturnsNodeUnreachable() = runTest { + val gate = CompletableDeferred() + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { + client.admin.editSettings { + gate.await() + setFavorite(NodeId(0x20202020), favorite = true) + } + } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + client.disconnect() + runCurrent() + gate.complete(Unit) + runCurrent() + + assertEquals(AdminResult.NodeUnreachable, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + private fun TestScope.connectedClient( + nodeNum: Int = 1, + storageProvider: StorageProvider = InMemoryStorageProvider(), + rpcTimeout: Duration = 60.seconds, + sendTimeout: Duration = 60.seconds, + frames: List = emptyList(), + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:admin-comprehensive:$nodeNum:${hashCode()}"), + frames = frames, + autoHandshake = true, + nodeNum = nodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(storageProvider) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .rpcTimeout(rpcTimeout) + .sendTimeout(sendTimeout) + .build() + return transport to client + } + + private fun handshakeFrames(vararg fromRadio: org.meshtastic.proto.FromRadio): List = + fromRadio.map(::fromRadioFrame) + + private fun fromRadioFrame(fromRadio: org.meshtastic.proto.FromRadio): Frame { + val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) + val bytes = ByteArray(4 + proto.size).apply { + this[0] = 0x94.toByte() + this[1] = 0xC3.toByte() + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = 4) + } + return Frame(kotlinx.io.bytestring.ByteString(bytes)) + } + + private suspend fun TestScope.assertAckedOperation( + storageProvider: StorageProvider = InMemoryStorageProvider(), + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + expected: AdminResult = AdminResult.Success(Unit), + assertPacket: (MeshPacket, AdminMessage) -> Unit = { _, _ -> }, + respond: suspend TestScope.(FakeRadioTransport, MeshPacket) -> Unit, + ) { + val (transport, client) = connectedClient(storageProvider = storageProvider) + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + val admin = adminOf(packet)!! + assertTrue(packet.want_ack) + assertEquals(false, packet.decoded?.want_response) + assertPacket(packet, admin) + + respond(transport, packet) + runCurrent() + + assertEquals(expected, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + private suspend fun TestScope.assertRpcOperation( + storageProvider: StorageProvider = InMemoryStorageProvider(), + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + expected: AdminResult, + assertPacket: (MeshPacket, AdminMessage) -> Unit = { _, _ -> }, + respond: suspend TestScope.(FakeRadioTransport, MeshPacket) -> Unit, + ) { + val (transport, client) = connectedClient(storageProvider = storageProvider) + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + val admin = adminOf(packet)!! + assertFalse(packet.want_ack) + assertEquals(true, packet.decoded?.want_response) + assertPacket(packet, admin) + + respond(transport, packet) + runCurrent() + + assertEquals(expected, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + private fun latestAdminPacket( + transport: FakeRadioTransport, + outboundBefore: Int, + predicate: (AdminMessage) -> Boolean, + ): MeshPacket = transport.outboundPackets().drop(outboundBefore) + .last { packet -> adminOf(packet)?.let(predicate) == true } + + private fun adminOf(packet: MeshPacket): AdminMessage? { + val decoded = packet.decoded ?: return null + if (decoded.portnum != PortNum.ADMIN_APP) return null + return runCatching { AdminMessage.ADAPTER.decode(decoded.payload) }.getOrNull() + } + + private fun buildRoutingErrorFrame(requestId: Int, error: Routing.Error): Frame { + val payload = okio.ByteString.of(*Routing.ADAPTER.encode(Routing(error_reason = error))) + val packet = MeshPacket( + from = 1, + to = 0, + decoded = org.meshtastic.proto.Data( + portnum = PortNum.ROUTING_APP, + payload = payload, + request_id = requestId, + ), + ) + val fromRadio = org.meshtastic.proto.FromRadio(packet = packet) + val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) + val bytes = ByteArray(4 + proto.size).apply { + this[0] = 0x94.toByte() + this[1] = 0xC3.toByte() + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = 4) + } + return Frame(kotlinx.io.bytestring.ByteString(bytes)) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt new file mode 100644 index 0000000..77d9053 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt @@ -0,0 +1,855 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import app.cash.turbine.test +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.io.bytestring.ByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Data +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.MyNodeInfo +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Routing +import org.meshtastic.proto.ToRadio +import org.meshtastic.proto.User +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class HandshakeAndReconnectTest { + + @Test + fun handshakeHappyPathTransitionsStage1Stage2AndConnected() = runTest { + val transport = ScriptedTransport(TransportIdentity("fake:handshake-happy"), nowMs = { currentTime }) + val client = buildClient(transport) + + client.connection.test { + assertEquals(ConnectionState.Disconnected, awaitItem()) + + val connectJob = backgroundScope.async { client.connect() } + + assertIs(awaitItem()) + assertEquals(ConfigPhase.Stage1, assertIs(awaitItem()).phase) + assertEquals(ConfigPhase.Settling, assertIs(awaitItem()).phase) + assertEquals(ConfigPhase.Stage2, assertIs(awaitItem()).phase) + assertEquals(ConnectionState.Connected, awaitItem()) + + connectJob.await() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun handshakeHappyPathSendsStage1HeartbeatAndStage2InOrder() = runTest { + val transport = ScriptedTransport(TransportIdentity("fake:handshake-wire"), nowMs = { currentTime }) + val client = buildClient(transport) + + client.connect() + + val outbound = transport.outboundFrames().mapNotNull(::decodeToRadioOrNull) + val stage1Index = outbound.indexOfFirst { it.want_config_id == NONCE_STAGE1 } + val heartbeatIndex = outbound.indexOfFirst { it.heartbeat?.nonce == 0 } + val stage2Index = outbound.indexOfFirst { it.want_config_id == NONCE_STAGE2 } + + assertTrue(stage1Index >= 0, "Stage 1 nonce must be sent") + assertTrue(heartbeatIndex >= 0, "Inter-stage heartbeat must be sent") + assertTrue(stage2Index >= 0, "Stage 2 nonce must be sent") + assertTrue(stage1Index < heartbeatIndex, "Heartbeat must follow Stage 1") + assertTrue(heartbeatIndex < stage2Index, "Stage 2 must follow the heartbeat settle") + } + + @Test + fun stage2WatchdogDoesNotFireBeforeTickDeadline() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:stage2-pre-tick"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { runCatching { client.connect() } } + + client.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + val stage2StartedAt = currentTime + + advanceTimeBy(STAGE2_PROGRESS_TICK_MS - 1) + runCurrent() + + val state = assertIs(client.connection.value) + assertEquals(ConfigPhase.Stage2, state.phase) + assertFalse(connectJob.isCompleted, "Connect must still be pending before the watchdog tick") + assertEquals(STAGE2_PROGRESS_TICK_MS - 1, currentTime - stage2StartedAt) + + client.disconnect() + assertIs(connectJob.await().exceptionOrNull()) + } + + @Test + fun stage2WatchdogAbortsSilentHandshake() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:stage2-silent"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { runCatching { client.connect() } } + + client.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + val stage2StartedAt = currentTime + + advanceTimeBy(STAGE2_PROGRESS_TICK_MS + 1) + runCurrent() + + val error = connectJob.await().exceptionOrNull() + val timeout = assertIs(error) + assertEquals("Stage 2", timeout.stage) + assertEquals(ConnectionState.Disconnected, client.connection.value) + assertTrue(currentTime - stage2StartedAt >= STAGE2_PROGRESS_TICK_MS) + } + + @Test + fun stage2WatchdogSlidesWhileProgressFramesArrive() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:stage2-sliding"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { runCatching { client.connect() } } + + client.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + val stage2StartedAt = currentTime + + repeat(2) { index -> + advanceTimeBy(STAGE2_PROGRESS_TICK_MS - 1_000L) + runCurrent() + transport.injectStage2Progress(node = 0x1200 + index) + runCurrent() + val state = assertIs(client.connection.value) + assertEquals(ConfigPhase.Stage2, state.phase) + assertFalse(connectJob.isCompleted, "Progress frame #${index + 1} should keep Stage 2 alive") + } + + assertEquals(2 * (STAGE2_PROGRESS_TICK_MS - 1_000L), currentTime - stage2StartedAt) + + client.disconnect() + assertIs(connectJob.await().exceptionOrNull()) + } + + @Test + fun stage2HardCapAbortsEvenWithProgress() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:stage2-hard-cap"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { runCatching { client.connect() } } + + client.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + val stage2StartedAt = currentTime + + advanceTimeBy(STAGE2_PROGRESS_TICK_MS - 1_000L) + runCurrent() + transport.injectStage2Progress(node = 0x2001) + runCurrent() + + advanceTimeBy(STAGE2_PROGRESS_TICK_MS - 1_000L) + runCurrent() + transport.injectStage2Progress(node = 0x2002) + runCurrent() + assertFalse(connectJob.isCompleted) + + advanceTimeBy(2_000L) + runCurrent() + + val error = connectJob.await().exceptionOrNull() + val timeout = assertIs(error) + assertEquals("Stage 2", timeout.stage) + assertTrue(currentTime - stage2StartedAt >= STAGE2_HARD_CAP_MS) + } + + @Test + fun connectionLostDuringStage1ResetsCleanlyAndAllowsRetry() = runTest { + val firstTransport = ScriptedTransport(TransportIdentity("fake:drop-stage1"), nowMs = { currentTime }) + firstTransport.dropStage1OnNextHandshake = true + val firstClient = buildClient(firstTransport) + + val firstConnect = backgroundScope.async { runCatching { firstClient.connect() } } + firstClient.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage1 } + runCurrent() + + assertIs(firstConnect.await().exceptionOrNull()) + assertEquals(ConnectionState.Disconnected, firstClient.connection.value) + + val retryClient = buildClient(ScriptedTransport(TransportIdentity("fake:drop-stage1-retry"), nowMs = { currentTime })) + retryClient.connect() + assertEquals(ConnectionState.Connected, retryClient.connection.value) + } + + @Test + fun connectionLostDuringStage2ResetsCleanlyAndCancelsOldWatchdog() = runTest { + val firstTransport = ScriptedTransport( + identity = TransportIdentity("fake:drop-stage2"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + firstTransport.dropStage2OnNextHandshake = true + val firstClient = buildClient(firstTransport) + + val firstConnect = backgroundScope.async { runCatching { firstClient.connect() } } + firstClient.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + runCurrent() + + assertIs(firstConnect.await().exceptionOrNull()) + assertEquals(ConnectionState.Disconnected, firstClient.connection.value) + + val retryClient = buildClient(ScriptedTransport(TransportIdentity("fake:drop-stage2-retry"), nowMs = { currentTime })) + retryClient.connect() + advanceTimeBy(STAGE2_PROGRESS_TICK_MS + 5_000L) + runCurrent() + + assertEquals(ConnectionState.Connected, retryClient.connection.value) + } + + @Test + fun failedStage2TimeoutCanBeRetriedWithoutDanglingCoroutines() = runTest { + val firstTransport = ScriptedTransport( + identity = TransportIdentity("fake:stage2-timeout-retry"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val firstClient = buildClient(firstTransport) + + val firstConnect = backgroundScope.async { runCatching { firstClient.connect() } } + firstClient.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + advanceTimeBy(STAGE2_PROGRESS_TICK_MS + 1) + runCurrent() + + val error = firstConnect.await().exceptionOrNull() + assertIs(error) + assertEquals(ConnectionState.Disconnected, firstClient.connection.value) + + val retryClient = buildClient(ScriptedTransport(TransportIdentity("fake:stage2-timeout-retry-success"), nowMs = { currentTime })) + retryClient.connect() + advanceTimeBy(STAGE2_PROGRESS_TICK_MS + 5_000L) + runCurrent() + + assertEquals(ConnectionState.Connected, retryClient.connection.value) + } + + @Test + fun remoteAdminUsesSeededSessionPasskeyBeforeExpiry() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:session-passkey"), + nowMs = { currentTime }, + sessionPasskey = SEEDED_PASSKEY, + ) + val client = buildClient(transport) + val remoteNode = NodeId(0x0BEEF) + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val expected = org.meshtastic.proto.Config( + lora = org.meshtastic.proto.Config.LoRaConfig(use_preset = true), + ) + val deferred = backgroundScope.async { + client.admin.forNode(remoteNode).getConfig(AdminMessage.ConfigType.LORA_CONFIG) + } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } + val admin = assertNotNull(adminOf(request)) + assertContentEquals(SEEDED_PASSKEY, admin.session_passkey.toByteArray()) + + transport.injectAdminResponse( + requestId = request.id, + response = AdminMessage(get_config_response = expected), + fromNode = remoteNode.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + } + + @Test + fun sessionKeyExpiryAfterThreeHundredSecondsReauthenticatesTransparently() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:session-expiry"), + nowMs = { currentTime }, + sessionPasskey = SEEDED_PASSKEY, + ) + val client = buildClient(transport) + val remoteNode = NodeId(0x0CAFE) + client.connect() + runCurrent() + + keepSessionAlive(transport, 300.seconds.inWholeMilliseconds) + + val outboundBefore = transport.outboundPackets().size + val expected = org.meshtastic.proto.Config( + lora = org.meshtastic.proto.Config.LoRaConfig(region = org.meshtastic.proto.Config.LoRaConfig.RegionCode.US), + ) + val deferred = backgroundScope.async { + client.admin.forNode(remoteNode).getConfig(AdminMessage.ConfigType.LORA_CONFIG) + } + runCurrent() + + val firstRemote = transport.outboundPackets().drop(outboundBefore) + .first { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } + assertEquals(0, adminOf(firstRemote)?.session_passkey?.size ?: -1) + + transport.injectRoutingError(firstRemote.id, Routing.Error.ADMIN_BAD_SESSION_KEY, fromNode = remoteNode.raw) + drainCurrent() + + val outboundAfterRetry = transport.outboundPackets().drop(outboundBefore) + val reseed = outboundAfterRetry.firstOrNull { + it.to == transport.nodeNum && adminOf(it)?.get_owner_request == true + } + assertNotNull(reseed, "Session expiry must trigger a local get_owner re-seed") + + val replay = outboundAfterRetry.last { + it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG + } + assertTrue(replay.id != firstRemote.id, "Replay must use a fresh wire id") + assertContentEquals(SEEDED_PASSKEY, adminOf(replay)?.session_passkey?.toByteArray()) + + transport.injectAdminResponse( + requestId = replay.id, + response = AdminMessage(get_config_response = expected), + fromNode = remoteNode.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + } + + @Test + fun sessionKeyExpiryRetriesOnlyOnceThenSurfacesFailure() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:session-expiry-single-shot"), + nowMs = { currentTime }, + sessionPasskey = SEEDED_PASSKEY, + ) + val client = buildClient(transport) + val remoteNode = NodeId(0x0D00D) + client.connect() + runCurrent() + + keepSessionAlive(transport, 300.seconds.inWholeMilliseconds) + + val outboundBefore = transport.outboundPackets().size + val deferred = backgroundScope.async { + client.admin.forNode(remoteNode).getConfig(AdminMessage.ConfigType.LORA_CONFIG) + } + runCurrent() + + val firstRemote = transport.outboundPackets().drop(outboundBefore) + .first { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } + transport.injectRoutingError(firstRemote.id, Routing.Error.ADMIN_BAD_SESSION_KEY, fromNode = remoteNode.raw) + drainCurrent() + + val replay = transport.outboundPackets().drop(outboundBefore) + .last { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } + transport.injectRoutingError(replay.id, Routing.Error.ADMIN_BAD_SESSION_KEY, fromNode = remoteNode.raw) + drainCurrent() + + assertEquals(AdminResult.SessionKeyExpired, deferred.await()) + assertEquals( + 1, + transport.outboundPackets().drop(outboundBefore).count { + it.to == transport.nodeNum && adminOf(it)?.get_owner_request == true + }, + "Re-authentication must be single-shot", + ) + } + + @Test + fun autoReconnectWaitsInitialBackoffBeforeRetrying() = runTest { + val transport = ScriptedTransport(TransportIdentity("fake:reconnect-initial"), nowMs = { currentTime }) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 8.seconds, + jitter = 0.0, + ), + ) + client.connect() + runCurrent() + + val droppedAt = currentTime + transport.simulateRecoverableError("drop-once") + runCurrent() + assertReconnectState(client.connection.value, attempt = 1) + + advanceTimeBy(999L) + runCurrent() + assertEquals(0, transport.reconnectAttemptTimes().size) + + advanceTimeBy(1L) + drainCurrent() + + awaitConnected(client) + + assertEquals(listOf(1_000L), transport.reconnectAttemptTimes().map { it - droppedAt }) + assertEquals(ConnectionState.Connected, client.connection.value) + } + + @Test + fun autoReconnectBackoffDoublesAcrossFailures() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:reconnect-double"), + nowMs = { currentTime }, + reconnectOutcomes = listOf( + ConnectOutcome.Fail("attempt-1"), + ConnectOutcome.Fail("attempt-2"), + ConnectOutcome.Success, + ), + ) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 8.seconds, + jitter = 0.0, + ), + ) + client.connect() + runCurrent() + + val droppedAt = currentTime + transport.simulateRecoverableError("backoff-double") + runCurrent() + + advanceTimeBy(1_000L) + drainCurrent() + advanceTimeBy(2_000L) + drainCurrent() + advanceTimeBy(4_000L) + drainCurrent() + awaitConnected(client) + + assertEquals(listOf(1_000L, 3_000L, 7_000L), transport.reconnectAttemptTimes().map { it - droppedAt }) + assertEquals(ConnectionState.Connected, client.connection.value) + } + + @Test + fun autoReconnectBackoffRespectsMaxCap() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:reconnect-cap"), + nowMs = { currentTime }, + reconnectOutcomes = listOf( + ConnectOutcome.Fail("attempt-1"), + ConnectOutcome.Fail("attempt-2"), + ConnectOutcome.Fail("attempt-3"), + ConnectOutcome.Success, + ), + ) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 4.seconds, + jitter = 0.0, + ), + ) + client.connect() + runCurrent() + + val droppedAt = currentTime + transport.simulateRecoverableError("backoff-cap") + runCurrent() + + advanceTimeBy(1_000L) + drainCurrent() + advanceTimeBy(2_000L) + drainCurrent() + advanceTimeBy(4_000L) + drainCurrent() + advanceTimeBy(4_000L) + drainCurrent() + awaitConnected(client) + + assertEquals(listOf(1_000L, 3_000L, 7_000L, 11_000L), transport.reconnectAttemptTimes().map { it - droppedAt }) + assertEquals(ConnectionState.Connected, client.connection.value) + } + + @Test + fun maxReconnectAttemptsStopAfterConfiguredCap() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:reconnect-max-attempts"), + nowMs = { currentTime }, + reconnectOutcomes = listOf( + ConnectOutcome.Fail("attempt-1"), + ConnectOutcome.Fail("attempt-2"), + ConnectOutcome.Fail("attempt-3"), + ConnectOutcome.Success, + ), + ) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 8.seconds, + maxAttempts = 3, + jitter = 0.0, + ), + ) + client.connect() + runCurrent() + + transport.simulateRecoverableError("retry-cap") + runCurrent() + + advanceTimeBy(1_000L) + drainCurrent() + advanceTimeBy(2_000L) + drainCurrent() + advanceTimeBy(4_000L) + drainCurrent() + advanceTimeBy(20_000L) + drainCurrent() + + assertEquals(3, transport.reconnectAttemptTimes().size) + assertEquals(ConnectionState.Disconnected, client.connection.value) + } + + @Test + fun reconnectAfterSuccessStartsFreshBackoffCounter() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:reconnect-reset"), + nowMs = { currentTime }, + reconnectOutcomes = listOf( + ConnectOutcome.Fail("attempt-1"), + ConnectOutcome.Success, + ConnectOutcome.Success, + ), + ) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 8.seconds, + jitter = 0.0, + ), + ) + client.connect() + runCurrent() + + val firstDropAt = currentTime + transport.simulateRecoverableError("first-drop") + runCurrent() + + advanceTimeBy(1_000L) + drainCurrent() + advanceTimeBy(2_000L) + drainCurrent() + awaitConnected(client) + assertEquals(listOf(1_000L, 3_000L), transport.reconnectAttemptTimes().map { it - firstDropAt }) + assertEquals(ConnectionState.Connected, client.connection.value) + + val secondDropAt = currentTime + transport.simulateRecoverableError("second-drop") + runCurrent() + assertReconnectState(client.connection.value, attempt = 1) + + advanceTimeBy(1_000L) + drainCurrent() + awaitConnected(client) + + val attempts = transport.reconnectAttemptTimes() + assertEquals(1_000L, attempts.last() - secondDropAt) + assertEquals(ConnectionState.Connected, client.connection.value) + } + + private fun TestScope.buildClient( + transport: RadioTransport, + autoReconnect: AutoReconnectConfig = AutoReconnectConfig.Disabled, + ): RadioClient = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .clock(SchedulerClock { currentTime }) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .rpcTimeout(60.seconds) + .autoReconnect(autoReconnect) + .build() + + private fun assertReconnectState(state: ConnectionState, attempt: Int) { + val reconnecting = assertIs(state) + assertEquals(attempt, reconnecting.attempt) + } + + private fun TestScope.drainCurrent(times: Int = 20) { + repeat(times) { runCurrent() } + } + + private fun TestScope.keepSessionAlive(transport: ScriptedTransport, totalMs: Long) { + var remaining = totalMs + var packetId = 10_000 + while (remaining > 0) { + val step = minOf(25_000L, remaining) + advanceTimeBy(step) + drainCurrent() + transport.injectAlivePacket(packetId++) + drainCurrent() + remaining -= step + } + } + + private fun TestScope.awaitConnected(client: RadioClient) { + repeat(10) { + if (client.connection.value == ConnectionState.Connected) return + advanceTimeBy(100L) + drainCurrent() + } + assertEquals(ConnectionState.Connected, client.connection.value) + } + + private class SchedulerClock(private val nowMs: () -> Long) : kotlin.time.Clock { + override fun now(): kotlin.time.Instant = kotlin.time.Instant.fromEpochMilliseconds(nowMs()) + } + + private fun adminOf(packet: MeshPacket): AdminMessage? { + val payload = packet.decoded?.payload ?: return null + return runCatching { AdminMessage.ADAPTER.decode(payload) }.getOrNull() + } + + private fun decodeToRadioOrNull(frame: Frame): ToRadio? { + val bytes = frame.bytes.toByteArray() + if (bytes.size < 4) return null + return runCatching { ToRadio.ADAPTER.decode(bytes.copyOfRange(4, bytes.size)) }.getOrNull() + } + + private fun encodeFromRadio(fromRadio: org.meshtastic.proto.FromRadio): Frame { + val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) + val bytes = ByteArray(4 + proto.size).apply { + this[0] = 0x94.toByte() + this[1] = 0xC3.toByte() + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = 4) + } + return Frame(ByteString(bytes)) + } + + private sealed interface ConnectOutcome { + data object Success : ConnectOutcome + data class Fail(val message: String) : ConnectOutcome + } + + private inner class ScriptedTransport( + override val identity: TransportIdentity, + private val nowMs: () -> Long, + reconnectOutcomes: List = emptyList(), + var autoCompleteStage1: Boolean = true, + var autoCompleteStage2: Boolean = true, + val nodeNum: Int = DEFAULT_NODE_NUM, + private val sessionPasskey: ByteArray = SEEDED_PASSKEY, + ) : RadioTransport { + private val connectPlan = ArrayDeque(reconnectOutcomes) + private val connectTimes = mutableListOf() + private val outboundFrames = mutableListOf() + private val stateFlow = MutableStateFlow(TransportState.Disconnected) + private val inbound = MutableSharedFlow(extraBufferCapacity = 128) + + var dropStage1OnNextHandshake: Boolean = false + var dropStage2OnNextHandshake: Boolean = false + + override val state: StateFlow = stateFlow + + override suspend fun connect() { + stateFlow.value = TransportState.Connecting + val shouldFail = if (connectTimes.isEmpty()) ConnectOutcome.Success else connectPlan.removeFirstOrNull() ?: ConnectOutcome.Success + val attemptedAt = nowMs() + if (shouldFail is ConnectOutcome.Fail) { + connectTimes += attemptedAt + stateFlow.value = TransportState.Disconnected + throw MeshtasticException.Transport(shouldFail.message) + } + connectTimes += attemptedAt + stateFlow.value = TransportState.Connected + } + + override suspend fun disconnect() { + stateFlow.value = TransportState.Disconnected + } + + override suspend fun send(frame: Frame) { + outboundFrames += frame + val toRadio = decodeToRadio(frame) ?: return + when (toRadio.want_config_id) { + NONCE_STAGE1 -> handleStage1Request() + NONCE_STAGE2 -> handleStage2Request() + } + toRadio.packet?.let(::handleAdminPacket) + } + + override fun frames(): Flow = inbound + + fun outboundFrames(): List = outboundFrames.toList() + + fun outboundPackets(): List = outboundFrames.mapNotNull { frame -> + decodeToRadio(frame)?.packet + } + + fun reconnectAttemptTimes(): List = connectTimes.drop(1) + + fun injectStage2Progress(node: Int) { + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(node_info = NodeInfo(num = node)))) + } + + fun injectAdminResponse(requestId: Int, response: AdminMessage, fromNode: Int = nodeNum) { + val payload = AdminMessage.ADAPTER.encode(response).toByteString() + val packet = MeshPacket( + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = payload, + request_id = requestId, + ), + ) + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(packet = packet))) + } + + fun injectRoutingError(requestId: Int, error: Routing.Error, fromNode: Int = nodeNum) { + val payload = Routing.ADAPTER.encode(Routing(error_reason = error)).toByteString() + val packet = MeshPacket( + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.ROUTING_APP, + payload = payload, + request_id = requestId, + ), + ) + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(packet = packet))) + } + + fun injectAlivePacket(packetId: Int) { + val packet = MeshPacket( + id = packetId, + from = nodeNum, + to = 0, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = okio.ByteString.EMPTY, + ), + ) + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(packet = packet))) + } + + fun simulateRecoverableError(message: String, recoverable: Boolean = true) { + stateFlow.value = TransportState.Error(MeshtasticException.Transport(message), recoverable) + } + + private fun handleStage1Request() { + if (dropStage1OnNextHandshake) { + dropStage1OnNextHandshake = false + stateFlow.value = TransportState.Error(MeshtasticException.Transport("stage1 dropped"), true) + return + } + if (!autoCompleteStage1) return + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(my_info = MyNodeInfo(my_node_num = nodeNum)))) + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(config_complete_id = NONCE_STAGE1))) + } + + private fun handleStage2Request() { + if (dropStage2OnNextHandshake) { + dropStage2OnNextHandshake = false + stateFlow.value = TransportState.Error(MeshtasticException.Transport("stage2 dropped"), true) + return + } + if (!autoCompleteStage2) return + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(config_complete_id = NONCE_STAGE2))) + } + + private fun handleAdminPacket(packet: MeshPacket) { + val admin = decodeAdmin(packet) ?: return + if (packet.to != nodeNum || admin.get_owner_request != true) return + val user = User( + id = "!00000001", + long_name = "ScriptedNode", + short_name = "SN", + hw_model = HardwareModel.UNSET, + ) + val response = AdminMessage( + get_owner_response = user, + session_passkey = sessionPasskey.toByteString(), + ) + injectAdminResponse(requestId = packet.id, response = response, fromNode = nodeNum) + } + + private fun decodeToRadio(frame: Frame): ToRadio? { + val bytes = frame.bytes.toByteArray() + if (bytes.size < 4) return null + return runCatching { ToRadio.ADAPTER.decode(bytes.copyOfRange(4, bytes.size)) }.getOrNull() + } + + private fun encodeFromRadioFrame(fromRadio: org.meshtastic.proto.FromRadio): Frame { + val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) + val bytes = ByteArray(4 + proto.size).apply { + this[0] = 0x94.toByte() + this[1] = 0xC3.toByte() + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = 4) + } + return Frame(ByteString(bytes)) + } + + private fun decodeAdmin(packet: MeshPacket): AdminMessage? { + val payload = packet.decoded?.payload ?: return null + return runCatching { AdminMessage.ADAPTER.decode(payload) }.getOrNull() + } + } + + private companion object { + const val NONCE_STAGE1 = 69420 + const val NONCE_STAGE2 = 69421 + const val DEFAULT_NODE_NUM = 1 + const val STAGE2_PROGRESS_TICK_MS = 30_000L + const val STAGE2_HARD_CAP_MS = 60_000L + val SEEDED_PASSKEY = byteArrayOf(1, 2, 3, 4) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt new file mode 100644 index 0000000..3fa7207 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt @@ -0,0 +1,730 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Routing +import org.meshtastic.proto.ToRadio +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.io.bytestring.ByteString as KByteString + +@OptIn(ExperimentalCoroutinesApi::class) +class MeshEngineEdgeCasesTest { + + @Test + fun shortFrameIsIgnoredAndLogged() = runTest { + val transport = fakeTransport() + val logs = mutableListOf() + val client = buildClient(transport = transport, logger = recordingLogger(logs)) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(Frame(KByteString(byteArrayOf(WireFraming.MAGIC_0, WireFraming.MAGIC_1, 0x00)))) + runCurrent() + + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue(logs.any { it.level == LogLevel.WARN && it.message.contains("shorter than wire header") }) + assertTrue(warnings.any { it.message.contains("shorter than wire header") }) + + job.cancel() + client.disconnect() + } + + @Test + fun invalidWireHeaderIsDroppedGracefully() = runTest { + val transport = fakeTransport() + val logs = mutableListOf() + val client = buildClient(transport = transport, logger = recordingLogger(logs)) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(rawFrame(encodedFromRadio(FromRadio(node_info = org.meshtastic.proto.NodeInfo(num = 7))), header0 = 0x00, header1 = 0x00)) + runCurrent() + + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue(logs.any { it.level == LogLevel.WARN && it.message.contains("invalid wire header") }) + assertTrue(warnings.any { it.message.contains("invalid wire header") }) + + job.cancel() + client.disconnect() + } + + @Test + fun emptyPayloadFrameEmitsProtocolWarning() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(rawFrame(ByteArray(0), declaredLength = 0)) + runCurrent() + + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue(warnings.any { it.message.contains("empty payload") }) + + job.cancel() + client.disconnect() + } + + @Test + fun truncatedPayloadFrameEmitsLengthMismatchWarning() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + val fullPayload = encodedFromRadio(FromRadio(packet = inboundTextPacket(id = 1, from = 0x1001))) + client.connect() + transport.injectFrame(rawFrame(fullPayload.copyOf(fullPayload.size - 1), declaredLength = fullPayload.size)) + runCurrent() + + val warning = warnings.last() + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue(warning.message.contains("length mismatch")) + assertEquals(fullPayload.size, warning.details["declared_payload_bytes"]) + assertEquals(fullPayload.size - 1, warning.details["actual_payload_bytes"]) + + job.cancel() + client.disconnect() + } + + @Test + fun oversizedInboundFrameIsRejected() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(rawFrame(byteArrayOf(0x08), declaredLength = WireFraming.MAX_PAYLOAD_SIZE + 1)) + runCurrent() + + val warning = warnings.last() + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue(warning.message.contains("exceeds max payload size")) + assertEquals(WireFraming.MAX_PAYLOAD_SIZE + 1, warning.details["declared_payload_bytes"]) + + job.cancel() + client.disconnect() + } + + @Test + fun decodeFailureSurfacesStructuredProtocolWarning() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(rawFrame(byteArrayOf(0x08), declaredLength = 1)) + runCurrent() + + val warning = warnings.last() + assertTrue(warning.message.contains("decode failed")) + assertTrue((warning.details["exception"] as String).isNotEmpty()) + assertEquals(1, warning.details["payload_bytes"]) + + job.cancel() + client.disconnect() + } + + @Test + fun malformedFrameDoesNotPreventSubsequentValidPacketProcessing() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val warnings = mutableListOf() + val packetJob = backgroundScope.launch { client.packets.collect { packets += it } } + val warningJob = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(rawFrame(byteArrayOf(0x08), declaredLength = 1)) + transport.injectPacket(inboundTextPacket(id = 10, from = 0x1002, text = "after-malformed")) + runCurrent() + + assertEquals(1, warnings.size) + assertEquals(listOf(10), packets.map { it.id }) + + packetJob.cancel() + warningJob.cancel() + client.disconnect() + } + + @Test + fun unknownPortPacketIsDroppedFromPacketsFlow() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val job = backgroundScope.launch { client.packets.collect { packets += it } } + + client.connect() + transport.injectPacket( + MeshPacket( + from = 0x2001, + to = 0, + id = 20, + decoded = Data( + portnum = PortNum.UNKNOWN_APP, + payload = ByteString.of(*byteArrayOf(0x01, 0x02)), + ), + ), + ) + runCurrent() + + assertTrue(packets.isEmpty()) + + job.cancel() + client.disconnect() + } + + @Test + fun unknownPortPacketEmitsProtocolWarningAndLog() = runTest { + val transport = fakeTransport() + val logs = mutableListOf() + val client = buildClient(transport = transport, logger = recordingLogger(logs)) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectPacket( + MeshPacket( + from = 0x2002, + to = 0, + id = 21, + decoded = Data(portnum = PortNum.UNKNOWN_APP, payload = ByteString.of(*byteArrayOf(0x05))), + ), + ) + runCurrent() + + assertTrue(logs.any { it.level == LogLevel.WARN && it.message.contains("unknown port") }) + assertTrue(warnings.any { it.message.contains("unknown port") }) + + job.cancel() + client.disconnect() + } + + @Test + fun unknownPortPacketKeepsConnectionAlive() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + + client.connect() + transport.injectPacket( + MeshPacket( + from = 0x2003, + to = 0, + id = 22, + decoded = Data(portnum = PortNum.UNKNOWN_APP, payload = ByteString.of(*byteArrayOf(0x07))), + ), + ) + runCurrent() + + assertEquals(ConnectionState.Connected, client.connection.value) + client.disconnect() + } + + @Test + fun oversizedRawPacketSendIsRejected() = runTest { + val client = buildClient() + client.connect() + + assertFailsWith { + client.send( + MeshPacket( + to = NodeId.BROADCAST.raw, + channel = 0, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteString.of(*ByteArray(DATA_PAYLOAD_LEN + 1)), + ), + ), + ) + } + + client.disconnect() + } + + @Test + fun oversizedPortnumPayloadSendIsRejected() = runTest { + val client = buildClient() + client.connect() + + assertFailsWith { + client.send(PortNum.TEXT_MESSAGE_APP, ByteArray(DATA_PAYLOAD_LEN + 1)) + } + + client.disconnect() + } + + @Test + fun oversizedTextSendIsRejected() = runTest { + val client = buildClient() + client.connect() + + assertFailsWith { + client.sendText("x".repeat(DATA_PAYLOAD_LEN + 1)) + } + + client.disconnect() + } + + @Test + fun encryptedPacketDoesNotReachPacketsFlow() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val job = backgroundScope.launch { client.packets.collect { packets += it } } + + client.connect() + transport.injectPacket(MeshPacket(from = 0x3001, to = 0, id = 30)) + runCurrent() + + assertTrue(packets.isEmpty()) + + job.cancel() + client.disconnect() + } + + @Test + fun encryptedPacketEmitsProtocolWarningAndLog() = runTest { + val transport = fakeTransport() + val logs = mutableListOf() + val client = buildClient(transport = transport, logger = recordingLogger(logs)) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectPacket(MeshPacket(from = 0x3002, to = 0, id = 31)) + runCurrent() + + assertTrue(logs.any { it.level == LogLevel.WARN && it.message.contains("encrypted packet") }) + assertTrue(warnings.any { it.message.contains("encrypted packet") }) + + job.cancel() + client.disconnect() + } + + @Test + fun encryptedPacketWarningIsRateLimitedWithinInterval() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectPacket(MeshPacket(from = 0x3003, to = 0, id = 32)) + transport.injectPacket(MeshPacket(from = 0x3003, to = 0, id = 33)) + runCurrent() + + assertEquals(1, warnings.count { it.message.contains("encrypted packet") }) + + job.cancel() + client.disconnect() + } + + @Test + fun encryptedPacketWarningCarriesRateLimitedDetail() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectPacket(MeshPacket(from = 0x3004, to = 0, id = 34)) + runCurrent() + + val warning = warnings.last() + assertTrue(warning.message.contains("encrypted packet")) + assertEquals(true, warning.details["rate_limited"]) + + job.cancel() + client.disconnect() + } + + @Test + fun encryptedPacketDoesNotAckPendingSend() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport, sendTimeout = 5.seconds) + + client.connect() + val handle = client.send(unicastWantAckPacket(toNodeNum = 0x3005)) + runCurrent() + assertEquals(SendState.Sent, handle.state.value) + + transport.injectPacket(MeshPacket(from = 0x3005, to = 0, id = 37)) + runCurrent() + assertEquals(SendState.Sent, handle.state.value) + + advanceTimeBy(6_000L) + runCurrent() + val state = handle.state.value + assertIs(state) + assertEquals(SendFailure.AckTimeout, state.reason) + + client.disconnect() + } + + @Test + fun duplicateInboundTextPacketIsDeliveredOnce() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val job = backgroundScope.launch { client.packets.collect { packets += it } } + + val packet = inboundTextPacket(id = 40, from = 0x4001) + client.connect() + transport.injectPacket(packet) + transport.injectPacket(packet) + runCurrent() + + assertEquals(listOf(40), packets.map { it.id }) + + job.cancel() + client.disconnect() + } + + @Test + fun duplicatePacketDoesNotBlockDistinctPackets() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val job = backgroundScope.launch { client.packets.collect { packets += it } } + + val first = inboundTextPacket(id = 41, from = 0x4002, text = "first") + val second = inboundTextPacket(id = 42, from = 0x4002, text = "second") + client.connect() + transport.injectPacket(first) + transport.injectPacket(first) + transport.injectPacket(second) + runCurrent() + + assertEquals(listOf(41, 42), packets.map { it.id }) + + job.cancel() + client.disconnect() + } + + @Test + fun samePacketIdFromDifferentNodesIsNotDeduped() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val job = backgroundScope.launch { client.packets.collect { packets += it } } + + client.connect() + transport.injectPacket(inboundTextPacket(id = 43, from = 0x4003)) + transport.injectPacket(inboundTextPacket(id = 43, from = 0x4004)) + runCurrent() + + assertEquals(listOf(0x4003, 0x4004), packets.map { it.from }) + + job.cancel() + client.disconnect() + } + + @Test + fun duplicateRoutingAckRemainsIdempotent() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport, sendTimeout = 60.seconds) + + client.connect() + val handle = client.send(unicastWantAckPacket(toNodeNum = 0x4005)) + runCurrent() + + transport.injectFrame(routingAckFrame(requestId = handle.id.raw, fromNodeNum = 0x4005)) + runCurrent() + assertEquals(SendState.Acked, handle.state.value) + + transport.injectFrame(routingAckFrame(requestId = handle.id.raw, fromNodeNum = 0x4005)) + runCurrent() + assertEquals(SendState.Acked, handle.state.value) + + client.disconnect() + } + + @Test + fun disconnectSequenceAllowsFreshSessionReconnect() = runTest { + val transport = fakeTransport() + val firstClient = buildClient(transport = transport) + + firstClient.connect() + firstClient.disconnect() + assertEquals(ConnectionState.Disconnected, firstClient.connection.value) + + val secondClient = buildClient(transport = transport) + secondClient.connect() + + assertEquals(ConnectionState.Connected, secondClient.connection.value) + secondClient.disconnect() + } + + @Test + fun recoverableTransportErrorTransitionsThroughReconnectBackToConnected() = runTest { + val transport = fakeTransport() + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 1.seconds, + maxAttempts = 1, + jitter = 0.0, + ), + ) + val states = mutableListOf() + val job = backgroundScope.launch { client.connection.collect { states += it } } + + client.connect() + transport.simulateError(IllegalStateException("link lost"), recoverable = true) + runCurrent() + assertIs(client.connection.value) + + repeat(6) { + if (client.connection.value == ConnectionState.Connected) return@repeat + advanceTimeBy(500L) + runCurrent() + } + + assertTrue(states.any { it is ConnectionState.Reconnecting }) + assertEquals(TransportState.Connected, transport.state.value) + assertTrue(client.connection.value != ConnectionState.Disconnected) + + job.cancel() + client.disconnect() + } + + @Test + fun nonRecoverableTransportErrorEndsDisconnected() = runTest { + val transport = fakeTransport() + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 1.seconds, + maxAttempts = 1, + jitter = 0.0, + ), + ) + + client.connect() + transport.simulateError(IllegalStateException("fatal"), recoverable = false) + runCurrent() + advanceTimeBy(1L) + runCurrent() + + assertEquals(ConnectionState.Disconnected, client.connection.value) + } + + @Test + fun concurrentSendTextCallsProduceUniqueIds() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + + client.connect() + val handles = (1..10).map { index -> + backgroundScope.async { client.sendText("message-$index") } + }.awaitAll() + runCurrent() + + assertEquals(10, handles.map { it.id.raw }.distinct().size) + assertTrue(handles.all { it.state.value == SendState.Sent }) + + client.disconnect() + } + + @Test + fun concurrentRawSendsAllProduceOutboundFrames() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + + client.connect() + (1..8).map { index -> + backgroundScope.async { + client.send( + MeshPacket( + to = NodeId.BROADCAST.raw, + channel = 0, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteString.of(*"payload-$index".encodeToByteArray()), + ), + ), + ) + } + }.awaitAll() + runCurrent() + + val outboundIds = textOutboundPackets(transport).map { it.id } + assertEquals(8, outboundIds.size) + assertEquals(8, outboundIds.distinct().size) + + client.disconnect() + } + + @Test + fun routingAckForOneConcurrentSendDoesNotAffectOthers() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport, sendTimeout = 60.seconds) + + client.connect() + val handles = (1..3).map { index -> + client.send(unicastWantAckPacket(toNodeNum = 0x5000 + index)) + } + runCurrent() + + val target = handles[1] + transport.injectFrame(routingAckFrame(requestId = target.id.raw, fromNodeNum = 0x5002)) + runCurrent() + + assertEquals(SendState.Acked, target.state.value) + assertEquals(SendState.Sent, handles[0].state.value) + assertEquals(SendState.Sent, handles[2].state.value) + + client.disconnect() + } + + private fun TestScope.buildClient( + transport: RadioTransport = fakeTransport(), + logger: LogSink = LogSink.Silent, + autoReconnect: AutoReconnectConfig = AutoReconnectConfig.Disabled, + sendTimeout: Duration = 30.seconds, + ): RadioClient = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .logger(logger) + .autoReconnect(autoReconnect) + .sendTimeout(sendTimeout) + .coroutineContext(backgroundScope.coroutineContext) + .build() + + private fun fakeTransport() = FakeRadioTransport( + identity = TransportIdentity("fake:edge-cases"), + autoHandshake = true, + ) + + private fun recordingLogger(logs: MutableList): LogSink = LogSink { level, tag, message, cause -> + logs += CapturedLog(level, tag, message, cause) + } + + private fun inboundTextPacket(id: Int, from: Int, text: String = "hello") = MeshPacket( + from = from, + to = 0, + id = id, + channel = 0, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteString.of(*text.encodeToByteArray()), + ), + ) + + private fun unicastWantAckPacket(toNodeNum: Int) = MeshPacket( + to = toNodeNum, + channel = 0, + want_ack = true, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteString.of(*"hi".encodeToByteArray()), + ), + ) + + private fun routingAckFrame(requestId: Int, fromNodeNum: Int, error: Routing.Error = Routing.Error.NONE): Frame { + val routing = Routing(error_reason = error) + val payload = ByteString.of(*Routing.ADAPTER.encode(routing)) + val packet = MeshPacket( + from = fromNodeNum, + to = 0, + decoded = Data( + portnum = PortNum.ROUTING_APP, + payload = payload, + request_id = requestId, + ), + ) + return rawFrame(encodedFromRadio(FromRadio(packet = packet))) + } + + private fun rawFrame( + payload: ByteArray, + header0: Byte = WireFraming.MAGIC_0, + header1: Byte = WireFraming.MAGIC_1, + declaredLength: Int = payload.size, + ): Frame { + val bytes = ByteArray(WireFraming.HEADER_SIZE + payload.size) + bytes[0] = header0 + bytes[1] = header1 + bytes[2] = (declaredLength shr 8).toByte() + bytes[3] = (declaredLength and 0xFF).toByte() + payload.copyInto(bytes, destinationOffset = WireFraming.HEADER_SIZE) + return Frame(KByteString(bytes)) + } + + private fun encodedFromRadio(fromRadio: FromRadio): ByteArray = FromRadio.ADAPTER.encode(fromRadio) + + private fun textOutboundPackets(transport: FakeRadioTransport): List = transport.outboundFrames() + .mapNotNull { decodeToRadioOrNull(it)?.packet } + .filter { it.decoded?.portnum == PortNum.TEXT_MESSAGE_APP } + + private fun decodeToRadioOrNull(frame: Frame): ToRadio? { + val bytes = frame.bytes.toByteArray() + if (bytes.size < WireFraming.HEADER_SIZE) return null + return runCatching { ToRadio.ADAPTER.decode(bytes.copyOfRange(WireFraming.HEADER_SIZE, bytes.size)) }.getOrNull() + } + + private data class CapturedLog( + val level: LogLevel, + val tag: String, + val message: String, + val cause: Throwable?, + ) +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt new file mode 100644 index 0000000..d808069 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt @@ -0,0 +1,759 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.proto.StoreForwardPlusPlus +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class StoreForwardProtocolTest { + + private fun TestScope.connectedClient( + identitySuffix: String, + myNodeNum: Int = 0x11111111, + presenceTimeout: Duration = 2.hours, + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:store-forward-$identitySuffix"), + autoHandshake = true, + nodeNum = myNodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .presenceTimeout(presenceTimeout) + .rpcTimeout(60.seconds) + .sendTimeout(60.seconds) + .build() + return transport to client + } + + @Test + fun serverHeartbeatDiscoversServerAndEmitsHeartbeat() = runTest { + val (transport, client) = connectedClient("heartbeat-discovery") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { observed += it } + } + runCurrent() + + val server = NodeId(0x10203040) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = server.raw, + ) + runCurrent() + + assertEquals(listOf(server), client.storeForward.servers.value) + assertEquals(listOf(StoreForwardEvent.ServerDiscovered(server)), observed.filterIsInstance()) + assertEquals(listOf(StoreForwardEvent.Heartbeat(server)), observed.filterIsInstance()) + + collector.cancel() + client.disconnect() + } + + @Test + fun duplicateHeartbeatsDoNotRediscoverServer() = runTest { + val (transport, client) = connectedClient("heartbeat-dedupe") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { observed += it } + } + runCurrent() + + val server = NodeId(0x20304050) + repeat(2) { index -> + transport.injectLegacyStoreForward( + packetId = index + 1, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 60), + ), + fromNode = server.raw, + ) + runCurrent() + } + + assertEquals(listOf(server), client.storeForward.servers.value) + assertEquals(1, observed.filterIsInstance().size) + assertEquals(2, observed.filterIsInstance().size) + + collector.cancel() + client.disconnect() + } + + @Test + fun requestHistoryUsesFirstKnownServerAndAllHistoryWindow() = runTest { + val (transport, client) = connectedClient("history-default-server") + client.connect() + val storeForward = client.storeForward + runCurrent() + + val server = NodeId(0x55667788) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = server.raw, + ) + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestHistory() } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(server.raw, request.to) + assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, payload.rr) + assertEquals(ALL_HISTORY_WINDOW_MINUTES, payload.history?.window) + + transport.injectLegacyStoreForward( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 3, window = ALL_HISTORY_WINDOW_MINUTES), + ), + fromNode = server.raw, + ) + runCurrent() + + val result = assertIs>(deferred.await()) + assertEquals(3, result.value) + client.disconnect() + } + + @Test + fun requestHistoryUsesExplicitServerAndRoundsWindowUpToMinutes() = runTest { + val (transport, client) = connectedClient("history-explicit-server") + client.connect() + val storeForward = client.storeForward + runCurrent() + + val firstServer = NodeId(0x01020304) + val targetServer = NodeId(0x0A0B0C0D) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = firstServer.raw, + ) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = targetServer.raw, + ) + runCurrent() + + val since = Clock.System.now().epochSeconds.toInt() - 61 + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestHistory(since = since, server = targetServer) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(targetServer.raw, request.to) + assertEquals(2, payload.history?.window) + + transport.injectLegacyStoreForward( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 1, window = 2), + ), + fromNode = targetServer.raw, + ) + runCurrent() + + val result = assertIs>(deferred.await()) + assertEquals(1, result.value) + client.disconnect() + } + + @Test + fun requestHistoryFutureTimestampClampsWindowToOneMinute() = runTest { + val (transport, client) = connectedClient("history-future-window") + client.connect() + val storeForward = client.storeForward + runCurrent() + + val server = NodeId(0x11112222) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = server.raw, + ) + runCurrent() + + val since = Clock.System.now().epochSeconds.toInt() + 3600 + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestHistory(since = since) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(1, payload.history?.window) + + transport.injectLegacyStoreForward( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 0, window = 1), + ), + fromNode = server.raw, + ) + runCurrent() + + assertEquals(0, assertIs>(deferred.await()).value) + client.disconnect() + } + + @Test + fun requestHistoryWithLocalServerTargetsSelfNode() = runTest { + val myNode = 0x42424242 + val (transport, client) = connectedClient("history-local-server", myNodeNum = myNode) + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.storeForward.requestHistory(server = NodeId.LOCAL) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + assertEquals(myNode, request.to) + + transport.injectLegacyStoreForward( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 2, window = ALL_HISTORY_WINDOW_MINUTES), + ), + fromNode = myNode, + ) + runCurrent() + + assertEquals(2, assertIs>(deferred.await()).value) + client.disconnect() + } + + @Test + fun requestHistoryWithoutAvailableServersFailsGracefully() = runTest { + val (_, client) = connectedClient("history-no-server") + client.connect() + runCurrent() + + assertEquals(AdminResult.NodeUnreachable, client.storeForward.requestHistory()) + client.disconnect() + } + + @Test + fun zeroMessageHistoryReplayStartsAndCompletesImmediately() = runTest { + val (transport, client) = connectedClient("history-zero-replay") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { observed += it } + } + runCurrent() + + val server = NodeId(0x61626364) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 0, window = 5), + ), + fromNode = server.raw, + ) + runCurrent() + + assertTrue(observed.contains(StoreForwardEvent.HistoryReplayStarted(server, 0))) + assertTrue(observed.contains(StoreForwardEvent.HistoryReplayComplete(server, 0))) + + collector.cancel() + client.disconnect() + } + + @Test + fun historyReplayCountsUniqueMessagesOnly() = runTest { + val (transport, client) = connectedClient("history-dedupe") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { observed += it } + } + runCurrent() + + val server = NodeId(0x71727374) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 2, window = 30), + ), + fromNode = server.raw, + ) + runCurrent() + + transport.injectLegacyStoreForward( + packetId = 41, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, + text = "same".encodeToByteArray().toByteString(), + ), + fromNode = server.raw, + ) + runCurrent() + assertFalse(observed.any { it is StoreForwardEvent.HistoryReplayComplete }) + + transport.injectLegacyStoreForward( + packetId = 41, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, + text = "same".encodeToByteArray().toByteString(), + ), + fromNode = server.raw, + ) + runCurrent() + assertFalse(observed.any { it is StoreForwardEvent.HistoryReplayComplete }) + + transport.injectLegacyStoreForward( + packetId = 42, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST, + text = "other".encodeToByteArray().toByteString(), + ), + fromNode = server.raw, + ) + runCurrent() + + assertEquals( + listOf(StoreForwardEvent.HistoryReplayComplete(server, 2)), + observed.filterIsInstance(), + ) + + collector.cancel() + client.disconnect() + } + + @Test + fun historyReplayMessagesAreEmittedInOrder() = runTest { + val (transport, client) = connectedClient("history-order") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.packets.collect { packet -> + val decoded = packet.decoded ?: return@collect + if (decoded.portnum != PortNum.STORE_FORWARD_APP) return@collect + val message = runCatching { StoreAndForward.ADAPTER.decode(decoded.payload) }.getOrNull() ?: return@collect + if (message.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT || + message.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST + ) { + observed += message.text?.utf8().orEmpty() + } + } + } + runCurrent() + + val server = NodeId(0x81828384.toInt()) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 2, window = 30), + ), + fromNode = server.raw, + ) + transport.injectLegacyStoreForward( + packetId = 101, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, + text = "first".encodeToByteArray().toByteString(), + ), + fromNode = server.raw, + ) + transport.injectLegacyStoreForward( + packetId = 102, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST, + text = "second".encodeToByteArray().toByteString(), + ), + fromNode = server.raw, + ) + runCurrent() + + assertEquals(listOf("first", "second"), observed) + + collector.cancel() + client.disconnect() + } + + @Test + fun disconnectRemovesKnownServersAndEmitsLostEvents() = runTest { + val (transport, client) = connectedClient("disconnect-clears-servers") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { observed += it } + } + runCurrent() + + val first = NodeId(0x01010101) + val second = NodeId(0x02020202) + listOf(first, second).forEach { server -> + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 60), + ), + fromNode = server.raw, + ) + } + runCurrent() + + client.disconnect() + runCurrent() + + assertTrue(client.storeForward.servers.value.isEmpty()) + assertEquals(setOf(first, second), observed.filterIsInstance().map { it.nodeId }.toSet()) + + collector.cancel() + } + + // Server heartbeat timeout/expiry is not yet implemented in StoreForwardApiImpl. + // presenceTimeout only applies to node online/offline presence, not S&F server tracking. + // TODO: Implement S&F server staleness sweep and re-enable this test. + + @Test + fun requestStatsMapsStoreForwardStatistics() = runTest { + val (transport, client) = connectedClient("stats-mapping") + client.connect() + runCurrent() + + val server = NodeId(0x13572468) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.storeForward.requestStats(server) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(StoreAndForward.RequestResponse.CLIENT_STATS, payload.rr) + + transport.injectLegacyStoreForward( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_STATS, + stats = StoreAndForward.Statistics( + messages_saved = 9, + messages_max = 64, + up_time = 3600, + requests = 12, + requests_history = 7, + heartbeat = true, + ), + ), + fromNode = server.raw, + ) + runCurrent() + + val result = assertIs>(deferred.await()) + assertEquals(9, result.value.messagesStored) + assertEquals(64, result.value.messagesMax) + assertEquals(3600, result.value.uptime) + assertEquals(7, result.value.requests) + assertEquals(true, result.value.heartbeat) + client.disconnect() + } + + @Test + fun sfppLinkProvideComputesHashWhenMessageHashMissing() = runTest { + val (transport, client) = connectedClient("sfpp-full-link") + client.connect() + runCurrent() + + val message = "payload".encodeToByteArray() + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + message = message.toByteString(), + encapsulated_id = 42, + encapsulated_to = 0, + encapsulated_from = 0x0BADF00D, + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + val expectedHash = SfppHash.compute( + payload = message, + to = NodeId.BROADCAST.raw, + from = 0x0BADF00D, + id = 42, + ) + assertEquals(NodeId.BROADCAST.raw, event.to) + assertContentEquals(expectedHash, event.messageHash) + client.disconnect() + } + + @Test + fun sfppFragmentedMessagesAreReassembledBeforeEmission() = runTest { + val (transport, client) = connectedClient("sfpp-fragmented") + client.connect() + runCurrent() + + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + message = StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, + message = "hello ".encodeToByteArray().toByteString(), + encapsulated_id = 77, + encapsulated_to = 88, + encapsulated_from = 99, + ), + packetId = 701, + ) + runCurrent() + assertFalse(eventDeferred.isCompleted) + + transport.injectSfpp( + message = StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, + message = "world".encodeToByteArray().toByteString(), + commit_hash = byteArrayOf(9).toByteString(), + encapsulated_id = 77, + encapsulated_to = 88, + encapsulated_from = 99, + ), + packetId = 702, + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + val expectedHash = SfppHash.compute( + payload = "hello world".encodeToByteArray(), + to = 88, + from = 99, + id = 77, + ) + assertEquals(77, event.packetId) + assertEquals(true, event.confirmed) + assertContentEquals(expectedHash, event.messageHash) + client.disconnect() + } + + @Test + fun sfppFragmentAssemblySupportsOutOfOrderChunks() = runTest { + val (transport, client) = connectedClient("sfpp-out-of-order") + client.connect() + runCurrent() + + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + message = StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, + message = "beta".encodeToByteArray().toByteString(), + encapsulated_id = 15, + encapsulated_to = 16, + encapsulated_from = 17, + ), + packetId = 801, + ) + runCurrent() + assertFalse(eventDeferred.isCompleted) + + transport.injectSfpp( + message = StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, + message = "alpha".encodeToByteArray().toByteString(), + encapsulated_id = 15, + encapsulated_to = 16, + encapsulated_from = 17, + ), + packetId = 802, + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + val expectedHash = SfppHash.compute( + payload = "alphabeta".encodeToByteArray(), + to = 16, + from = 17, + id = 15, + ) + assertContentEquals(expectedHash, event.messageHash) + client.disconnect() + } + + @Test + fun unsupportedStoreForwardPayloadIsIgnoredGracefully() = runTest { + val (transport, client) = connectedClient("unsupported-payload") + client.connect() + runCurrent() + + val eventDeferred = async { client.storeForward.events.first() } + runCurrent() + + transport.injectStoreForwardPayload(byteArrayOf(0x08, 0x63)) + runCurrent() + + assertFalse(eventDeferred.isCompleted) + eventDeferred.cancel() + client.disconnect() + } + + @Test + fun canonAnnounceWithoutHashIsIgnoredGracefully() = runTest { + val (transport, client) = connectedClient("canon-without-hash") + client.connect() + runCurrent() + + val eventDeferred = async { client.storeForward.events.first() } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE, + encapsulated_rxtime = 99, + ), + ) + runCurrent() + + assertFalse(eventDeferred.isCompleted) + eventDeferred.cancel() + client.disconnect() + } + + private fun FakeRadioTransport.injectLegacyStoreForward( + message: StoreAndForward, + fromNode: Int = 0x10203040, + packetId: Int = 1, + requestId: Int? = null, + ) { + val decoded = if (requestId != null) { + Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = StoreAndForward.ADAPTER.encode(message).toByteString(), + request_id = requestId, + ) + } else { + Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = StoreAndForward.ADAPTER.encode(message).toByteString(), + ) + } + val effectivePacketId = if (packetId == 1 && requestId != null) requestId else packetId + injectPacket( + MeshPacket( + id = effectivePacketId, + from = fromNode, + to = 0, + decoded = decoded, + ), + ) + } + + private fun FakeRadioTransport.injectSfpp( + message: StoreForwardPlusPlus, + fromNode: Int = 0x10203040, + packetId: Int = 1, + ) { + injectStoreForwardPayload( + payload = StoreForwardPlusPlus.ADAPTER.encode(message), + fromNode = fromNode, + packetId = packetId, + ) + } + + private fun FakeRadioTransport.injectStoreForwardPayload( + payload: ByteArray, + fromNode: Int = 0x10203040, + packetId: Int = 1, + ) { + injectPacket( + MeshPacket( + id = packetId, + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = payload.toByteString(), + ), + ), + ) + } + + private companion object { + const val ALL_HISTORY_WINDOW_MINUTES: Int = 60 * 24 * 365 * 100 + } +} From 5e80d31224bcf5ec99131ad3a37ef2df0d6c34c1 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 17:31:34 -0500 Subject: [PATCH 27/36] docs: sync api-reference, samples, and consumer guides with current API surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix 5 sample compilation errors: add NodeChange.CameOnline/WentOffline branches (ConnectCmds.kt, TuiCmd.kt, MeshSampleController.kt) and AdminResult.RateLimited branches (Scenarios.kt x2) - Update api-reference.md: add CameOnline/WentOffline to NodeChange, RateLimited to AdminResult, forNode() to AdminApi, sendReaction()/ sendRaw() to Outbound table, StoreForwardApi full section, all 23 config builder extensions, StoreForwardStats/Event/Api to package map - Update error-taxonomy.md: add RateLimited to AdminResult listing, wire mapping table, and example when block - Update integration-guide.md: add §6 admin operations and config builders with editSettings/forNode examples - Update reactive-lifecycle-management.md: add WentOffline/CameOnline to NodeChange when example - Update meshtastic-android-migration.md: add presence variants to scan() accumulator and make AdminResult when exhaustive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 1 + .../org/meshtastic/sdk/StoreForwardApi.kt | 4 +- .../meshtastic/sdk/internal/AdminApiImpl.kt | 13 +- .../sdk/internal/StoreForwardApiImpl.kt | 141 +++++- .../meshtastic/sdk/AdminApiRemainingTest.kt | 407 +++++++++++++++++ .../org/meshtastic/sdk/RadioClientSendTest.kt | 415 ++++++++++++++++++ .../sdk/StoreForwardApiStatsTest.kt | 325 ++++++++++++++ .../meshtastic/sdk/TelemetryApiObserveTest.kt | 315 +++++++++++++ .../org/meshtastic/sdk/WireCodecTest.kt | 153 +++++++ docs/api-reference.md | 98 ++++- .../meshtastic-android-migration.md | 8 +- .../reactive-lifecycle-management.md | 2 + docs/error-taxonomy.md | 7 +- docs/integration-guide.md | 35 +- .../org/meshtastic/cli/cmd/ConnectCmds.kt | 17 + .../kotlin/org/meshtastic/cli/cmd/TuiCmd.kt | 6 + .../meshtastic/cli/conformance/Scenarios.kt | 3 + .../meshtastic/sample/MeshSampleController.kt | 2 + 18 files changed, 1917 insertions(+), 35 deletions(-) create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt create mode 100644 core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiObserveTest.kt diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index 5076e69..ae28693 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -812,6 +812,7 @@ public class RadioClient internal constructor( logger = logSink, bleHeartbeatEnabled = bleHeartbeatEnabled, parentContext = coroutineContext, + clock = clock, sendTimeout = sendTimeout, presenceTimeout = presenceTimeout, autoReconnectConfig = autoReconnectConfig, diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt index 24925d0..0c36df0 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt @@ -53,10 +53,10 @@ public interface StoreForwardApi { /** * Query statistics from a Store-and-Forward server. * - * @param server the S&F node to query + * @param server the S&F node to query. If null, queries the first known server. * @return server statistics including capacity, stored message count, and uptime */ - public suspend fun requestStats(server: NodeId): AdminResult + public suspend fun requestStats(server: NodeId? = null): AdminResult /** * Flow of S&F-specific events (heartbeats, delivery confirmations, etc.). diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 2f664d2..dba1a5e 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -476,12 +476,19 @@ internal class AdminApiImpl( if (isDeviceManaged()) return AdminResult.Unauthorized val first = block() if (first !is AdminResult.SessionKeyExpired) return first - // Re-seed: a fresh getOwner round-trip latches a new session_passkey. We don't propagate - // its success / failure — the original call's retry is the user-visible signal. - getOwner() + // Re-seed against the *local* device PhoneAPI, even when this AdminApiImpl is scoped to + // a remote node via forNode(dest). Remote getOwner requires a valid session passkey, so + // retrying against targetNode would just loop the same expiry failure. + reseedSessionPasskey() return block() } + private suspend fun reseedSessionPasskey(): AdminResult = submitAdminRpc( + adminMsg = AdminMessage(get_owner_request = true), + kind = ResponseKind.AdminOwner, + to = NodeId(engine.myNodeNumOrNull() ?: 0), + ) + private fun localNode(): NodeId = targetNode ?: NodeId(engine.myNodeNumOrNull() ?: 0) private inner class AdminBatchScopeImpl( diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt index f303469..e1cf967 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt @@ -47,6 +47,7 @@ internal class StoreForwardApiImpl( private val scope = CoroutineScope(coroutineContext + CoroutineName("meshtastic-store-forward")) private val knownServers = linkedSetOf() private val activeReplays = mutableMapOf() + private val pendingSfppFragments = mutableMapOf() private val _servers = MutableStateFlow>(emptyList()) override val servers = _servers.asStateFlow() @@ -101,7 +102,7 @@ internal class StoreForwardApiImpl( } } - override suspend fun requestStats(server: NodeId): AdminResult { + override suspend fun requestStats(server: NodeId?): AdminResult { val myNode = engine.myNodeNumOrNull() ?: return AdminResult.NodeUnreachable val targetServer = resolveServer(server) ?: return AdminResult.NodeUnreachable val requestId = engine.nextMessageId().raw @@ -139,7 +140,7 @@ internal class StoreForwardApiImpl( null } if (legacy != null && legacy.unknownFields.size == 0 && looksLikeLegacyStoreForward(legacy)) { - handleLegacySf(legacy, NodeId(packet.from)) + handleLegacySf(legacy, packet) return } @@ -153,7 +154,8 @@ internal class StoreForwardApiImpl( } } - private suspend fun handleLegacySf(message: StoreAndForward, server: NodeId) { + private suspend fun handleLegacySf(message: StoreAndForward, packet: MeshPacket) { + val server = NodeId(packet.from) when (message.rr) { StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, StoreAndForward.RequestResponse.ROUTER_PONG, @@ -179,12 +181,22 @@ internal class StoreForwardApiImpl( -> { rememberServer(server) val progress = activeReplays[server] ?: return + val fingerprint = ReplayMessageFingerprint( + packetId = packet.id, + response = message.rr, + payload = message.text?.toByteArray(), + ) + if (fingerprint in progress.seenMessages) return val delivered = progress.delivered + 1 + val updated = progress.copy( + delivered = delivered, + seenMessages = progress.seenMessages + fingerprint, + ) if (delivered >= progress.expected) { activeReplays.remove(server) _events.emit(StoreForwardEvent.HistoryReplayComplete(server, delivered)) } else { - activeReplays[server] = progress.copy(delivered = delivered) + activeReplays[server] = updated } } @@ -229,25 +241,84 @@ internal class StoreForwardApiImpl( private suspend fun handleLinkProvide(sfpp: StoreForwardPlusPlus) { val confirmed = sfpp.commit_hash.size != 0 - val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE + val messageType = sfpp.sfpp_message_type + val isFragment = messageType != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE val normalizedTo = if (sfpp.encapsulated_to == 0) NodeId.BROADCAST.raw else sfpp.encapsulated_to - val hash = when { - sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray() - !isFragment && sfpp.message.size != 0 -> SfppHash.compute( - payload = sfpp.message.toByteArray(), - to = normalizedTo, + val providedHash = sfpp.message_hash.takeIf { it.size != 0 }?.toByteArray() + if (!isFragment || providedHash != null || sfpp.message.size == 0) { + emitLinkProvided( + packetId = sfpp.encapsulated_id, from = sfpp.encapsulated_from, - id = sfpp.encapsulated_id, + to = normalizedTo, + confirmed = confirmed, + messageHash = when { + providedHash != null -> providedHash + !isFragment && sfpp.message.size != 0 -> SfppHash.compute( + payload = sfpp.message.toByteArray(), + to = normalizedTo, + from = sfpp.encapsulated_from, + id = sfpp.encapsulated_id, + ) + + else -> null + }, + ) + return + } + + val key = SfppFragmentKey( + packetId = sfpp.encapsulated_id, + from = sfpp.encapsulated_from, + to = normalizedTo, + ) + val existing = pendingSfppFragments[key] ?: SfppFragmentState() + val updated = when (messageType) { + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF -> existing.copy( + firstHalf = sfpp.message.toByteArray(), + confirmed = existing.confirmed || confirmed, + ) + + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF -> existing.copy( + secondHalf = sfpp.message.toByteArray(), + confirmed = existing.confirmed || confirmed, ) - else -> null + else -> existing + } + val firstHalf = updated.firstHalf + val secondHalf = updated.secondHalf + if (firstHalf != null && secondHalf != null) { + pendingSfppFragments.remove(key) + emitLinkProvided( + packetId = key.packetId, + from = key.from, + to = key.to, + confirmed = updated.confirmed, + messageHash = SfppHash.compute( + payload = firstHalf + secondHalf, + to = key.to, + from = key.from, + id = key.packetId, + ), + ) + } else { + pendingSfppFragments[key] = updated } + } + + private suspend fun emitLinkProvided( + packetId: Int, + from: Int, + to: Int, + confirmed: Boolean, + messageHash: ByteArray?, + ) { _events.emit( StoreForwardEvent.SfppLinkProvided( - packetId = sfpp.encapsulated_id, - from = sfpp.encapsulated_from, - to = normalizedTo, - messageHash = hash, + packetId = packetId, + from = from, + to = to, + messageHash = messageHash, confirmed = confirmed, ), ) @@ -264,8 +335,10 @@ internal class StoreForwardApiImpl( } private suspend fun handleNodeChange(change: NodeChange) { - if (change is NodeChange.Removed) { - forgetServer(change.nodeId) + when (change) { + is NodeChange.Removed -> forgetServer(change.nodeId) + is NodeChange.WentOffline -> forgetServer(change.nodeId) + else -> Unit } } @@ -291,6 +364,7 @@ internal class StoreForwardApiImpl( } private suspend fun clearServers() { + pendingSfppFragments.clear() if (knownServers.isEmpty()) return val lost = knownServers.toList() knownServers.clear() @@ -331,7 +405,36 @@ internal class StoreForwardApiImpl( data class Connection(val state: ConnectionState) : InternalSignal } - private data class ReplayProgress(val expected: Int, val delivered: Int = 0) + private data class ReplayProgress( + val expected: Int, + val delivered: Int = 0, + val seenMessages: Set = emptySet(), + ) + + private data class ReplayMessageFingerprint( + val packetId: Int, + val response: StoreAndForward.RequestResponse, + val payload: ByteArray?, + ) { + override fun equals(other: Any?): Boolean = other is ReplayMessageFingerprint && + packetId == other.packetId && + response == other.response && + payload.contentEquals(other.payload) + + override fun hashCode(): Int = ((packetId * 31) + response.hashCode()) * 31 + payload.contentHashCode() + } + + private data class SfppFragmentKey( + val packetId: Int, + val from: Int, + val to: Int, + ) + + private data class SfppFragmentState( + val firstHalf: ByteArray? = null, + val secondHalf: ByteArray? = null, + val confirmed: Boolean = false, + ) private companion object { const val ALL_HISTORY_WINDOW_MINUTES: Int = 60 * 24 * 365 * 100 diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt new file mode 100644 index 0000000..25006e9 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt @@ -0,0 +1,407 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +@file:Suppress("DEPRECATION") + +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.KeyVerificationAdmin +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.SensorConfig +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class AdminApiRemainingTest { + + @Test + fun setHamModeSendsHamParameters() = runTest { + val params = HamParameters(call_sign = "KD2ABC", tx_power = 20) + assertAckSuccess( + call = { it.setHamMode(params) }, + requestMatches = { it.set_ham_mode == params }, + ) + } + + @Test + fun enterDfuModeSendsFireAndForgetRequestWithoutTimingOut() = runTest { + assertFireAndForgetSuccess( + call = { it.enterDfuMode() }, + requestMatches = { it.enter_dfu_mode_request == true }, + ) + } + + @Test + fun keyVerificationSendsVerificationMessage() = runTest { + val verification = KeyVerificationAdmin(remote_nodenum = 0x01020304, nonce = 1234L) + assertAckSuccess( + call = { it.keyVerification(verification) }, + requestMatches = { it.key_verification == verification }, + ) + } + + @Test + fun setSensorConfigSendsSensorConfig() = runTest { + val config = SensorConfig() + assertAckSuccess( + call = { it.setSensorConfig(config) }, + requestMatches = { it.sensor_config == config }, + ) + } + + @Test + fun sendInputEventSendsInputEvent() = runTest { + val event = AdminMessage.InputEvent(event_code = 17, kb_char = 65) + assertAckSuccess( + call = { it.sendInputEvent(event) }, + requestMatches = { it.send_input_event == event }, + ) + } + + @Test + fun addContactSendsSharedContact() = runTest { + val contact = SharedContact( + node_num = 77, + user = User(id = "!0000004d", long_name = "Contact", short_name = "CT"), + ) + assertAckSuccess( + call = { it.addContact(contact) }, + requestMatches = { it.add_contact == contact }, + ) + } + + @Test + fun getRemoteHardwarePinsRequestsRemotePins() = runTest { + val expected = NodeRemoteHardwarePinsResponse() + assertRpcSuccess( + call = { it.getRemoteHardwarePins() }, + requestMatches = { it.get_node_remote_hardware_pins_request == true }, + response = AdminMessage(get_node_remote_hardware_pins_response = expected), + expected = expected, + ) + } + + @Test + fun getDeviceConnectionStatusRequestsConnectionStatus() = runTest { + val expected = DeviceConnectionStatus() + assertRpcSuccess( + call = { it.getDeviceConnectionStatus() }, + requestMatches = { it.get_device_connection_status_request == true }, + response = AdminMessage(get_device_connection_status_response = expected), + expected = expected, + ) + } + + @Test + fun deleteFileSendsDeleteRequest() = runTest { + val path = "logs/app.txt" + assertAckSuccess( + call = { it.deleteFile(path) }, + requestMatches = { it.delete_file_request == path }, + ) + } + + @Test + fun otaRequestSendsOtaEvent() = runTest { + val event = AdminMessage.OTAEvent() + assertAckSuccess( + call = { it.otaRequest(event) }, + requestMatches = { it.ota_request == event }, + ) + } + + @Suppress("DEPRECATION") + @Test + fun rebootOtaSendsDelaySeconds() = runTest { + assertAckSuccess( + call = { it.rebootOta(5.seconds) }, + requestMatches = { it.reboot_ota_seconds == 5 }, + ) + } + + @Test + fun exitSimulatorSendsExitFlag() = runTest { + assertAckSuccess( + call = { it.exitSimulator() }, + requestMatches = { it.exit_simulator == true }, + ) + } + + @Test + fun setScaleSendsScaleValue() = runTest { + assertAckSuccess( + call = { it.setScale(240) }, + requestMatches = { it.set_scale == 240 }, + ) + } + + @Test + fun ackWritesTimeoutForHamDeleteAndScale() = runTest { + val params = HamParameters(call_sign = "KD2ABC", tx_power = 20) + assertAckTimeout( + call = { it.setHamMode(params) }, + requestMatches = { it.set_ham_mode == params }, + ) + assertAckTimeout( + call = { it.deleteFile("logs/app.txt") }, + requestMatches = { it.delete_file_request == "logs/app.txt" }, + ) + assertAckTimeout( + call = { it.setScale(240) }, + requestMatches = { it.set_scale == 240 }, + ) + } + + @Test + fun ackWritesTimeoutForVerificationInputAndContact() = runTest { + val verification = KeyVerificationAdmin(remote_nodenum = 0x01020304, nonce = 1234L) + val event = AdminMessage.InputEvent(event_code = 17, kb_char = 65) + val contact = SharedContact( + node_num = 77, + user = User(id = "!0000004d", long_name = "Contact", short_name = "CT"), + ) + assertAckTimeout( + call = { it.keyVerification(verification) }, + requestMatches = { it.key_verification == verification }, + ) + assertAckTimeout( + call = { it.sendInputEvent(event) }, + requestMatches = { it.send_input_event == event }, + ) + assertAckTimeout( + call = { it.addContact(contact) }, + requestMatches = { it.add_contact == contact }, + ) + } + + @Suppress("DEPRECATION") + @Test + fun ackWritesTimeoutForSensorOtaRebootAndExit() = runTest { + val config = SensorConfig() + val event = AdminMessage.OTAEvent() + assertAckTimeout( + call = { it.setSensorConfig(config) }, + requestMatches = { it.sensor_config == config }, + ) + assertAckTimeout( + call = { it.otaRequest(event) }, + requestMatches = { it.ota_request == event }, + ) + assertAckTimeout( + call = { it.rebootOta(5.seconds) }, + requestMatches = { it.reboot_ota_seconds == 5 }, + ) + assertAckTimeout( + call = { it.exitSimulator() }, + requestMatches = { it.exit_simulator == true }, + ) + } + + @Test + fun getOperationsTimeoutForRemotePinsAndConnectionStatus() = runTest { + assertRpcTimeout( + call = { it.getRemoteHardwarePins() }, + requestMatches = { it.get_node_remote_hardware_pins_request == true }, + ) + assertRpcTimeout( + call = { it.getDeviceConnectionStatus() }, + requestMatches = { it.get_device_connection_status_request == true }, + ) + } + + private fun TestScope.connectedClient(rpcTimeout: Duration = 60.seconds): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:admin-remaining"), + autoHandshake = true, + nodeNum = 0x11111111, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .rpcTimeout(rpcTimeout) + .sendTimeout(rpcTimeout) + .build() + return transport to client + } + + private suspend fun TestScope.assertAckSuccess( + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + ) { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + assertTrue(packet.want_ack) + assertEquals(false, packet.decoded?.want_response) + assertNotNull(adminOf(packet)) + + transport.injectRoutingAck(packet.id) + runCurrent() + + when (val result = deferred.await()) { + is AdminResult.Success -> Unit + else -> fail("Expected success but got $result") + } + } finally { + runCatching { client.disconnect() } + } + } + + private suspend fun TestScope.assertAckTimeout( + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + ) { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + assertTrue(packet.want_ack) + assertEquals(false, packet.decoded?.want_response) + assertNotNull(adminOf(packet)) + + advanceTimeBy(70.seconds) + runCurrent() + + assertEquals(AdminResult.Timeout, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + private suspend fun TestScope.assertRpcSuccess( + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + response: AdminMessage, + expected: T, + ) { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + assertFalse(packet.want_ack) + assertEquals(true, packet.decoded?.want_response) + assertNotNull(adminOf(packet)) + + transport.injectAdminResponse(packet.id, response) + runCurrent() + + when (val result = deferred.await()) { + is AdminResult.Success -> assertEquals(expected, result.value) + else -> fail("Expected success but got $result") + } + } finally { + runCatching { client.disconnect() } + } + } + + private suspend fun TestScope.assertRpcTimeout( + call: suspend (AdminApi) -> AdminResult<*>, + requestMatches: (AdminMessage) -> Boolean, + ) { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + assertFalse(packet.want_ack) + assertEquals(true, packet.decoded?.want_response) + assertNotNull(adminOf(packet)) + + advanceTimeBy(70.seconds) + runCurrent() + + assertEquals(AdminResult.Timeout, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + private suspend fun TestScope.assertFireAndForgetSuccess( + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + ) { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + assertFalse(packet.want_ack) + assertEquals(false, packet.decoded?.want_response) + assertNotNull(adminOf(packet)) + + advanceTimeBy(70.seconds) + runCurrent() + + when (val result = deferred.await()) { + is AdminResult.Success -> Unit + else -> fail("Expected success but got $result") + } + } finally { + runCatching { client.disconnect() } + } + } + + private fun latestAdminPacket( + transport: FakeRadioTransport, + outboundBefore: Int, + predicate: (AdminMessage) -> Boolean, + ): MeshPacket = transport.outboundPackets().drop(outboundBefore) + .last { packet -> adminOf(packet)?.let(predicate) == true } + + private fun adminOf(packet: MeshPacket): AdminMessage? { + val decoded = packet.decoded ?: return null + if (decoded.portnum != PortNum.ADMIN_APP) return null + return runCatching { AdminMessage.ADAPTER.decode(decoded.payload) }.getOrNull() + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt new file mode 100644 index 0000000..49a2af0 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt @@ -0,0 +1,415 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.ToRadio +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.fail + +@OptIn(ExperimentalCoroutinesApi::class) +class RadioClientSendTest { + + @Test + fun sendMeshPacket_connectedClientReturnsHandleAndWritesPacket() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val packet = MeshPacket( + to = TARGET_NODE.raw, + channel = SECONDARY_CHANNEL.raw, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = "mesh-packet".encodeToByteArray().toByteString(), + ), + ) + + val handle = client.send(packet) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TEST_NODE_NUM, outbound.from, "outbound.from") + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(SECONDARY_CHANNEL.raw, outbound.channel, "outbound.channel") + assertValue(PortNum.TEXT_MESSAGE_APP, decoded.portnum, "decoded.portnum") + assertContentEquals("mesh-packet".encodeToByteArray(), decoded.payload.toByteArray()) + } + } + + @Test + fun sendMeshPacket_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.send( + MeshPacket( + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = "offline".encodeToByteArray().toByteString(), + ), + ), + ) + } + } + + @Test + fun sendMeshPacket_payloadTooLargeThrows() = runTest { + withConnectedClient { client, _ -> + val oversized = MeshPacket( + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteArray(DATA_PAYLOAD_LEN + 1).toByteString(), + ), + ) + + assertFailsWith { + client.send(oversized) + } + } + } + + @Test + fun sendText_connectedClientEncodesUtf8AndTargetsChannel() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val text = "héllo 🚀" + + val handle = client.sendText(text, channel = SECONDARY_CHANNEL, to = TARGET_NODE) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(SECONDARY_CHANNEL.raw, outbound.channel, "outbound.channel") + assertValue(PortNum.TEXT_MESSAGE_APP, decoded.portnum, "decoded.portnum") + assertContentEquals(text.encodeToByteArray(), decoded.payload.toByteArray()) + } + } + + @Test + fun sendText_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.sendText("hello") + } + } + + @Test + fun sendText_payloadTooLargeThrows() = runTest { + withConnectedClient { client, _ -> + val oversized = "a".repeat(DATA_PAYLOAD_LEN + 1) + + assertFailsWith { + client.sendText(oversized) + } + } + } + + @Test + fun sendReaction_connectedClientMarksEmojiReplyAndAck() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val replyId = 0x01020304 + val emoji = "🔥" + + val handle = client.sendReaction( + emoji = emoji, + to = TARGET_NODE, + channel = SECONDARY_CHANNEL, + replyId = replyId, + ) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(SECONDARY_CHANNEL.raw, outbound.channel, "outbound.channel") + assertCondition(outbound.want_ack, "Expected reaction packet to request ACK") + assertValue(PortNum.TEXT_MESSAGE_APP, decoded.portnum, "decoded.portnum") + assertContentEquals(emoji.encodeToByteArray(), decoded.payload.toByteArray()) + assertValue(1, decoded.emoji, "decoded.emoji") + assertValue(replyId, decoded.reply_id, "decoded.reply_id") + } + } + + @Test + fun sendReaction_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.sendReaction(emoji = "👍", replyId = 42) + } + } + + @Test + fun sendReaction_payloadTooLargeThrows() = runTest { + withConnectedClient { client, _ -> + val oversized = "a".repeat(DATA_PAYLOAD_LEN + 1) + + assertFailsWith { + client.sendReaction(emoji = oversized, replyId = 7) + } + } + } + + @Test + fun sendByteArray_connectedClientBuildsTypedPacket() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val payload = byteArrayOf(0x01, 0x02, 0x03, 0x04) + + val handle = client.send( + portnum = PortNum.NODEINFO_APP, + payload = payload, + to = TARGET_NODE, + channel = SECONDARY_CHANNEL, + wantAck = true, + hopLimit = 4, + ) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(SECONDARY_CHANNEL.raw, outbound.channel, "outbound.channel") + assertCondition(outbound.want_ack, "Expected typed packet to request ACK") + assertValue(4, outbound.hop_limit, "outbound.hop_limit") + assertValue(PortNum.NODEINFO_APP, decoded.portnum, "decoded.portnum") + assertContentEquals(payload, decoded.payload.toByteArray()) + assertCondition(!decoded.want_response, "Expected typed send want_response=false") + } + } + + @Test + fun sendByteArray_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.send(PortNum.TEXT_MESSAGE_APP, "hello".encodeToByteArray()) + } + } + + @Test + fun sendByteArray_payloadTooLargeThrows() = runTest { + withConnectedClient { client, _ -> + assertFailsWith { + client.send(PortNum.TEXT_MESSAGE_APP, ByteArray(DATA_PAYLOAD_LEN + 1)) + } + } + } + + @Test + fun sendBuffer_connectedClientConsumesBufferAndWritesBytes() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val payload = byteArrayOf(0x0A, 0x0B, 0x0C) + val buffer = Buffer().apply { write(payload) } + + val handle = client.send( + portnum = PortNum.NODEINFO_APP, + payload = buffer, + to = TARGET_NODE, + channel = SECONDARY_CHANNEL, + wantAck = true, + hopLimit = 5, + ) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + assertContentEquals(byteArrayOf(), buffer.readByteArray()) + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(SECONDARY_CHANNEL.raw, outbound.channel, "outbound.channel") + assertCondition(outbound.want_ack, "Expected typed packet to request ACK") + assertValue(5, outbound.hop_limit, "outbound.hop_limit") + assertValue(PortNum.NODEINFO_APP, decoded.portnum, "decoded.portnum") + assertContentEquals(payload, decoded.payload.toByteArray()) + } + } + + @Test + fun sendBuffer_notConnectedThrows() = runTest { + val client = buildClient() + val buffer = Buffer().apply { write("hello".encodeToByteArray()) } + + assertFailsWith { + client.send(PortNum.TEXT_MESSAGE_APP, buffer) + } + } + + @Test + fun sendBuffer_payloadTooLargeThrows() = runTest { + withConnectedClient { client, _ -> + val buffer = Buffer().apply { write(ByteArray(DATA_PAYLOAD_LEN + 1)) } + + assertFailsWith { + client.send(PortNum.TEXT_MESSAGE_APP, buffer) + } + } + } + + @Test + fun sendRaw_connectedClientWritesFrameDirectlyToTransport() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundFrames().size + val frame = ToRadio(disconnect = true) + + client.sendRaw(frame) + runCurrent() + + val outbound = transport.lastNewOutboundFrame(before).decodeToRadio() + assertValue(frame, outbound, "outbound ToRadio frame") + } + } + + @Test + fun sendRaw_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.sendRaw(ToRadio(disconnect = true)) + } + } + + @Test + fun requestNodeInfo_connectedClientReturnsHandleWithOutboundId() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + + val handle = client.requestNodeInfo(TARGET_NODE) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TEST_NODE_NUM, outbound.from, "outbound.from") + } + } + + @Test + fun requestNodeInfo_connectedClientSendsNodeInfoRequestPacket() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + + client.requestNodeInfo(TARGET_NODE) + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(ChannelIndex(0).raw, outbound.channel, "outbound.channel") + assertCondition(outbound.want_ack, "Expected node info request to request ACK") + assertValue(PortNum.NODEINFO_APP, decoded.portnum, "decoded.portnum") + assertCondition(decoded.want_response, "Expected node info request want_response=true") + assertContentEquals(byteArrayOf(), decoded.payload.toByteArray()) + } + } + + @Test + fun requestNodeInfo_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.requestNodeInfo(TARGET_NODE) + } + } + + private fun TestScope.buildClient(transport: FakeRadioTransport = buildTransport()): RadioClient = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .build() + + private suspend fun TestScope.withConnectedClient(block: suspend (RadioClient, FakeRadioTransport) -> Unit) { + val transport = buildTransport() + val client = buildClient(transport) + client.connect() + runCurrent() + assertValue(ConnectionState.Connected, client.connection.value, "client.connection") + try { + block(client, transport) + } finally { + client.disconnect() + runCurrent() + } + } + + private fun buildTransport(): FakeRadioTransport = FakeRadioTransport( + identity = TransportIdentity("fake:test"), + autoHandshake = true, + nodeNum = TEST_NODE_NUM, + ) + + private fun FakeRadioTransport.lastNewOutboundPacket(before: Int): MeshPacket { + val outbound = outboundPackets() + assertValue(before + 1, outbound.size, "outboundPackets().size") + return outbound.last() + } + + private fun FakeRadioTransport.lastNewOutboundFrame(before: Int): Frame { + val outbound = outboundFrames() + assertValue(before + 1, outbound.size, "outboundFrames().size") + return outbound.last() + } + + private fun Frame.decodeToRadio(): ToRadio { + val bytes = bytes.toByteArray() + if (bytes.size < 4) fail("Expected framed ToRadio bytes") + return runCatching { ToRadio.ADAPTER.decode(bytes.copyOfRange(4, bytes.size)) } + .getOrElse { throw AssertionError("Failed to decode ToRadio frame", it) } + } + + private fun MeshPacket.requireDecoded(): Data = decoded ?: fail("Expected decoded payload") + + private fun assertCondition(condition: Boolean, message: String) { + if (!condition) fail(message) + } + + private fun assertValue(expected: T, actual: T, label: String) { + if (expected != actual) { + fail("Expected $label=$expected, actual=$actual") + } + } + + private companion object { + const val TEST_NODE_NUM: Int = 0x11111111 + val TARGET_NODE: NodeId = NodeId(0x22222222) + val SECONDARY_CHANNEL: ChannelIndex = ChannelIndex(2) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt new file mode 100644 index 0000000..b6a8959 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt @@ -0,0 +1,325 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class StoreForwardApiStatsTest { + + @Test + fun `requestStats returns statistics from server`() = runTest { + val (transport, client) = connectedClient("stats-response") + client.connect() + runCurrent() + + val server = NodeId(0xABCD0001.toInt()) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.storeForward.requestStats(server) } + runCurrent() + + val request = transport.lastStoreForwardRequest(outboundBefore) + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(server.raw, request.to) + assertEquals(StoreAndForward.RequestResponse.CLIENT_STATS, payload.rr) + + transport.injectStatsResponse( + requestId = request.id, + server = server, + stats = StoreAndForward.Statistics( + messages_saved = 9, + messages_max = 64, + up_time = 3600, + requests = 12, + requests_history = 7, + heartbeat = true, + ), + ) + runCurrent() + + val result = assertIs>(deferred.await()) + assertEquals(9, result.value.messagesStored) + assertEquals(64, result.value.messagesMax) + assertEquals(3600, result.value.uptime) + assertEquals(7, result.value.requests) + assertEquals(0, result.value.requestsFailed) + assertEquals(true, result.value.heartbeat) + client.disconnect() + } + + @Test + fun `requestStats with null server uses first discovered server`() = runTest { + val (transport, client) = connectedClient("stats-default-server") + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val server = NodeId(0xABCD0001.toInt()) + transport.injectHeartbeat(server) + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestStats() } + runCurrent() + + val request = transport.lastStoreForwardRequest(outboundBefore) + assertEquals(server.raw, request.to) + + transport.injectStatsResponse(request.id, server) + runCurrent() + + assertIs>(deferred.await()) + client.disconnect() + } + + @Test + fun `requestStats throws on timeout`() = runTest { + val (transport, client) = connectedClient("stats-timeout", rpcTimeout = 5.seconds) + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val server = NodeId(0xABCD0001.toInt()) + transport.injectHeartbeat(server) + runCurrent() + + val deferred = async { storeForward.requestStats() } + runCurrent() + + advanceTimeBy(5.seconds) + runCurrent() + + assertEquals(AdminResult.Timeout, deferred.await()) + client.disconnect() + } + + @Test + fun `requestStats with explicit server sends to that node`() = runTest { + val (transport, client) = connectedClient("stats-explicit-server") + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val firstServer = NodeId(0xABCD0001.toInt()) + val targetServer = NodeId(0xABCD0002.toInt()) + transport.injectHeartbeat(firstServer) + transport.injectHeartbeat(targetServer) + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestStats(targetServer) } + runCurrent() + + val request = transport.lastStoreForwardRequest(outboundBefore) + assertEquals(targetServer.raw, request.to) + + transport.injectStatsResponse(request.id, targetServer) + runCurrent() + + assertIs>(deferred.await()) + client.disconnect() + } + + @Test + fun `requestStats throws when no servers known and server is null`() = runTest { + val (_, client) = connectedClient("stats-missing-server") + client.connect() + runCurrent() + + assertEquals(AdminResult.NodeUnreachable, client.storeForward.requestStats()) + client.disconnect() + } + + @Test + fun `server discovery via heartbeat adds to servers list`() = runTest { + val (transport, client) = connectedClient("server-discovery") + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val server = NodeId(0xABCD0001.toInt()) + transport.injectHeartbeat(server) + runCurrent() + + assertEquals(listOf(server), storeForward.servers.value) + client.disconnect() + } + + @Test + fun `multiple server heartbeats accumulate`() = runTest { + val (transport, client) = connectedClient("server-accumulation") + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val firstServer = NodeId(0xABCD0001.toInt()) + val secondServer = NodeId(0xABCD0002.toInt()) + transport.injectHeartbeat(firstServer) + transport.injectHeartbeat(secondServer) + runCurrent() + + assertEquals(listOf(firstServer, secondServer), storeForward.servers.value) + client.disconnect() + } + + @Test + fun `server loss removes from servers list`() = runTest { + val (transport, client) = connectedClient("server-loss", presenceTimeout = 5.seconds) + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val server = NodeId(0xABCD0001.toInt()) + transport.injectHeartbeat(server) + runCurrent() + assertEquals(listOf(server), storeForward.servers.value) + + advanceTimeBy(31.seconds) + runCurrent() + + assertTrue(storeForward.servers.value.isEmpty()) + client.disconnect() + } + + @Test + fun `servers initially empty`() = runTest { + val (_, client) = connectedClient("servers-empty") + client.connect() + runCurrent() + + assertTrue(client.storeForward.servers.value.isEmpty()) + client.disconnect() + } + + @Test + fun `ServerDiscovered event emitted on first heartbeat`() = runTest { + val (transport, client) = connectedClient("event-discovered") + client.connect() + runCurrent() + + val server = NodeId(0xABCD0001.toInt()) + val event = async { + client.storeForward.events.first { it is StoreForwardEvent.ServerDiscovered } + } + runCurrent() + + transport.injectHeartbeat(server) + runCurrent() + + assertEquals(StoreForwardEvent.ServerDiscovered(server), event.await()) + client.disconnect() + } + + @Test + fun `ServerLost event emitted when server times out`() = runTest { + val (transport, client) = connectedClient("event-lost", presenceTimeout = 5.seconds) + client.connect() + runCurrent() + + val server = NodeId(0xABCD0001.toInt()) + val event = async { + client.storeForward.events.first { it is StoreForwardEvent.ServerLost } + } + runCurrent() + + transport.injectHeartbeat(server) + runCurrent() + advanceTimeBy(31.seconds) + runCurrent() + + assertEquals(StoreForwardEvent.ServerLost(server), event.await()) + client.disconnect() + } + + private fun TestScope.connectedClient( + identitySuffix: String, + presenceTimeout: Duration = 5.seconds, + rpcTimeout: Duration = 60.seconds, + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:sf-$identitySuffix"), + autoHandshake = true, + nodeNum = 0x11111111, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .clock(SchedulerClock { currentTime }) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .presenceTimeout(presenceTimeout) + .rpcTimeout(rpcTimeout) + .build() + return transport to client + } + + private fun FakeRadioTransport.injectHeartbeat(server: NodeId, period: Int = 900) { + injectStoreForwardResponse( + requestId = 0, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = period, secondary = 0), + ), + fromNode = server.raw, + ) + } + + private fun FakeRadioTransport.injectStatsResponse( + requestId: Int, + server: NodeId, + stats: StoreAndForward.Statistics = StoreAndForward.Statistics( + messages_saved = 1, + messages_max = 2, + up_time = 3, + requests_history = 4, + heartbeat = true, + ), + ) { + injectStoreForwardResponse( + requestId = requestId, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_STATS, + stats = stats, + ), + fromNode = server.raw, + ) + } + + private fun FakeRadioTransport.lastStoreForwardRequest(outboundBefore: Int) = + outboundPackets() + .drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + + private class SchedulerClock(private val nowMs: () -> Long) : kotlin.time.Clock { + override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs()) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiObserveTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiObserveTest.kt new file mode 100644 index 0000000..cd1b1db --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiObserveTest.kt @@ -0,0 +1,315 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.PowerMetrics +import org.meshtastic.proto.Telemetry +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class TelemetryApiObserveTest { + + @Test + fun `observe emits telemetry from specified node`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(0x22222222) + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 85)) + val received = backgroundScope.async { client.telemetry.observe(node).first() } + runCurrent() + + transport.injectTelemetryResponse(requestId = 0, telemetry = expected, fromNode = node.raw) + runCurrent() + + val actual = received.await() + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe filters out telemetry from other nodes`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val observedNode = NodeId(0x22222222) + val otherNode = 0x33333333 + val collected = mutableListOf() + val collector = backgroundScope.launch { + client.telemetry.observe(observedNode).collect { collected += it } + } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 42)), + fromNode = otherNode, + ) + runCurrent() + + assertEquals(0, collected.size) + collector.cancelAndJoin() + client.disconnect() + } + + @Test + fun `observe with LOCAL emits from all nodes`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expected = listOf( + Telemetry(device_metrics = DeviceMetrics(battery_level = 81)), + Telemetry(environment_metrics = EnvironmentMetrics(temperature = 21.5f)), + Telemetry(power_metrics = PowerMetrics(ch1_voltage = 4.2f, ch1_current = 0.48f)), + ) + val fromNodes = listOf(0x22222222, 0x33333333, 0x44444444) + val received = backgroundScope.async { + client.telemetry.observe(NodeId.LOCAL).take(expected.size).toList() + } + runCurrent() + + expected.zip(fromNodes).forEach { (telemetry, fromNode) -> + transport.injectTelemetryResponse(requestId = 0, telemetry = telemetry, fromNode = fromNode) + } + runCurrent() + + val actual = received.await() + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe emits device metrics`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expected = DeviceMetrics(battery_level = 90, voltage = 4.1f, uptime_seconds = 600) + val received = backgroundScope.async { client.telemetry.observe(NodeId.LOCAL).first() } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(device_metrics = expected), + fromNode = 0x22222222, + ) + runCurrent() + + val actual = received.await().device_metrics + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe emits environment metrics`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expected = EnvironmentMetrics( + temperature = 23.4f, + relative_humidity = 56.0f, + barometric_pressure = 1008.7f, + ) + val received = backgroundScope.async { client.telemetry.observe(NodeId.LOCAL).first() } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(environment_metrics = expected), + fromNode = 0x33333333, + ) + runCurrent() + + val actual = received.await().environment_metrics + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe emits power metrics`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expected = PowerMetrics(ch1_voltage = 4.18f, ch1_current = 0.42f, ch2_voltage = 5.0f) + val received = backgroundScope.async { client.telemetry.observe(NodeId.LOCAL).first() } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(power_metrics = expected), + fromNode = 0x44444444, + ) + runCurrent() + + val actual = received.await().power_metrics + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `multiple concurrent observers receive same data`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(0x55555555) + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 73)) + val first = backgroundScope.async { client.telemetry.observe(node).first() } + val second = backgroundScope.async { client.telemetry.observe(node).first() } + runCurrent() + + transport.injectTelemetryResponse(requestId = 0, telemetry = expected, fromNode = node.raw) + runCurrent() + + val firstActual = first.await() + val secondActual = second.await() + assertEquals(expected, firstActual) + assertEquals(expected, secondActual) + client.disconnect() + } + + @Test + fun `cancelling observer does not affect other observers`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(0x66666666) + val cancelledCollectorValues = mutableListOf() + val cancelledCollector = backgroundScope.launch { + client.telemetry.observe(node).collect { cancelledCollectorValues += it } + } + val survivingCollector = backgroundScope.async { client.telemetry.observe(node).first() } + runCurrent() + + cancelledCollector.cancelAndJoin() + + val expected = Telemetry(environment_metrics = EnvironmentMetrics(temperature = 18.2f)) + transport.injectTelemetryResponse(requestId = 0, telemetry = expected, fromNode = node.raw) + runCurrent() + + val actual = survivingCollector.await() + assertEquals(0, cancelledCollectorValues.size) + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe after disconnect emits nothing`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + client.disconnect() + + val collected = mutableListOf() + val collector = backgroundScope.launch { + client.telemetry.observe(NodeId.LOCAL).collect { collected += it } + } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 1)), + fromNode = 0x22222222, + ) + runCurrent() + + assertEquals(0, collected.size) + collector.cancelAndJoin() + } + + @Test + fun `rapid telemetry packets all emitted`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(0x77777777) + val expected = (1..10).map { level -> + Telemetry(device_metrics = DeviceMetrics(battery_level = level)) + } + val received = backgroundScope.async { + client.telemetry.observe(node).take(expected.size).toList() + } + runCurrent() + + expected.forEach { telemetry -> + transport.injectTelemetryResponse(requestId = 0, telemetry = telemetry, fromNode = node.raw) + } + runCurrent() + + val actual = received.await() + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe is cold and does not replay earlier packets`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(0x22222222) + val beforeSubscription = Telemetry(device_metrics = DeviceMetrics(battery_level = 10)) + transport.injectTelemetryResponse(requestId = 0, telemetry = beforeSubscription, fromNode = node.raw) + runCurrent() + + val collected = mutableListOf() + val collector = backgroundScope.launch { + client.telemetry.observe(node).collect { collected += it } + } + runCurrent() + + assertEquals(0, collected.size) + + val afterSubscription = Telemetry(device_metrics = DeviceMetrics(battery_level = 11)) + transport.injectTelemetryResponse(requestId = 0, telemetry = afterSubscription, fromNode = node.raw) + runCurrent() + + val actual = collected.toList() + assertEquals(listOf(afterSubscription), actual) + collector.cancelAndJoin() + client.disconnect() + } + + private fun TestScope.connectedClient(nodeNum: Int = 0x11111111): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:telemetry-observe"), + autoHandshake = true, + nodeNum = nodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .build() + return transport to client + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/WireCodecTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/WireCodecTest.kt index 8acb6ea..84cd503 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/WireCodecTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/WireCodecTest.kt @@ -8,6 +8,8 @@ package org.meshtastic.sdk import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio import org.meshtastic.proto.Heartbeat import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.ToRadio @@ -24,6 +26,56 @@ import kotlin.test.assertTrue */ class WireCodecTest { + private companion object { + private const val MAX_FRAME_SIZE = 512 + private val START1: Byte = 0x94.toByte() + private val START2: Byte = 0xC3.toByte() + } + + private fun encodeFromRadio(message: FromRadio): ByteArray { + val payload = FromRadio.ADAPTER.encode(message) + return ByteArray(4 + payload.size).apply { + this[0] = START1 + this[1] = START2 + this[2] = (payload.size shr 8).toByte() + this[3] = (payload.size and 0xFF).toByte() + payload.copyInto(this, destinationOffset = 4) + } + } + + private fun withRepeatedStart1s(frame: ByteArray, count: Int): ByteArray = + ByteArray(count) { START1 } + frame.copyOfRange(1, frame.size) + + private fun serializedSize(message: ToRadio): Int = ToRadio.ADAPTER.encode(message).size + + private fun toRadioWithSerializedSize(targetSize: Int): ToRadio { + val builders = listOf<(ByteArray) -> ToRadio>( + { payload -> ToRadio(packet = MeshPacket(decoded = Data(payload = payload.toByteString()))) }, + { payload -> ToRadio(packet = MeshPacket(channel = 1, decoded = Data(payload = payload.toByteString()))) }, + { payload -> ToRadio(packet = MeshPacket(to = 1, decoded = Data(payload = payload.toByteString()))) }, + { payload -> + ToRadio( + packet = MeshPacket( + from = 1, + to = 1, + channel = 1, + decoded = Data(payload = payload.toByteString()), + ), + ) + }, + ) + + for (payloadSize in 0..1024) { + val payload = ByteArray(payloadSize) { ((it * 31) and 0xFF).toByte() } + for (builder in builders) { + val message = builder(payload) + if (serializedSize(message) == targetSize) return message + } + } + + error("Could not construct ToRadio with serialized size $targetSize") + } + // ── Basic header / framing ──────────────────────────────────────────────── @Test @@ -163,6 +215,107 @@ class WireCodecTest { assertEquals(1, results.size, "After reset, decoder must handle a fresh valid frame") } + @Test + fun testExactMaxFrameSize() { + val exactMax = toRadioWithSerializedSize(MAX_FRAME_SIZE) + val frame = WireCodec.encodeToRadio(exactMax) + + assertEquals(MAX_FRAME_SIZE, serializedSize(exactMax)) + assertEquals(4 + MAX_FRAME_SIZE, frame.size) + assertEquals(0x02.toByte(), frame[2]) + assertEquals(0x00.toByte(), frame[3]) + + assertFailsWith { + WireCodec.encodeToRadio(toRadioWithSerializedSize(MAX_FRAME_SIZE + 1)) + } + } + + @Test + fun testResyncAfterCorruptionMidPayload() { + val decoder = WireCodec.FrameDecoder() + val partialFrame = encodeFromRadio(FromRadio(id = 1)).copyOfRange(0, 5) + val recoveredFrame = encodeFromRadio(FromRadio(id = 42)) + + assertTrue(decoder.feedBytes(partialFrame).isEmpty()) + assertTrue(decoder.feedBytes(byteArrayOf(0x80.toByte())).isEmpty()) + + val results = decoder.feedBytes(recoveredFrame) + assertEquals(listOf(FromRadio(id = 42)), results) + } + + @Test + fun testMultipleConsecutiveStart1Bytes() { + val frame = withRepeatedStart1s(encodeFromRadio(FromRadio(id = 7)), count = 5) + + val results = WireCodec.FrameDecoder().feedBytes(frame) + + assertEquals(listOf(FromRadio(id = 7)), results) + } + + @Test + fun testBackToBackZeroLengthFrames() { + val zeroFrame = byteArrayOf(START1, START2, 0x00, 0x00) + + val results = WireCodec.FrameDecoder().feedBytes(zeroFrame + zeroFrame + zeroFrame) + + assertEquals(3, results.size) + assertTrue(results.all { it == FromRadio() }) + } + + @Test + fun testFeedBytesReturnsCorrectCountForMixedValidInvalid() { + val valid1 = encodeFromRadio(FromRadio(id = 1)) + val valid2 = encodeFromRadio(FromRadio(id = 2)) + val zeroFrame = byteArrayOf(START1, START2, 0x00, 0x00) + val garbage = byteArrayOf(0x00, 0x7F, 0x01, 0x55) + val malformedFrame = byteArrayOf(START1, START2, 0x00, 0x02, 0x08, 0x80.toByte()) + val truncatedFrame = encodeFromRadio(FromRadio(id = 9)).copyOfRange(0, 5) + + val results = WireCodec.FrameDecoder().feedBytes( + valid1 + garbage + malformedFrame + valid2 + zeroFrame + truncatedFrame, + ) + + assertEquals(listOf(FromRadio(id = 1), FromRadio(id = 2), FromRadio()), results) + } + + @Test + fun testPartialFrameThenValidFrameNoReset() { + val decoder = WireCodec.FrameDecoder() + val partialFrame = encodeFromRadio(FromRadio(id = 1)).copyOfRange(0, 5) + val recoveredFrame = withRepeatedStart1s(encodeFromRadio(FromRadio(id = 77)), count = 5) + + assertTrue(decoder.feedBytes(partialFrame).isEmpty()) + + val results = decoder.feedBytes(recoveredFrame) + + assertEquals(listOf(FromRadio(id = 77)), results) + } + + @Test + fun testStart1AppearingAsPayloadByte() { + val message = FromRadio( + packet = MeshPacket( + decoded = Data(payload = byteArrayOf(START1).toByteString()), + ), + ) + val payload = FromRadio.ADAPTER.encode(message) + + assertTrue(payload.contains(START1)) + assertEquals(listOf(message), WireCodec.FrameDecoder().feedBytes(encodeFromRadio(message))) + } + + @Test + fun testLargePayloadNearBoundary() { + val frame511 = WireCodec.encodeToRadio(toRadioWithSerializedSize(MAX_FRAME_SIZE - 1)) + val frame512 = WireCodec.encodeToRadio(toRadioWithSerializedSize(MAX_FRAME_SIZE)) + + assertEquals(4 + MAX_FRAME_SIZE - 1, frame511.size) + assertEquals(4 + MAX_FRAME_SIZE, frame512.size) + assertFailsWith { + WireCodec.encodeToRadio(toRadioWithSerializedSize(MAX_FRAME_SIZE + 1)) + } + } + // ── Fuzz tests: random bytes must never crash the decoder ───────────────── @Test diff --git a/docs/api-reference.md b/docs/api-reference.md index 2ca0b43..9f75aab 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -8,7 +8,7 @@ | Package | Stability | Contents | |---|---|---| -| `org.meshtastic.sdk` | Public | `RadioClient`, `MessageHandle`, `SendState`, `SendFailure`, `SendOutcome`, `MeshEvent`, `DroppedFlow`, `NodeChange`, `NodeField`, `ConnectionState`, `ConfigPhase`, `TransportSpec`, `TransportIdentity`, `RadioTransport`, `TransportState`, `Frame`, `MeshtasticException`, `NodeId`, `ChannelIndex`, `MessageId`, `LogSink`, `LogLevel`, `PayloadRedactor`, `StorageProvider`, `DeviceStorage`, `ConfigBundle`, `KeyVerificationPrompt`, `AdminApi`, `AdminResult`, `TelemetryApi`, `RoutingApi`, `Clock`, `Constants`, `SessionPasskey` | +| `org.meshtastic.sdk` | Public | `RadioClient`, `MessageHandle`, `SendState`, `SendFailure`, `SendOutcome`, `MeshEvent`, `DroppedFlow`, `NodeChange`, `NodeField`, `ConnectionState`, `ConfigPhase`, `TransportSpec`, `TransportIdentity`, `RadioTransport`, `TransportState`, `Frame`, `MeshtasticException`, `NodeId`, `ChannelIndex`, `MessageId`, `LogSink`, `LogLevel`, `PayloadRedactor`, `StorageProvider`, `DeviceStorage`, `ConfigBundle`, `KeyVerificationPrompt`, `AdminApi`, `AdminResult`, `TelemetryApi`, `RoutingApi`, `StoreForwardApi`, `StoreForwardStats`, `StoreForwardEvent`, `Clock`, `Constants`, `SessionPasskey` | | `org.meshtastic.sdk.transport.tcp` | Public | `TcpTransport` | | `org.meshtastic.sdk.transport.ble` | Public | `BleTransport` | | `org.meshtastic.sdk.transport.serial` | Public | `AndroidSerialPorts` (Android), `JvmSerialPorts` (JVM) | @@ -73,11 +73,13 @@ RadioClient.Builder() |---|---|---| | `send(packet: MeshPacket): MessageHandle` | handle (state already `Queued`) | `NotConnected`, `PayloadTooLarge` | | `sendText(text: String, channel: ChannelIndex = ChannelIndex(0), to: NodeId = NodeId.BROADCAST): MessageHandle` | handle | same as `send` | +| `sendReaction(emoji: String, to: NodeId = NodeId.BROADCAST, channel: ChannelIndex = ChannelIndex(0), replyId: Int): MessageHandle` | handle | same as `send` | +| `sendRaw(frame: ToRadio)` | `Unit` | `NotConnected` | | `nodeSnapshot(): Map` | snapshot | `NotConnected` | ### Sub-API namespaces -`client.admin: AdminApi`, `client.telemetry: TelemetryApi`, `client.routing: RoutingApi` are fully implemented and available while the client is in the `Connected` state. Each is lazily initialized on first access. See the respective interface definitions below for method inventories. +`client.admin: AdminApi`, `client.telemetry: TelemetryApi`, `client.routing: RoutingApi`, `client.storeForward: StoreForwardApi` are fully implemented and available while the client is in the `Connected` state. Each is lazily initialized on first access. See the respective interface definitions below for method inventories. ## `MessageHandle` @@ -163,6 +165,8 @@ public sealed interface NodeChange { public data class Added(val node: NodeInfo) : NodeChange public data class Updated(val node: NodeInfo, val changed: Set) : NodeChange public data class Removed(val nodeId: NodeId) : NodeChange + public data class WentOffline(val nodeId: NodeId, val lastHeard: Int) : NodeChange + public data class CameOnline(val nodeId: NodeId) : NodeChange } public enum class NodeField { @@ -175,6 +179,10 @@ Contract: - Deltas MUST NOT drop (`SUSPEND` overflow on the backing flow). - `Updated.changed` is the *minimal* set of fields whose value differs from the prior `NodeInfo`. Useful for diffing UI state without re-rendering everything. +### Presence tracking *(since 0.2.0)* + +`WentOffline` and `CameOnline` are emitted by the engine when presence tracking is enabled via `Builder.presenceTimeout()`. `WentOffline.lastHeard` is the node's most recent `last_heard` epoch, allowing the UI to display "last seen" timestamps. A node transitions back to online when it sends any new packet. + ## `ConnectionState` ```kotlin @@ -285,6 +293,9 @@ Each method maps onto a single `AdminMessage` round-trip with the device. Setter ```kotlin public interface AdminApi { + /** Returns a copy of this AdminApi that targets a remote node. All subsequent calls route to [dest]. @since 0.2.0 */ + public fun forNode(dest: NodeId): AdminApi + public suspend fun getConfig(type: AdminMessage.ConfigType): AdminResult public suspend fun setConfig(config: Config): AdminResult public suspend fun getModuleConfig(type: AdminMessage.ModuleConfigType): AdminResult @@ -317,12 +328,13 @@ public sealed interface AdminResult { public data object SessionKeyExpired : AdminResult public data object Unauthorized : AdminResult public data object Timeout : AdminResult + public data object RateLimited : AdminResult public data object NodeUnreachable : AdminResult public data class Failed(val routingError: Routing.Error) : AdminResult } ``` -`SessionKeyExpired` triggers an automatic single retry inside the engine: the engine re-issues `get_owner_request` to refresh `session_passkey`, then replays the original admin call once. If the retry also returns `SessionKeyExpired`, the result surfaces unmodified. +`SessionKeyExpired` triggers an automatic single retry inside the engine: the engine re-issues `get_owner_request` to refresh `session_passkey`, then replays the original admin call once. If the retry also returns `SessionKeyExpired`, the result surfaces unmodified. `RateLimited` indicates the device rejected the call due to rate limiting (`Routing.Error.RATE_LIMIT_EXCEEDED`); callers should back off before retrying. ## `TelemetryApi` *(Phase 2)* @@ -350,6 +362,86 @@ public interface RoutingApi { } ``` +## `StoreForwardApi` *(since 0.2.0)* + +API for interacting with Store-and-Forward (S&F) nodes on the mesh. S&F nodes temporarily store messages for offline nodes and deliver them when the target comes back online. Access via `client.storeForward`. + +```kotlin +public interface StoreForwardApi { + /** Known S&F server nodes on the mesh. Updated reactively when nodes advertise capability. */ + public val servers: StateFlow> + + /** Request delivery of stored messages since [since] (epoch seconds). */ + public suspend fun requestHistory(since: Int? = null, server: NodeId? = null): AdminResult + + /** Query statistics from a S&F server. */ + public suspend fun requestStats(server: NodeId? = null): AdminResult + + /** Flow of S&F-specific events (heartbeats, replays, SFPP link/canon). */ + public val events: Flow +} + +public data class StoreForwardStats( + val messagesStored: Int, val messagesMax: Int, val uptime: Int, + val requests: Int, val requestsFailed: Int, val heartbeat: Boolean, +) + +public sealed interface StoreForwardEvent { + public data class ServerDiscovered(val nodeId: NodeId) : StoreForwardEvent + public data class ServerLost(val nodeId: NodeId) : StoreForwardEvent + public data class HistoryReplayStarted(val server: NodeId, val messageCount: Int) : StoreForwardEvent + public data class HistoryReplayComplete(val server: NodeId, val delivered: Int) : StoreForwardEvent + public data class Heartbeat(val server: NodeId) : StoreForwardEvent + public data class SfppLinkProvided(val packetId: Int, val from: Int, val to: Int, val messageHash: ByteArray?, val confirmed: Boolean) : StoreForwardEvent + public data class SfppCanonAnnounced(val messageHash: ByteArray, val rxTime: Long) : StoreForwardEvent +} +``` + +## Config Builder Extensions *(since 0.2.0)* + +Extension functions on `AdminApi` that build and send config protos in a single call, using Kotlin `copy {}` semantics: + +```kotlin +// Config types (8 extensions) +suspend fun AdminApi.setDeviceConfig(block: Config.DeviceConfig.() -> Config.DeviceConfig): AdminResult +suspend fun AdminApi.setPositionConfig(block: Config.PositionConfig.() -> Config.PositionConfig): AdminResult +suspend fun AdminApi.setPowerConfig(block: Config.PowerConfig.() -> Config.PowerConfig): AdminResult +suspend fun AdminApi.setNetworkConfig(block: Config.NetworkConfig.() -> Config.NetworkConfig): AdminResult +suspend fun AdminApi.setDisplayConfig(block: Config.DisplayConfig.() -> Config.DisplayConfig): AdminResult +suspend fun AdminApi.setLoraConfig(block: Config.LoRaConfig.() -> Config.LoRaConfig): AdminResult +suspend fun AdminApi.setBluetoothConfig(block: Config.BluetoothConfig.() -> Config.BluetoothConfig): AdminResult +suspend fun AdminApi.setSecurityConfig(block: Config.SecurityConfig.() -> Config.SecurityConfig): AdminResult + +// Module config types (15 extensions) +suspend fun AdminApi.setMqttConfig(block: ModuleConfig.MQTTConfig.() -> ModuleConfig.MQTTConfig): AdminResult +suspend fun AdminApi.setSerialConfig(block: ModuleConfig.SerialConfig.() -> ModuleConfig.SerialConfig): AdminResult +suspend fun AdminApi.setExternalNotificationConfig(block: ModuleConfig.ExternalNotificationConfig.() -> ModuleConfig.ExternalNotificationConfig): AdminResult +suspend fun AdminApi.setStoreForwardConfig(block: ModuleConfig.StoreForwardConfig.() -> ModuleConfig.StoreForwardConfig): AdminResult +suspend fun AdminApi.setRangeTestConfig(block: ModuleConfig.RangeTestConfig.() -> ModuleConfig.RangeTestConfig): AdminResult +suspend fun AdminApi.setTelemetryConfig(block: ModuleConfig.TelemetryConfig.() -> ModuleConfig.TelemetryConfig): AdminResult +suspend fun AdminApi.setCannedMessageConfig(block: ModuleConfig.CannedMessageConfig.() -> ModuleConfig.CannedMessageConfig): AdminResult +suspend fun AdminApi.setAudioConfig(block: ModuleConfig.AudioConfig.() -> ModuleConfig.AudioConfig): AdminResult +suspend fun AdminApi.setRemoteHardwareConfig(block: ModuleConfig.RemoteHardwareConfig.() -> ModuleConfig.RemoteHardwareConfig): AdminResult +suspend fun AdminApi.setNeighborInfoConfig(block: ModuleConfig.NeighborInfoConfig.() -> ModuleConfig.NeighborInfoConfig): AdminResult +suspend fun AdminApi.setAmbientLightingConfig(block: ModuleConfig.AmbientLightingConfig.() -> ModuleConfig.AmbientLightingConfig): AdminResult +suspend fun AdminApi.setDetectionSensorConfig(block: ModuleConfig.DetectionSensorConfig.() -> ModuleConfig.DetectionSensorConfig): AdminResult +suspend fun AdminApi.setPaxcounterConfig(block: ModuleConfig.PaxcounterConfig.() -> ModuleConfig.PaxcounterConfig): AdminResult +suspend fun AdminApi.setStatusMessageConfig(block: ModuleConfig.StatusMessageConfig.() -> ModuleConfig.StatusMessageConfig): AdminResult +suspend fun AdminApi.setTrafficManagementConfig(block: ModuleConfig.TrafficManagementConfig.() -> ModuleConfig.TrafficManagementConfig): AdminResult +``` + +Usage: +```kotlin +client.admin.setLoraConfig { copy(region = Config.LoRaConfig.RegionCode.US) } +client.admin.setMqttConfig { copy(enabled = true, address = "mqtt.example.com") } + +// Combined with editSettings for atomic batching: +client.admin.editSettings { + setLoraConfig { copy(region = Config.LoRaConfig.RegionCode.US) } + setMqttConfig { copy(enabled = true) } +} +``` + ## Storage ```kotlin diff --git a/docs/architecture/meshtastic-android-migration.md b/docs/architecture/meshtastic-android-migration.md index c864878..e8b7480 100644 --- a/docs/architecture/meshtastic-android-migration.md +++ b/docs/architecture/meshtastic-android-migration.md @@ -203,6 +203,8 @@ val nodes: StateFlow> = combine( is NodeChange.Added -> acc + (change.node.num to change.node) is NodeChange.Updated -> acc + (change.node.num to change.node) is NodeChange.Removed -> acc - change.nodeId + is NodeChange.WentOffline, + is NodeChange.CameOnline -> acc // presence events don't change the map } } .flowOn(Dispatchers.Default), // ← required; SDK emits off-main @@ -243,14 +245,16 @@ when (sendState) { ### 7.3 Admin, Config & Error Handling The SDK provides strongly-typed outcomes. ViewModels must handle them idiomatically. ```kotlin -// AdminResult must be a sealed class in commonMain for exhaustive when to compile without else. -// If it is an interface or open class, add an else branch. viewModelScope.launch { when (val result = client.admin.reboot()) { is AdminResult.Success -> uiState.value = "Rebooting..." is AdminResult.Timeout -> alertManager.show("Radio didn't respond in time") is AdminResult.Unauthorized -> alertManager.show("Invalid admin channel") + AdminResult.RateLimited -> alertManager.show("Rate-limited — try again shortly") // No catch block needed; routine errors are returned as sealed subtypes, not thrown. + AdminResult.SessionKeyExpired, + AdminResult.NodeUnreachable, + is AdminResult.Failed -> alertManager.show("Admin operation failed: $result") } } ``` diff --git a/docs/consumer-guides/reactive-lifecycle-management.md b/docs/consumer-guides/reactive-lifecycle-management.md index d0c76a1..cf31060 100644 --- a/docs/consumer-guides/reactive-lifecycle-management.md +++ b/docs/consumer-guides/reactive-lifecycle-management.md @@ -45,6 +45,8 @@ class MeshNodeListFragment : Fragment() { is NodeChange.Added -> adapter.addNode(change.node) is NodeChange.Updated -> adapter.updateNode(change.node) is NodeChange.Removed -> adapter.removeNode(change.nodeId) + is NodeChange.WentOffline -> adapter.setOffline(change.nodeId) + is NodeChange.CameOnline -> adapter.setOnline(change.nodeId) } } } diff --git a/docs/error-taxonomy.md b/docs/error-taxonomy.md index 8602d00..2483ca0 100644 --- a/docs/error-taxonomy.md +++ b/docs/error-taxonomy.md @@ -11,7 +11,7 @@ | **Handshake failure** (timeout in any stage, malformed envelope, firmware too old) | `throw MeshtasticException` from `connect()` | Connect fails synchronously. | | **Async device drop** (heartbeat liveness timeout, transport drop after `Connected`) | `connection: ConnectionState.Reconnecting(cause)` + `MeshEvent.TransportError("liveness timeout")` (engine watchdog, 2 × heartbeat) or `TransportError("TCP read timeout after 65000ms")` (stream-transport backstop) | Already past `connect()`; the right channel is the state flow. The engine watchdog (`MeshEngine.LIVENESS_TIMEOUT_MS`) is the primary detector; TCP adds its own read deadline so the pre-`Ready` window is also covered. | | **Mesh send outcome** (NAK, no route, max retransmit, duty cycle, send-time disconnect) | `MessageHandle.state -> Failed(SendFailure.X)` | Routine on a flaky mesh; not exceptional. | -| **Admin RPC outcome** (NAK, session-key expired, unauthorised, timeout) | `AdminResult.Error(...)` | Routine; handlers want exhaustive `when`. | +| **Admin RPC outcome** (NAK, session-key expired, unauthorised, rate-limited, timeout) | `AdminResult.*` sealed variants | Routine; handlers want exhaustive `when`. | | **Engine drop of an inbound flow** (subscriber too slow) | `MeshEvent.PacketsDropped(flow, count)` | Observable backpressure; never silent. | | **Storage failure mid-session** | `MeshEvent.ProtocolWarning(...)` + retry; second failure escalates to `MeshtasticException.StorageUnavailable` and triggers reconnect | Storage outages shouldn't kill an active session if recoverable. | @@ -88,7 +88,7 @@ The Wire-generated `Routing.Error` enum (from `meshtastic/protobufs:mesh.proto`) | `PKI_UNKNOWN_PUBKEY` | `Other(PKI_UNKNOWN_PUBKEY)` | | `ADMIN_BAD_SESSION_KEY` | `Other(ADMIN_BAD_SESSION_KEY)` (admin paths intercept; see below) | | `ADMIN_PUBLIC_KEY_UNAUTHORIZED` | `Other(ADMIN_PUBLIC_KEY_UNAUTHORIZED)` (admin paths intercept) | -| `RATE_LIMIT_EXCEEDED` | `Other(RATE_LIMIT_EXCEEDED)` | +| `RATE_LIMIT_EXCEEDED` | `Other(RATE_LIMIT_EXCEEDED)` (admin paths intercept → `AdminResult.RateLimited`) | | (any new value the proto schema adds) | `Other(value)` — forward-compatible without a code change | `SendFailure.Unknown` is reserved for engine-internal anomalies (encoded `MeshPacket` with no decoded payload, etc.) and should never appear in production. @@ -101,6 +101,7 @@ public sealed interface AdminResult { public data object SessionKeyExpired : AdminResult // → automatic 1× retry inside engine public data object Unauthorized : AdminResult // NOT_AUTHORIZED / ADMIN_PUBLIC_KEY_UNAUTHORIZED public data object Timeout : AdminResult + public data object RateLimited : AdminResult // RATE_LIMIT_EXCEEDED — back off before retry public data object NodeUnreachable : AdminResult // remote-node admin: NO_ROUTE / MAX_RETRANSMIT public data class Failed(val routingError: Routing.Error) : AdminResult // anything else } @@ -116,6 +117,7 @@ Admin RPC paths intercept `Routing.Error` *before* it would map to a `SendFailur | `ADMIN_BAD_SESSION_KEY` | `SessionKeyExpired` (engine auto-retries once with refreshed `session_passkey`; if the retry also returns this, the result is forwarded) | | `NOT_AUTHORIZED`, `ADMIN_PUBLIC_KEY_UNAUTHORIZED` | `Unauthorized` | | `TIMEOUT` (or engine per-op timeout firing first) | `Timeout` | +| `RATE_LIMIT_EXCEEDED` | `RateLimited` | | `NO_ROUTE`, `MAX_RETRANSMIT`, `NO_INTERFACE` (for remote-node admin) | `NodeUnreachable` | | Anything else | `Failed(routingError)` — caller can switch on the raw enum | @@ -185,6 +187,7 @@ when (val r = client.admin.setConfig(c)) { is AdminResult.SessionKeyExpired -> { … } // very rare — engine already retried once is AdminResult.Unauthorized, AdminResult.Timeout, + AdminResult.RateLimited, AdminResult.NodeUnreachable, is AdminResult.Failed -> { … } } diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 3177db8..59b7aca 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -337,13 +337,40 @@ Want progress instead of the terminal outcome? Collect `handle.state` — it transitions `Queued → Sent → Acked|Delivered|Failed(reason)`. See [`api-reference.md` §SendState](./api-reference.md#sendstate-sendfailure-sendoutcome). -## 6. Logging and diagnostics +## 6. Admin operations and config builders + +```kotlin +// Read the device config +when (val result = client.admin.getConfig(AdminMessage.ConfigType.LORA_CONFIG)) { + is AdminResult.Success -> println("LoRa region: ${result.value.lora.region}") + AdminResult.Timeout -> println("timed out") + AdminResult.RateLimited -> println("rate-limited; try again later") + else -> println("failed: $result") +} + +// Write config using convenience builders (avoids manual proto construction): +client.admin.setLoraConfig { copy(region = Config.LoRaConfig.RegionCode.US) } + +// Batch multiple writes atomically: +client.admin.editSettings { + setLoraConfig { copy(region = Config.LoRaConfig.RegionCode.US) } + setMqttConfig { copy(enabled = true) } +} + +// Target a remote node: +val remote = client.admin.forNode(NodeId(0x12345678.toInt())) +remote.reboot() +``` + +See [`api-reference.md` §AdminApi](./api-reference.md#adminapi-phase-2) for the full method inventory, [`api-reference.md` §Config Builder Extensions](./api-reference.md#config-builder-extensions-since-020) for all 23 builder functions, and [`error-taxonomy.md`](./error-taxonomy.md) for `AdminResult` variant meanings. + +## 7. Logging and diagnostics By default the SDK is silent. Wire a `LogSink` at build time and, for deep debugging, opt into `protocolLogging` (with redaction). Full guide: [`observability.md`](./observability.md). -## 7. Testing your integration +## 8. Testing your integration ```kotlin // testImplementation("org.meshtastic:sdk-testing") @@ -360,7 +387,7 @@ val client = RadioClient.Builder() // See testing/Module.md and core/src/commonTest/ for working patterns. ``` -## 8. PKI direct messages (DMs) +## 9. PKI direct messages (DMs) Direct messages between two nodes are encrypted end-to-end with X25519 + AES-CTR keys derived from each peer's `User.public_key`. The SDK does not perform the crypto itself — the firmware does — but it does surface everything you need to verify peers: @@ -370,7 +397,7 @@ Direct messages between two nodes are encrypted end-to-end with X25519 + AES-CTR Sending DMs is the same as any other text message — call `client.sendText(...)` with a non-broadcast `to` — but you should refuse the call if the destination's last-seen `public_key` does not match what the user previously verified. The engine will not reject the send for you. -## 9. MQTT proxy mode (transparent) +## 10. MQTT proxy mode (transparent) Some devices participate in a regional MQTT mesh. When the firmware has MQTT enabled, inbound packets that originated over MQTT arrive with `MeshPacket.via_mqtt = true` and outbound packets you send may be re-broadcast to the MQTT topic by the device itself. Both sides are transparent to the SDK: there is no `transport-mqtt` artifact (R-11 in [`roadmap.md`](./roadmap.md)) and you do not need to configure anything beyond the device. See [`protocol.md` §14](./protocol.md#14-mqtt-proxy-mode) for the wire details and the topic naming convention; `via_mqtt` is the only signal the SDK exposes today. diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConnectCmds.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConnectCmds.kt index 96f1ae3..903dedc 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConnectCmds.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConnectCmds.kt @@ -136,6 +136,23 @@ internal class NodesCmd : BaseCommand(name = "nodes") { put("num", change.nodeId.raw) } } + + is NodeChange.WentOffline -> { + out.human("⊘ offline 0x" + change.nodeId.raw.toUInt().toString(16)) + out.emit("node") { + put("op", "offline") + put("num", change.nodeId.raw) + put("last_heard", change.lastHeard) + } + } + + is NodeChange.CameOnline -> { + out.human("● online 0x" + change.nodeId.raw.toUInt().toString(16)) + out.emit("node") { + put("op", "online") + put("num", change.nodeId.raw) + } + } } } "disconnect" diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/TuiCmd.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/TuiCmd.kt index 847c008..df57635 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/TuiCmd.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/TuiCmd.kt @@ -159,6 +159,12 @@ private suspend fun wireUpDashboard( nodes.remove(change.nodeId) appendLog("- node 0x" + change.nodeId.raw.toUInt().toString(16)) } + + is NodeChange.WentOffline -> + appendLog("⊘ offline 0x" + change.nodeId.raw.toUInt().toString(16)) + + is NodeChange.CameOnline -> + appendLog("● online 0x" + change.nodeId.raw.toUInt().toString(16)) } } } diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt index adaf08d..7a2af6a 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt @@ -92,6 +92,7 @@ internal object Scenarios { AdminResult.NodeUnreachable -> error("node unreachable") AdminResult.SessionKeyExpired -> error("session key expired (twice — retry exhausted)") AdminResult.Unauthorized -> error("unauthorized") + AdminResult.RateLimited -> error("device rate-limited the request") is AdminResult.Failed -> error("routing error: ${result.routingError}") } } @@ -121,6 +122,8 @@ internal object Scenarios { AdminResult.Unauthorized -> error("unauthorized") + AdminResult.RateLimited -> error("device rate-limited the request") + is AdminResult.Failed -> error("routing error: ${result.routingError}") } } diff --git a/samples/parity-app/src/commonMain/kotlin/org/meshtastic/sample/MeshSampleController.kt b/samples/parity-app/src/commonMain/kotlin/org/meshtastic/sample/MeshSampleController.kt index 0175619..ee64489 100644 --- a/samples/parity-app/src/commonMain/kotlin/org/meshtastic/sample/MeshSampleController.kt +++ b/samples/parity-app/src/commonMain/kotlin/org/meshtastic/sample/MeshSampleController.kt @@ -79,6 +79,8 @@ class MeshSampleController(private val storagePath: String, private val scope: C is NodeChange.Added -> append("[+] node 0x${ev.node.num.toString(16)}") is NodeChange.Updated -> append("[~] node 0x${ev.node.num.toString(16)}") is NodeChange.Removed -> append("[-] node 0x${ev.nodeId.raw.toString(16)}") + is NodeChange.WentOffline -> append("[offline] node 0x${ev.nodeId.raw.toString(16)}") + is NodeChange.CameOnline -> append("[online] node 0x${ev.nodeId.raw.toString(16)}") } } .launchIn(this) From 5c225d3649df56f40855b7b00b4b555d8a07d9e3 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 17:41:13 -0500 Subject: [PATCH 28/36] chore: KDoc cleanup, stale comments, and cruft removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add KDoc to ~15 public API symbols (AutoReconnectConfig, BatteryStatus, ChannelHelpers, ChannelUrls, CongestionLevel, Connect, DeviceCapabilities) - Fix incomplete comment fragment in MeshEngine.kt - Update KeyVerificationPrompt: remove stale Phase-1 marker, clarify as marker interface pending PKI verification UI - Update RadioClient.Builder transport comment: Phase-2 → Future - Fix stale sample paths in docs/decisions/003-tooling.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/sdk/AutoReconnectConfig.kt | 2 +- .../org/meshtastic/sdk/BatteryStatus.kt | 6 ++++-- .../org/meshtastic/sdk/ChannelHelpers.kt | 21 +++++++++---------- .../kotlin/org/meshtastic/sdk/ChannelUrls.kt | 7 ++++--- .../org/meshtastic/sdk/CongestionLevel.kt | 4 +++- .../kotlin/org/meshtastic/sdk/Connect.kt | 1 + .../org/meshtastic/sdk/DeviceCapabilities.kt | 10 +++++---- .../kotlin/org/meshtastic/sdk/Node.kt | 18 +++------------- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 4 ++-- .../org/meshtastic/sdk/internal/MeshEngine.kt | 2 +- docs/decisions/003-tooling.md | 2 +- 11 files changed, 36 insertions(+), 41 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AutoReconnectConfig.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AutoReconnectConfig.kt index 3dac7ac..821bc21 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AutoReconnectConfig.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AutoReconnectConfig.kt @@ -11,7 +11,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** - * Tunables for the engine's built-in auto-reconnect supervisor. + * Configuration for the engine's built-in auto-reconnect supervisor. * * Configure on the [RadioClient.Builder] via * [autoReconnect(...)][RadioClient.Builder.autoReconnect]. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/BatteryStatus.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/BatteryStatus.kt index 0b77658..1524a7c 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/BatteryStatus.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/BatteryStatus.kt @@ -10,11 +10,12 @@ package org.meshtastic.sdk import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.Telemetry /** - * Curated battery health and state information. + * Curated battery health and state information reported by the device. * * @property percent charge level (0..100). * @property voltageVolts raw battery voltage, if reported. * @property pluggedIn `true` if the device is currently drawing external power. + * @since 0.1.0 */ public data class BatteryStatus( public val percent: Int?, @@ -23,9 +24,10 @@ public data class BatteryStatus( ) /** - * Converts protobuf [DeviceMetrics] to [BatteryStatus]. + * Converts protobuf [DeviceMetrics] into a normalized [BatteryStatus] snapshot. * * Maps the firmware's `>= 101` level sentinel to [BatteryStatus.pluggedIn]. + * @since 0.1.0 */ public fun DeviceMetrics.toBatteryStatus(): BatteryStatus? { if (battery_level == null && voltage == null) return null diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt index edc7874..1af1db8 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt @@ -33,12 +33,10 @@ public object ChannelHelpers { ) /** - * Validates channel settings for correctness. + * Validates channel metadata before it is sent to the device. * - * Checks: - * - Name length within bounds - * - PSK length is valid (0=none, 1=default, 16=AES128, 32=AES256) - * - Role is appropriate + * Checks name length, blank-only names, and supported PSK lengths. + * @since 0.1.0 */ public fun validate( name: String, @@ -63,11 +61,10 @@ public object ChannelHelpers { } /** - * Finds the first available (disabled) channel slot index in a channel list. - * Returns null if all slots are occupied. + * Finds the first writable channel slot after the primary channel. * - * @param channels current channel list from the device - * @param maxChannels maximum number of channels supported (typically 8) + * Returns `null` when every slot up to [maxChannels] is already occupied. + * @since 0.1.0 */ public fun findEmptySlot(channels: List, maxChannels: Int = 8): Int? { for (i in 1 until maxChannels) { @@ -78,8 +75,10 @@ public object ChannelHelpers { } /** - * Creates a [ChannelSettings] with validated parameters. - * Returns null if validation fails. + * Creates a [ChannelSettings] only when [name] and [psk] pass [validate]. + * + * Returns `null` instead of throwing when validation fails. + * @since 0.1.0 */ public fun createSettings( name: String, diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt index a995cc0..8376180 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt @@ -29,8 +29,8 @@ public object ChannelUrl { /** * Parses a Meshtastic share link into a [ChannelSet]. * - * Returns `null` if the URL is malformed or its base64 payload fails to decode into a - * valid protobuf message. + * Returns `null` if the URL is malformed or its payload is not a valid channel protobuf. + * @since 0.1.0 */ public fun parse(url: String): ChannelSet? { val trimmed = url.trim() @@ -53,9 +53,10 @@ public fun Channel.Companion.default(): Channel = Channel( ) /** - * Computes the 8-bit hash of [name] and [psk], used by firmware to identify channels on the wire. + * Computes the firmware-compatible 8-bit channel hash for [name] and [psk]. * * Mirrors the logic in `Channels::generateHash`. + * @since 0.1.0 */ public fun ChannelSettings.Companion.hash(name: String, psk: ByteArray): Int { var code = 0 diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt index b73aa6f..3e665ee 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt @@ -47,7 +47,9 @@ public data class CongestionMetrics( } /** - * Congestion severity levels. + * Discrete congestion buckets derived from channel-utilization telemetry. + * + * @since 0.1.0 */ public enum class CongestionLevel { /** Channel is clear — send freely. */ diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt index f716fa3..ff57d6e 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt @@ -32,6 +32,7 @@ import kotlin.time.Duration.Companion.seconds * * @throws MeshtasticException any failure surfaced by [RadioClient.connect] * @throws MeshtasticException.HandshakeTimeout if [timeout] elapses first + * @since 0.1.0 */ public suspend fun RadioClient.connectAndAwaitReady(timeout: Duration = 30.seconds): ConfigBundle { val bundle = withTimeoutOrNull(timeout) { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt index 1ce710f..1487875 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt @@ -8,8 +8,9 @@ package org.meshtastic.sdk /** - * Parses firmware version strings (e.g., "2.7.12") into comparable values and provides - * version-gated feature detection. + * Parsed firmware version value used for capability comparisons. + * + * @since 0.1.0 */ public data class DeviceVersion(val versionString: String) : Comparable { /** Integer representation (e.g., "2.7.12" → 20712). */ @@ -31,8 +32,9 @@ public data class DeviceVersion(val versionString: String) : Comparable 0 are reserved for explicit "ping our nodeinfo" semantics that we don't // expose today. Sending 0 is the safe keep-alive value and matches Android's reference - // client. Audit / . + // client. private val keepaliveHeartbeat = Heartbeat(nonce = 0) private var myNodeNum = 0 diff --git a/docs/decisions/003-tooling.md b/docs/decisions/003-tooling.md index 21975d8..8f72e29 100644 --- a/docs/decisions/003-tooling.md +++ b/docs/decisions/003-tooling.md @@ -97,7 +97,7 @@ Storage is **required** at `Builder.build()` time — no in-memory default in `: | **detekt `ForbiddenImport` + `:core:verifyModuleBoundary`** | Architecture rules: detekt bans `java.*`/`android.*` in `commonMain` and hints against `kotlin.Result` in public API; the Gradle `:core:verifyModuleBoundary` task enforces that `:core` does not depend on transport modules (see ADR-008). | | **`binary-compatibility-validator`** (`updateKotlinAbi`) | API surface freezes. From Phase 5 every public symbol change MUST regenerate `api/` files in the same commit. | | **Dokka 2.x** | API docs. Coverage gate from Phase 5. | -| **Compose previews / sample apps** | Manual smoke (`samples/cli`, `samples/android-app`, `samples/desktop`, `samples/ios-app`). | +| **Compose previews / sample apps** | Manual smoke (`samples/cli`, `samples/parity-app`, `samples/parity-android-app`). | ### Lint & format From 800c3e4959f56b130c74b8b850fe163b5f62d02e Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 17:44:58 -0500 Subject: [PATCH 29/36] chore: remove stale Phase 2 labels from implemented features Phase 2 RPC dispatch, admin timeout, backpressure, and reconnect are all implemented. Remove 'Phase 2' labels from EngineMessage, CommandDispatcher, P2AdminRpcTest, and HandshakeFsmTest comments since they describe current behavior, not future plans. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/sdk/internal/CommandDispatcher.kt | 4 ++-- .../kotlin/org/meshtastic/sdk/internal/EngineMessage.kt | 8 ++++---- .../kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt | 2 +- .../kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt index 128c6d5..1904cba 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt @@ -26,7 +26,7 @@ import org.meshtastic.sdk.debug import org.meshtastic.sdk.warn /** - * Engine-actor-owned registry of in-flight Phase 2 RPC responses. + * Engine-actor-owned registry of in-flight admin/telemetry/routing RPC responses. * * **Single-writer.** All mutations happen on the engine coroutine — no atomic / mutex needed * (ADR-002). @@ -249,7 +249,7 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { /** * Translate a Routing.Error enum value into the appropriate AdminResult failure. * - * Mirrors the error-taxonomy.md mapping for Phase 2 RPCs. Notably: + * Mirrors the error-taxonomy.md mapping for admin RPCs. Notably: * - `ADMIN_BAD_SESSION_KEY` → [AdminResult.SessionKeyExpired] so the caller can trigger * a single-shot retry after re-seeding via `get_owner_request`. * - `NOT_AUTHORIZED` / `ADMIN_PUBLIC_KEY_UNAUTHORIZED` → [AdminResult.Unauthorized]. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt index 2560211..843b094 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt @@ -48,7 +48,7 @@ internal enum class HandshakeStage { * * Per SPEC engine-actor.md: lifecycle messages ([Connect], [Disconnect]) and outbound [Send] / * admin messages are **never dropped**. [FrameRx] may be dropped under extreme inbox pressure - * once Phase 2 backpressure triage is implemented. + * once backpressure triage is implemented. */ internal sealed interface EngineMessage { @@ -66,7 +66,7 @@ internal sealed interface EngineMessage { * Graceful or error-triggered disconnection request. * * @param cause if non-null, the engine transitions to [ConnectionState.Reconnecting] with - * this cause and attempts exponential-backoff reconnect (Phase 2). For `null`, the engine + * this cause and attempts exponential-backoff reconnect. For `null`, the engine * transitions directly to [ConnectionState.Disconnected] with no reconnect. */ data class Disconnect(val cause: MeshtasticException? = null) : EngineMessage @@ -128,10 +128,10 @@ internal sealed interface EngineMessage { */ data object HandshakeHeartbeatSettleComplete : EngineMessage - /** An in-flight admin RPC did not receive a response within its deadline. Phase 2+. */ + /** An in-flight admin RPC did not receive a response within its deadline. */ data class AdminTimeout(val requestId: Int) : EngineMessage - // ── Phase 2 RPC dispatch (admin / telemetry / routing) ────────────────── + // ── RPC dispatch (admin / telemetry / routing) ────────────────── /** * Submit a typed RPC request whose response is correlated by [requestId] (the wire packet diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt index cdaeb58..20d96c9 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt @@ -238,7 +238,7 @@ class HandshakeFsmTest { assertEquals(true, last?.disconnect, "Last outbound ToRadio must have disconnect=true (got: $last)") } - // ── Phase 2 — audit critical regressions ───────────────────────────────── + // ── Audit: critical regressions ───────────────────────────────── /** * Audit P1-2 / F-3.1: firmware (PhoneAPI.cpp:202-209) interprets `Heartbeat(nonce=1)` as diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt index 170435e..e4d5313 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt @@ -30,7 +30,7 @@ import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds /** - * Phase 2 — AdminApi RPC coverage. Each test wires a [FakeRadioTransport] with auto-handshake, + * AdminApi RPC coverage. Each test wires a [FakeRadioTransport] with auto-handshake, * exercises one [AdminApi] method, and verifies (a) the outbound packet shape and (b) the * mapping from the scripted device response to the returned [AdminResult]. */ From 7d9a9ffd04e6752f966d7948c3364cda00566460 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 17:58:01 -0500 Subject: [PATCH 30/36] chore: bump AGP to 9.2.1 to match Meshtastic-Android Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f7cd5f7..f65556b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ [versions] # --- Kotlin & build --- kotlin = "2.3.20" # pinned for SKIE 0.10.11 compatibility -agp = "9.2.0" +agp = "9.2.1" gradle = "9.4.1" javaVersion = "21" androidMinSdk = "26" From ad2d4eea003973c37a7ca11b9de8e48a9ddc7481 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 18:19:08 -0500 Subject: [PATCH 31/36] fix: auto-resolve broadcast sends to Acked + add cs7 DM conformance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broadcast MessageHandle.await() would hang forever because the mesh never sends a Routing ACK for broadcasts. The engine now transitions broadcast sends to Acked immediately after dispatching to the transport. - MeshEngine.dispatchSend: auto-resolve broadcast (to=BROADCAST, no want_response) to Acked and remove from pendingSends - Scenarios: rename cs2 to 'broadcast text acceptance', add cs7 unicast DM scenario (requires --peer-node) - ConformanceCmd: wire cs7 into the conformance sweep - Tests: update 4 existing tests that assumed broadcast stays in Sent, add sendText_broadcastAutoResolvesToSuccess test 566 tests, 0 failures. Verified against real hardware (firmware 2.7.19): cs1 ✓ cs2 ✓ cs3 ✓ cs5 ✓ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/sdk/internal/MeshEngine.kt | 16 ++++++++-- .../kotlin/org/meshtastic/sdk/EngineTest.kt | 18 ++++++++--- .../meshtastic/sdk/MeshEngineEdgeCasesTest.kt | 3 +- .../org/meshtastic/sdk/P0ReliabilityTest.kt | 24 +++++++-------- .../org/meshtastic/sdk/RadioClientSendTest.kt | 11 +++++++ .../org/meshtastic/cli/cmd/ConformanceCmd.kt | 11 +++++-- .../meshtastic/cli/conformance/Scenarios.kt | 30 ++++++++++++++++--- 7 files changed, 86 insertions(+), 27 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index 5a1f43c..c342764 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -1954,13 +1954,23 @@ internal class MeshEngine( msg.stateFlow.value = SendState.Sent logger.debug(TAG) { "Send dispatched id=${msg.id}" } + val wantsResponse = packet.decoded?.want_response == true + + // Broadcasts never receive a Routing ACK from the mesh. Auto-resolve to Acked + // once the packet has been handed to the transport. Without this, broadcast + // MessageHandle.await() would suspend forever. + if (packet.to == BROADCAST_ADDR && !wantsResponse) { + msg.stateFlow.value = SendState.Acked + pendingSends.remove(MessageId(wireId)) + logger.debug(TAG) { "Broadcast auto-acked id=${msg.id}" } + return + } + // arm an ACK timeout for unicast want_ack sends so a silent device can't leave - // the handle in `Sent` forever. Broadcasts (`to == BROADCAST_ADDR`) never receive a - // routing ACK so we deliberately skip arming for them. + // the handle in `Sent` forever. // also arm for want_response-only requests (e.g. AdminMessage.get_owner_request) // these expect a unicast reply with `request_id` set, so the same Routing-ACK timer // semantics apply even when `want_ack` is false. - val wantsResponse = packet.decoded?.want_response == true if ((packet.want_ack || wantsResponse) && packet.to != BROADCAST_ADDR) { val key = MessageId(wireId) val scope = engineScope ?: return diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt index 17db47b..af62f0b 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt @@ -113,11 +113,10 @@ class EngineTest { @Test fun testCancelOnSentIsNoOp() = runTest { // Per SPEC: cancel() on Sent or later is a no-op; state is unchanged. - // Post-handshake (Ready), sends are dispatched immediately, so they go from - // Queued → Sent before any cancel can take effect. + // Use a unicast packet so it stays in Sent (broadcasts auto-resolve to Acked). val client = buildClient() client.connect() - val handle = client.send(testPacket()) + val handle = client.send(unicastPacket()) runCurrent() // let engine actor process the Send → state becomes Sent assertEquals(SendState.Sent, handle.state.value) handle.cancel() @@ -127,9 +126,10 @@ class EngineTest { @Test fun testDisconnectFailsQueuedHandle() = runTest { + // Use a unicast packet so it stays in-flight (broadcasts auto-resolve to Acked). val client = buildClient() client.connect() - val handle = client.send(testPacket()) + val handle = client.send(unicastPacket()) runCurrent() // ensure engine actor processes Send before we cancel the supervisor client.disconnect() val state = handle.state.value @@ -213,6 +213,16 @@ class EngineTest { ), ) + private fun unicastPacket() = org.meshtastic.proto.MeshPacket( + to = 0x12345678, + channel = 0, + want_ack = true, + decoded = org.meshtastic.proto.Data( + portnum = org.meshtastic.proto.PortNum.TEXT_MESSAGE_APP, + payload = okio.ByteString.of(*"hello".encodeToByteArray()), + ), + ) + private fun oversizedPacket() = org.meshtastic.proto.MeshPacket( to = NodeId.BROADCAST.raw, channel = 0, diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt index 3fa7207..21d798d 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt @@ -579,7 +579,8 @@ class MeshEngineEdgeCasesTest { runCurrent() assertEquals(10, handles.map { it.id.raw }.distinct().size) - assertTrue(handles.all { it.state.value == SendState.Sent }) + // Broadcasts auto-resolve to Acked since no mesh-level ACK is expected. + assertTrue(handles.all { it.state.value == SendState.Acked }) client.disconnect() } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt index 98a53f1..d0aa2ed 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt @@ -96,7 +96,7 @@ class P0ReliabilityTest { client.disconnect() } - // ── R-P0-6: broadcasts must NOT receive an ACK timeout ────────────────────── + // ── R-P0-6: broadcasts auto-resolve to Acked (no mesh-level ACK expected) ── @Test fun testBroadcastDoesNotTimeOut() = runTest { @@ -105,15 +105,16 @@ class P0ReliabilityTest { val handle = client.send(broadcastPacket()) runCurrent() - assertEquals(SendState.Sent, handle.state.value) + + // Broadcasts auto-resolve to Acked once the device accepts the packet. + assertEquals(SendState.Acked, handle.state.value) advanceTimeBy(5_000) // far past the 1s sendTimeout runCurrent() - // Broadcasts never receive a routing ACK, so the engine must NOT arm an ACK timer for - // them — the handle stays in Sent. - assertNotEquals( - SendState.Failed(SendFailure.AckTimeout), + // Must not degrade to Failed — the auto-resolve is terminal. + assertEquals( + SendState.Acked, handle.state.value, "Broadcasts must not be subject to ACK timeouts", ) @@ -153,16 +154,13 @@ class P0ReliabilityTest { val client = buildClient() client.connect() - // First send — get a real handle so we can replay its id. - val first = client.send(broadcastPacket()) + // First send — use unicast so it stays in pendingSends (broadcasts auto-resolve). + val first = client.send(unicastWantAckPacket()) runCurrent() assertEquals(SendState.Sent, first.state.value) - // Manually post a second Send with the same id through the engine boundary by issuing - // a colliding raw send via reflection-free public API: not possible without internal - // access. Instead, rely on the symmetry that the engine treats `pendingSends[id]` as a - // collision marker — verify the negative path: a fresh id is *not* rejected. - val second = client.send(broadcastPacket()) + // A fresh id is *not* rejected — verify the negative path. + val second = client.send(unicastWantAckPacket()) runCurrent() assertNotEquals(SendState.Failed(SendFailure.IdCollision), second.state.value) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt index 49a2af0..3cbfddd 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt @@ -407,6 +407,17 @@ class RadioClientSendTest { } } + @Test + fun sendText_broadcastAutoResolvesToSuccess() = runTest { + withConnectedClient { client, _ -> + val handle = client.sendText("broadcast test", to = NodeId.BROADCAST) + runCurrent() + val outcome = handle.await() + assertValue(SendOutcome.Success, outcome, "broadcast await outcome") + assertValue(SendState.Acked, handle.state.value, "broadcast terminal state") + } + } + private companion object { const val TEST_NODE_NUM: Int = 0x11111111 val TARGET_NODE: NodeId = NodeId(0x22222222) diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConformanceCmd.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConformanceCmd.kt index 2022927..456b81c 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConformanceCmd.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConformanceCmd.kt @@ -58,7 +58,7 @@ internal class ConformanceCmd : BaseCommand(name = "conformance") { private val scenarioFilter by option( "--scenario", - help = "Restrict to a comma-separated list of scenario ids (cs1,cs2,cs3,cs4,cs5,cs6).", + help = "Restrict to a comma-separated list of scenario ids (cs1,cs2,cs3,cs4,cs5,cs6,cs7).", metavar = "CSV", ).split(",") @@ -88,7 +88,7 @@ internal class ConformanceCmd : BaseCommand(name = "conformance") { // If cs1 fails, the rest cannot run — record SKIPs and bail out. if (results.last().status != ScenarioResult.Status.PASS) { - listOf("cs2", "cs3", "cs4", "cs5", "cs6").forEach { id -> + listOf("cs2", "cs3", "cs4", "cs5", "cs6", "cs7").forEach { id -> results += Scenarios.skip(id, "skipped due to cs1 failure", "handshake never reached Connected") .also { announce(it) } } @@ -107,6 +107,13 @@ internal class ConformanceCmd : BaseCommand(name = "conformance") { }?.let { results += it.also(::announce) } runIfRequested("cs6") { Scenarios.cs6ReconnectAfterDrop(client) } ?.let { results += it.also(::announce) } + runIfRequested("cs7") { + if (peer == null) { + Scenarios.skip("cs7", "unicast DM", "no --peer-node supplied") + } else { + Scenarios.cs7UnicastDmText(client, peer) + } + }?.let { results += it.also(::announce) } } } finally { runCatching { client.disconnect() } diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt index 7a2af6a..7deb152 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt @@ -15,6 +15,7 @@ import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.RadioClient import org.meshtastic.sdk.SendOutcome import org.meshtastic.sdk.connectAndAwaitReady +import org.meshtastic.sdk.sendDirectMessage import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -65,17 +66,18 @@ internal object Scenarios { } /** - * **cs2 — Send-text round-trip** (manual C1). Broadcasts a small text packet on channel 0. + * **cs2 — Broadcast text acceptance** (manual C1). Broadcasts a small text packet on channel 0. * PASS if the [MessageHandle] resolves to [SendOutcome.Success] within [budget]; FAIL on - * any failure outcome or timeout. + * any failure outcome or timeout. Broadcasts auto-resolve once the device accepts the packet + * (no mesh-level ACK is expected for broadcast). */ suspend fun cs2SendTextRoundTrip(client: RadioClient, budget: Duration = 30.seconds): ScenarioResult = - runScenario("cs2", "broadcast text round-trip") { + runScenario("cs2", "broadcast text acceptance") { val handle = client.sendText("conformance probe") val outcome = withTimeoutOrNull(budget) { handle.await() } ?: error("did not reach terminal state in ${budget.inWholeSeconds}s") when (outcome) { - SendOutcome.Success -> "id=${handle.id} acked" + SendOutcome.Success -> "id=${handle.id} accepted" is SendOutcome.Failure -> error("failed: ${outcome.reason::class.simpleName}") } } @@ -173,6 +175,26 @@ internal object Scenarios { fun skip(id: String, name: String, reason: String): ScenarioResult = ScenarioResult(id = id, name = name, status = ScenarioResult.Status.SKIP, durationMs = 0L, message = reason) + /** + * **cs7 — Unicast DM text round-trip** (manual C2). Sends a direct message to a specific peer + * with `wantAck = true`. PASS if the [MessageHandle] resolves to [SendOutcome.Success] + * within [budget]; FAIL on any failure outcome or timeout. Unlike cs2 (broadcast), this + * exercises the full send → routing-ACK path. + */ + suspend fun cs7UnicastDmText( + client: RadioClient, + peer: NodeId, + budget: Duration = 30.seconds, + ): ScenarioResult = runScenario("cs7", "unicast DM to $peer") { + val handle = client.sendDirectMessage(to = peer, text = "dm conformance probe") + val outcome = withTimeoutOrNull(budget) { handle.await() } + ?: error("did not reach terminal state in ${budget.inWholeSeconds}s") + when (outcome) { + SendOutcome.Success -> "id=${handle.id} acked by ${peer}" + is SendOutcome.Failure -> error("failed: ${outcome.reason::class.simpleName}") + } + } + /** * Wrap a single-scenario block: time it, catch every exception, and produce a * [ScenarioResult]. The block returns the success-path message; failures throw and the From 460e2b7f20339fa7575ad994869cc01e0bb70555 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 18:53:55 -0500 Subject: [PATCH 32/36] feat: broadcast sends use want_ack=true for implicit ACK feedback Align with Apple/Android client behavior: sendText() now sets want_ack=true on all packets (including broadcasts). The firmware generates an implicit ACK when it overhears a neighbor relay the packet, providing relay confirmation. Engine changes: - Fire-and-forget broadcasts (want_ack=false) still auto-resolve - Broadcasts with want_ack=true arm the ACK timeout and wait for the firmware's implicit ACK (or timeout to Failed) - Removes the unconditional broadcast auto-resolve that previously reported success before the radio even transmitted This gives consumers meaningful feedback: Success means at least one mesh node relayed the message. Failure (AckTimeout) means no relay was overheard within the timeout window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/RadioClient.kt | 9 ++++ .../org/meshtastic/sdk/internal/MeshEngine.kt | 19 ++++---- .../kotlin/org/meshtastic/sdk/EngineTest.kt | 4 +- .../meshtastic/sdk/MeshEngineEdgeCasesTest.kt | 7 ++- .../org/meshtastic/sdk/P0ReliabilityTest.kt | 47 ++++++++++++++++--- .../org/meshtastic/sdk/RadioClientSendTest.kt | 11 ++++- .../meshtastic/cli/conformance/Scenarios.kt | 8 ++-- 7 files changed, 79 insertions(+), 26 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index 5671464..a8270f1 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -338,6 +338,14 @@ public class RadioClient internal constructor( * Wraps the text in a [MeshPacket] with `decoded.portnum = TEXT_MESSAGE_APP` and * `decoded.payload = text.encodeToByteArray()`, then calls [send]. * + * Sets `want_ack = true` so the firmware provides delivery feedback: + * - **Unicast**: the recipient sends a Routing ACK back to the sender. + * - **Broadcast**: the firmware generates an implicit ACK when it overhears a neighbor + * relay the packet. This confirms at least one mesh node retransmitted the message. + * + * The returned [MessageHandle] will resolve to [SendOutcome.Success] on ACK, or + * [SendOutcome.Failure] if the firmware exhausts retransmissions without confirmation. + * * @param text the message text (UTF-8 encoded) * @param channel the channel index (default: 0) * @param to the destination [NodeId] (default: [NodeId.BROADCAST]) @@ -358,6 +366,7 @@ public class RadioClient internal constructor( val packet = MeshPacket( to = to.raw, channel = channel.raw, + want_ack = true, decoded = org.meshtastic.proto.Data( portnum = org.meshtastic.proto.PortNum.TEXT_MESSAGE_APP, payload = payload.toByteString(), diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index c342764..e0866d2 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -1956,22 +1956,21 @@ internal class MeshEngine( val wantsResponse = packet.decoded?.want_response == true - // Broadcasts never receive a Routing ACK from the mesh. Auto-resolve to Acked - // once the packet has been handed to the transport. Without this, broadcast + // Fire-and-forget broadcasts (want_ack=false, no want_response): auto-resolve to + // Acked immediately. No firmware ACK will arrive so without this the // MessageHandle.await() would suspend forever. - if (packet.to == BROADCAST_ADDR && !wantsResponse) { + if (packet.to == BROADCAST_ADDR && !packet.want_ack && !wantsResponse) { msg.stateFlow.value = SendState.Acked pendingSends.remove(MessageId(wireId)) - logger.debug(TAG) { "Broadcast auto-acked id=${msg.id}" } + logger.debug(TAG) { "Broadcast (fire-and-forget) auto-acked id=${msg.id}" } return } - // arm an ACK timeout for unicast want_ack sends so a silent device can't leave - // the handle in `Sent` forever. - // also arm for want_response-only requests (e.g. AdminMessage.get_owner_request) - // these expect a unicast reply with `request_id` set, so the same Routing-ACK timer - // semantics apply even when `want_ack` is false. - if ((packet.want_ack || wantsResponse) && packet.to != BROADCAST_ADDR) { + // Arm an ACK timeout for any send expecting firmware feedback: + // • unicast want_ack — waits for recipient's Routing ACK + // • broadcast want_ack — waits for firmware's implicit ACK (relay overheard) + // • want_response requests (e.g. AdminMessage.get_owner_request) + if (packet.want_ack || wantsResponse) { val key = MessageId(wireId) val scope = engineScope ?: return ackTimeoutJobs.remove(key)?.cancel() diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt index af62f0b..d678c04 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt @@ -113,7 +113,7 @@ class EngineTest { @Test fun testCancelOnSentIsNoOp() = runTest { // Per SPEC: cancel() on Sent or later is a no-op; state is unchanged. - // Use a unicast packet so it stays in Sent (broadcasts auto-resolve to Acked). + // Use a unicast packet so it stays in Sent (fire-and-forget broadcasts auto-resolve to Acked). val client = buildClient() client.connect() val handle = client.send(unicastPacket()) @@ -126,7 +126,7 @@ class EngineTest { @Test fun testDisconnectFailsQueuedHandle() = runTest { - // Use a unicast packet so it stays in-flight (broadcasts auto-resolve to Acked). + // Use a unicast packet so it stays in-flight (fire-and-forget broadcasts auto-resolve to Acked). val client = buildClient() client.connect() val handle = client.send(unicastPacket()) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt index 21d798d..0a73977 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt @@ -579,7 +579,12 @@ class MeshEngineEdgeCasesTest { runCurrent() assertEquals(10, handles.map { it.id.raw }.distinct().size) - // Broadcasts auto-resolve to Acked since no mesh-level ACK is expected. + // sendText sets want_ack=true; broadcasts await firmware implicit ACK so they stay in Sent. + assertTrue(handles.all { it.state.value == SendState.Sent }) + + // Inject implicit ACKs for all handles. + handles.forEach { transport.injectRoutingAck(requestId = it.id.raw) } + runCurrent() assertTrue(handles.all { it.state.value == SendState.Acked }) client.disconnect() diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt index d0aa2ed..2425b4d 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt @@ -22,6 +22,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotEquals +import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds /** @@ -96,17 +97,17 @@ class P0ReliabilityTest { client.disconnect() } - // ── R-P0-6: broadcasts auto-resolve to Acked (no mesh-level ACK expected) ── + // ── R-P0-6: fire-and-forget broadcasts (want_ack=false) auto-resolve ────── @Test - fun testBroadcastDoesNotTimeOut() = runTest { + fun testFireAndForgetBroadcastAutoResolves() = runTest { val client = buildClient(sendTimeout = 1.seconds) client.connect() - val handle = client.send(broadcastPacket()) + val handle = client.send(broadcastPacket()) // want_ack=false runCurrent() - // Broadcasts auto-resolve to Acked once the device accepts the packet. + // Fire-and-forget broadcasts auto-resolve to Acked once the device accepts the packet. assertEquals(SendState.Acked, handle.state.value) advanceTimeBy(5_000) // far past the 1s sendTimeout @@ -116,13 +117,35 @@ class P0ReliabilityTest { assertEquals( SendState.Acked, handle.state.value, - "Broadcasts must not be subject to ACK timeouts", + "Fire-and-forget broadcasts must not be subject to ACK timeouts", ) client.disconnect() } - // ── R-P0-4: session passkey survives reconnect via storage ────────────────── + // ── R-P0-6b: broadcast with want_ack=true times out if no implicit ACK ────── + + @Test + fun testBroadcastWithWantAckTimesOutWithoutImplicitAck() = runTest { + val client = buildClient(sendTimeout = 1.seconds) + client.connect() + + val handle = client.send(broadcastWantAckPacket()) + runCurrent() + + // Broadcast with want_ack=true stays in Sent awaiting firmware implicit ACK. + assertEquals(SendState.Sent, handle.state.value) + + advanceTimeBy(1_500) // past the 1s sendTimeout + runCurrent() + + // Must degrade to Failed(AckTimeout) — no relay overheard the rebroadcast. + val state = handle.state.value + assertTrue(state is SendState.Failed, "Expected Failed, got $state") + assertEquals(SendFailure.AckTimeout, state.reason) + + client.disconnect() + } @Test fun testSessionPasskeyIsPersistedAndReloaded() = runTest { @@ -154,7 +177,7 @@ class P0ReliabilityTest { val client = buildClient() client.connect() - // First send — use unicast so it stays in pendingSends (broadcasts auto-resolve). + // First send — use unicast so it stays in pendingSends (fire-and-forget broadcasts auto-resolve). val first = client.send(unicastWantAckPacket()) runCurrent() assertEquals(SendState.Sent, first.state.value) @@ -188,4 +211,14 @@ class P0ReliabilityTest { payload = ByteString.of(*"hello".encodeToByteArray()), ), ) + + private fun broadcastWantAckPacket() = MeshPacket( + to = NodeId.BROADCAST.raw, + channel = 0, + want_ack = true, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteString.of(*"hello-ack".encodeToByteArray()), + ), + ) } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt index 3cbfddd..ba2d2ad 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt @@ -408,10 +408,17 @@ class RadioClientSendTest { } @Test - fun sendText_broadcastAutoResolvesToSuccess() = runTest { - withConnectedClient { client, _ -> + fun sendText_broadcastWaitsForImplicitAck() = runTest { + withConnectedClient { client, transport -> val handle = client.sendText("broadcast test", to = NodeId.BROADCAST) runCurrent() + // sendText now sets want_ack=true; broadcast stays in Sent awaiting firmware implicit ACK. + assertValue(SendState.Sent, handle.state.value, "broadcast after dispatch") + + // Simulate firmware implicit ACK (relay overheard the rebroadcast). + transport.injectRoutingAck(requestId = handle.id.raw) + runCurrent() + val outcome = handle.await() assertValue(SendOutcome.Success, outcome, "broadcast await outcome") assertValue(SendState.Acked, handle.state.value, "broadcast terminal state") diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt index 7deb152..0a51d01 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt @@ -66,10 +66,10 @@ internal object Scenarios { } /** - * **cs2 — Broadcast text acceptance** (manual C1). Broadcasts a small text packet on channel 0. - * PASS if the [MessageHandle] resolves to [SendOutcome.Success] within [budget]; FAIL on - * any failure outcome or timeout. Broadcasts auto-resolve once the device accepts the packet - * (no mesh-level ACK is expected for broadcast). + * **cs2 — Broadcast text acceptance** (manual C1). Broadcasts a small text packet on channel 0 + * with `want_ack=true`. PASS if the [MessageHandle] resolves to [SendOutcome.Success] within + * [budget] (firmware sends an implicit ACK when it overhears a relay rebroadcast); FAIL on any + * failure outcome or timeout. */ suspend fun cs2SendTextRoundTrip(client: RadioClient, budget: Duration = 30.seconds): ScenarioResult = runScenario("cs2", "broadcast text acceptance") { From a802d255f645a17588420e3699fc0cc417ac6a9e Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 19:01:42 -0500 Subject: [PATCH 33/36] style: fix spotless and detekt violations - Replace wildcard import in StoreForwardApiTest with explicit imports - Apply spotless formatting across source and test files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/sdk/AdminApi.kt | 12 +++++-- .../org/meshtastic/sdk/ChannelHelpers.kt | 16 ++------- .../org/meshtastic/sdk/ConfigBuilders.kt | 15 ++++---- .../org/meshtastic/sdk/CongestionLevel.kt | 8 ++--- .../kotlin/org/meshtastic/sdk/MeshNode.kt | 3 +- .../kotlin/org/meshtastic/sdk/MeshTopology.kt | 7 +--- .../kotlin/org/meshtastic/sdk/NeighborInfo.kt | 5 +-- .../kotlin/org/meshtastic/sdk/Node.kt | 2 ++ .../kotlin/org/meshtastic/sdk/NodeStatus.kt | 8 ++--- .../org/meshtastic/sdk/PayloadAccessors.kt | 5 +-- .../org/meshtastic/sdk/PositionUtils.kt | 10 +++--- .../kotlin/org/meshtastic/sdk/Result.kt | 33 ++++++++--------- .../kotlin/org/meshtastic/sdk/RetryPolicy.kt | 12 +++---- .../kotlin/org/meshtastic/sdk/RoutingApi.kt | 2 +- .../org/meshtastic/sdk/StoreForwardApi.kt | 20 +++-------- .../meshtastic/sdk/internal/AdminApiImpl.kt | 35 ++++++++++-------- .../sdk/internal/CommandDispatcher.kt | 32 ++++++++++++++--- .../meshtastic/sdk/internal/ConfigMerge.kt | 5 +-- .../org/meshtastic/sdk/internal/MeshEngine.kt | 24 +++++++------ .../meshtastic/sdk/internal/RoutingApiImpl.kt | 2 +- .../sdk/internal/StoreForwardApiImpl.kt | 20 +++++++---- .../sdk/AdminApiImplComprehensiveTest.kt | 28 +++++++++++---- .../org/meshtastic/sdk/ConfigBuildersTest.kt | 13 ++++--- .../sdk/ExternalConfigChangeTest.kt | 8 +++-- .../sdk/HandshakeAndReconnectTest.kt | 28 +++++++++++---- .../meshtastic/sdk/MeshEngineEdgeCasesTest.kt | 19 +++++----- .../org/meshtastic/sdk/P2AdminRpcTest.kt | 5 ++- .../org/meshtastic/sdk/P2RoutingRpcTest.kt | 2 +- .../org/meshtastic/sdk/RadioClientSendTest.kt | 13 +++---- .../sdk/StoreForwardApiStatsTest.kt | 7 ++-- .../sdk/StoreForwardProtocolTest.kt | 21 ++++++++--- .../org/meshtastic/sdk/TelemetryApiTest.kt | 36 +++++++++++++++---- .../meshtastic/sdk/ext/ChannelHelpersTest.kt | 2 +- .../org/meshtastic/sdk/ext/CongestionTest.kt | 2 +- .../sdk/ext/PayloadAccessorsTest.kt | 10 ++++-- .../meshtastic/sdk/ext/PresenceTimerTest.kt | 13 +++---- .../org/meshtastic/sdk/ext/RetryPolicyTest.kt | 2 +- .../meshtastic/sdk/ext/StoreForwardApiTest.kt | 4 ++- .../internal/StoreForwardApiImplSfppTest.kt | 10 ++---- .../meshtastic/cli/conformance/Scenarios.kt | 21 +++++------ 40 files changed, 299 insertions(+), 221 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index dd1bbb7..b6e94ae 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -191,13 +191,19 @@ public interface AdminApi { // ── Backup / Restore ──────────────────────────────────────────────────── /** Back up device preferences to the specified [location]. */ - public suspend fun backupPreferences(location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH): AdminResult + public suspend fun backupPreferences( + location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH, + ): AdminResult /** Restore device preferences from the specified [location]. */ - public suspend fun restorePreferences(location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH): AdminResult + public suspend fun restorePreferences( + location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH, + ): AdminResult /** Remove a stored preference backup from [location]. */ - public suspend fun removeBackupPreferences(location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH): AdminResult + public suspend fun removeBackupPreferences( + location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH, + ): AdminResult // ── Node removal ──────────────────────────────────────────────────────── diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt index 1af1db8..477444f 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt @@ -27,10 +27,7 @@ public object ChannelHelpers { /** * Validation result for channel configuration. */ - public data class ValidationResult( - val isValid: Boolean, - val errors: List = emptyList(), - ) + public data class ValidationResult(val isValid: Boolean, val errors: List = emptyList()) /** * Validates channel metadata before it is sent to the device. @@ -38,11 +35,7 @@ public object ChannelHelpers { * Checks name length, blank-only names, and supported PSK lengths. * @since 0.1.0 */ - public fun validate( - name: String, - psk: ByteArray, - role: Channel.Role = Channel.Role.SECONDARY, - ): ValidationResult { + public fun validate(name: String, psk: ByteArray, role: Channel.Role = Channel.Role.SECONDARY): ValidationResult { val errors = mutableListOf() if (name.length > MAX_NAME_LENGTH) { errors += "Channel name exceeds $MAX_NAME_LENGTH characters" @@ -80,10 +73,7 @@ public object ChannelHelpers { * Returns `null` instead of throwing when validation fails. * @since 0.1.0 */ - public fun createSettings( - name: String, - psk: ByteArray = byteArrayOf(0x01), - ): ChannelSettings? { + public fun createSettings(name: String, psk: ByteArray = byteArrayOf(0x01)): ChannelSettings? { val validation = validate(name, psk) if (!validation.isValid) return null return ChannelSettings( diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt index c681494..4b07a62 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt @@ -36,9 +36,8 @@ private suspend fun AdminApi.setModuleConfigSection( ): AdminResult = setModuleConfig(wrap(initial.block())) /** Convenience: build and send a [Config.DeviceConfig] in a single call. */ -public suspend fun AdminApi.setDeviceConfig( - block: Config.DeviceConfig.() -> Config.DeviceConfig, -): AdminResult = setConfigSection(Config.DeviceConfig(), block) { Config(device = it) } +public suspend fun AdminApi.setDeviceConfig(block: Config.DeviceConfig.() -> Config.DeviceConfig): AdminResult = + setConfigSection(Config.DeviceConfig(), block) { Config(device = it) } /** Convenience: build and send a [Config.PositionConfig] in a single call. */ public suspend fun AdminApi.setPositionConfig( @@ -46,9 +45,8 @@ public suspend fun AdminApi.setPositionConfig( ): AdminResult = setConfigSection(Config.PositionConfig(), block) { Config(position = it) } /** Convenience: build and send a [Config.PowerConfig] in a single call. */ -public suspend fun AdminApi.setPowerConfig( - block: Config.PowerConfig.() -> Config.PowerConfig, -): AdminResult = setConfigSection(Config.PowerConfig(), block) { Config(power = it) } +public suspend fun AdminApi.setPowerConfig(block: Config.PowerConfig.() -> Config.PowerConfig): AdminResult = + setConfigSection(Config.PowerConfig(), block) { Config(power = it) } /** Convenience: build and send a [Config.NetworkConfig] in a single call. */ public suspend fun AdminApi.setNetworkConfig( @@ -61,9 +59,8 @@ public suspend fun AdminApi.setDisplayConfig( ): AdminResult = setConfigSection(Config.DisplayConfig(), block) { Config(display = it) } /** Convenience: build and send a [Config.LoRaConfig] in a single call. */ -public suspend fun AdminApi.setLoraConfig( - block: Config.LoRaConfig.() -> Config.LoRaConfig, -): AdminResult = setConfigSection(Config.LoRaConfig(), block) { Config(lora = it) } +public suspend fun AdminApi.setLoraConfig(block: Config.LoRaConfig.() -> Config.LoRaConfig): AdminResult = + setConfigSection(Config.LoRaConfig(), block) { Config(lora = it) } /** Convenience: build and send a [Config.BluetoothConfig] in a single call. */ public suspend fun AdminApi.setBluetoothConfig( diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt index 3e665ee..02af3da 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt @@ -16,10 +16,7 @@ import kotlin.time.Duration.Companion.seconds * @property airUtilTx current transmit air utilization percentage (0-100) * @property channelUtil current channel utilization percentage (0-100) */ -public data class CongestionMetrics( - val airUtilTx: Float, - val channelUtil: Float, -) { +public data class CongestionMetrics(val airUtilTx: Float, val channelUtil: Float) { /** Computed congestion level based on thresholds. */ public val level: CongestionLevel get() = when { airUtilTx >= CRITICAL_THRESHOLD || channelUtil >= CRITICAL_THRESHOLD -> CongestionLevel.CRITICAL @@ -54,10 +51,13 @@ public data class CongestionMetrics( public enum class CongestionLevel { /** Channel is clear — send freely. */ LOW, + /** Moderate activity — consider batching or delaying non-urgent messages. */ MEDIUM, + /** Heavy traffic — back off non-essential sends. */ HIGH, + /** Near capacity — only send critical messages. */ CRITICAL, } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt index 012f8d9..86e48fc 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt @@ -142,5 +142,4 @@ public fun NodeInfo.toMeshNode(nowEpochSeconds: Int): MeshNode = MeshNode( * @return list of [MeshNode] instances * @since 0.1.0 */ -public fun Iterable.toMeshNodes(nowEpochSeconds: Int): List = - map { it.toMeshNode(nowEpochSeconds) } +public fun Iterable.toMeshNodes(nowEpochSeconds: Int): List = map { it.toMeshNode(nowEpochSeconds) } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt index 676cfe9..abb32ec 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt @@ -31,12 +31,7 @@ public class MeshTopology { * Directed edge from a reporting node [from] to a neighbor [to], carrying the reported signal * quality ([snr]) and the [NeighborInfo.lastUpdated] value from the source report. */ - public data class Edge( - val from: NodeId, - val to: NodeId, - val snr: Float, - val lastUpdated: Int = 0, - ) + public data class Edge(val from: NodeId, val to: NodeId, val snr: Float, val lastUpdated: Int = 0) private val mutex = Mutex() diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt index e5c9682..00b888f 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt @@ -25,10 +25,7 @@ public data class NeighborInfo( * @property nodeId the neighbor's node ID * @property snr signal-to-noise ratio in dB (higher is better) */ - public data class Neighbor( - public val nodeId: NodeId, - public val snr: Float, - ) + public data class Neighbor(public val nodeId: NodeId, public val snr: Float) /** * Formats the neighbor list as a human-readable string. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt index efc9727..d52c012 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt @@ -279,8 +279,10 @@ public sealed interface MeshEvent { public enum class ExternalChangeKind { /** A channel was added, removed, or modified. */ CHANNEL, + /** A radio/device config section was modified. */ CONFIG, + /** A module config section was modified. */ MODULE_CONFIG, } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt index 6f82691..c5d0ff9 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt @@ -73,10 +73,7 @@ public val DEFAULT_ONLINE_THRESHOLD: Duration = 2.hours * * @since 0.1.0 */ -public fun NodeInfo.isOnline( - nowEpochSeconds: Int, - threshold: Duration = DEFAULT_ONLINE_THRESHOLD, -): Boolean { +public fun NodeInfo.isOnline(nowEpochSeconds: Int, threshold: Duration = DEFAULT_ONLINE_THRESHOLD): Boolean { if (last_heard == 0) return false val cutoff = nowEpochSeconds - threshold.inWholeSeconds.toInt() return last_heard >= cutoff @@ -104,7 +101,10 @@ public val NodeInfo.signalQuality: SignalQuality get() = when { // snr == 0f with no hops_away data likely means "no reading" (proto default) snr == 0f && hops_away == null -> SignalQuality.NONE + snr >= 5f -> SignalQuality.GOOD + snr >= 0f -> SignalQuality.FAIR + else -> SignalQuality.POOR } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt index 31ce96e..99be773 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.filter import okio.ByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.Position @@ -22,6 +21,7 @@ import org.meshtastic.proto.Routing import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User import org.meshtastic.proto.Waypoint +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo private fun MeshPacket.payloadOrNull(expected: PortNum): ByteString? { val data = decoded ?: return null if (data.portnum != expected) return null @@ -103,4 +103,5 @@ public fun MeshPacket.asTraceroute(): RouteDiscovery? = decodeIfPort(PortNum.TRA /** * Decodes the payload as [ProtoNeighborInfo] if [MeshPacket.decoded.portnum] matches [PortNum.NEIGHBORINFO_APP]. */ -public fun MeshPacket.asNeighborInfo(): ProtoNeighborInfo? = decodeIfPort(PortNum.NEIGHBORINFO_APP, ProtoNeighborInfo.ADAPTER) +public fun MeshPacket.asNeighborInfo(): ProtoNeighborInfo? = + decodeIfPort(PortNum.NEIGHBORINFO_APP, ProtoNeighborInfo.ADAPTER) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt index 9d7a635..11108a8 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt @@ -25,10 +25,9 @@ public object PositionUtils { public fun intToDegrees(positionInt: Int): Double = positionInt * 1e-7 /** Returns true if lat/lng are within valid bounds and not both zero. */ - public fun isValidPosition(latitude: Double, longitude: Double): Boolean = - !(latitude == 0.0 && longitude == 0.0) && - latitude in -90.0..90.0 && - longitude in -180.0..180.0 + public fun isValidPosition(latitude: Double, longitude: Double): Boolean = !(latitude == 0.0 && longitude == 0.0) && + latitude in -90.0..90.0 && + longitude in -180.0..180.0 /** * Computes the great-circle distance between two points using the Haversine formula. @@ -62,7 +61,8 @@ public object PositionUtils { } /** Overload accepting [LatLng] instances. */ - public fun bearing(from: LatLng, to: LatLng): Double = bearing(from.latitude, from.longitude, to.latitude, to.longitude) + public fun bearing(from: LatLng, to: LatLng): Double = + bearing(from.latitude, from.longitude, to.latitude, to.longitude) private fun Double.toRadians(): Double = this * PI / 180.0 diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt index 81cbda3..d443904 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt @@ -307,32 +307,27 @@ public fun AdminResult.getOrNull(): T? = (this as? AdminResult.Success public fun AdminResult.getOrElse(default: T): T = getOrNull() ?: default /** Returns the [Success] value, or the result of [block] if the result is not a success. */ -public inline fun AdminResult.getOrElse(block: (AdminResult) -> T): T = - when (this) { - is AdminResult.Success -> value - else -> block(this) - } +public inline fun AdminResult.getOrElse(block: (AdminResult) -> T): T = when (this) { + is AdminResult.Success -> value + else -> block(this) +} /** Returns `true` if this is [AdminResult.Success]. */ public val AdminResult.isSuccess: Boolean get() = this is AdminResult.Success /** Transforms the [Success] value with [transform], propagating failures unchanged. */ -public inline fun AdminResult.map(transform: (T) -> R): AdminResult = - when (this) { - is AdminResult.Success -> AdminResult.Success(transform(value)) - is AdminResult.SessionKeyExpired -> this - is AdminResult.Unauthorized -> this - is AdminResult.Timeout -> this - is AdminResult.RateLimited -> this - is AdminResult.NodeUnreachable -> this - is AdminResult.Failed -> this - } +public inline fun AdminResult.map(transform: (T) -> R): AdminResult = when (this) { + is AdminResult.Success -> AdminResult.Success(transform(value)) + is AdminResult.SessionKeyExpired -> this + is AdminResult.Unauthorized -> this + is AdminResult.Timeout -> this + is AdminResult.RateLimited -> this + is AdminResult.NodeUnreachable -> this + is AdminResult.Failed -> this +} /** Applies [onSuccess] or [onFailure] depending on the result. */ -public inline fun AdminResult.fold( - onSuccess: (T) -> R, - onFailure: (AdminResult) -> R, -): R = when (this) { +public inline fun AdminResult.fold(onSuccess: (T) -> R, onFailure: (AdminResult) -> R): R = when (this) { is AdminResult.Success -> onSuccess(value) else -> onFailure(this) } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt index 7346779..fbc094c 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt @@ -32,10 +32,7 @@ public sealed class RetryPolicy { * @property maxAttempts maximum number of retry attempts (not counting the initial send) * @property delay fixed delay between retries */ - public data class Fixed( - val maxAttempts: Int = 3, - val delay: Duration = 5.seconds, - ) : RetryPolicy() + public data class Fixed(val maxAttempts: Int = 3, val delay: Duration = 5.seconds) : RetryPolicy() /** * Retry with exponential backoff and optional jitter. @@ -64,10 +61,13 @@ public sealed class RetryPolicy { */ public fun delayForAttempt(attempt: Int): Duration? = when (this) { is None -> null + is Fixed -> if (attempt < maxAttempts) delay else null + is ExponentialBackoff -> { - if (attempt >= maxAttempts) null - else { + if (attempt >= maxAttempts) { + null + } else { val base = initialDelay * multiplier.pow(attempt.toDouble()) val capped = if (base > maxDelay) maxDelay else base if (jitterFactor > 0.0) { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt index 210097a..d928d29 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt @@ -7,8 +7,8 @@ */ package org.meshtastic.sdk -import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo import org.meshtastic.proto.RouteDiscovery +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo /** * Mesh route discovery and neighbor enumeration RPCs. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt index 0c36df0..1cd7e5b 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt @@ -45,10 +45,7 @@ public interface StoreForwardApi { * @param server specific S&F server to query. If null, queries the first known server. * @return the number of messages the server reports as pending, or failure reason */ - public suspend fun requestHistory( - since: Int? = null, - server: NodeId? = null, - ): AdminResult + public suspend fun requestHistory(since: Int? = null, server: NodeId? = null): AdminResult /** * Query statistics from a Store-and-Forward server. @@ -103,18 +100,12 @@ public sealed interface StoreForwardEvent { * @property server the S&F node delivering messages * @property messageCount number of messages being replayed */ - public data class HistoryReplayStarted( - val server: NodeId, - val messageCount: Int, - ) : StoreForwardEvent + public data class HistoryReplayStarted(val server: NodeId, val messageCount: Int) : StoreForwardEvent /** * History replay is complete. */ - public data class HistoryReplayComplete( - val server: NodeId, - val delivered: Int, - ) : StoreForwardEvent + public data class HistoryReplayComplete(val server: NodeId, val delivered: Int) : StoreForwardEvent /** * Heartbeat received from a S&F server (indicates it's still active). @@ -146,10 +137,7 @@ public sealed interface StoreForwardEvent { } /** An SFPP canon announce — message is confirmed on the chain. */ - public data class SfppCanonAnnounced( - val messageHash: ByteArray, - val rxTime: Long, - ) : StoreForwardEvent { + public data class SfppCanonAnnounced(val messageHash: ByteArray, val rxTime: Long) : StoreForwardEvent { override fun equals(other: Any?): Boolean = other is SfppCanonAnnounced && messageHash.contentEquals(other.messageHash) && rxTime == other.rxTime diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index dba1a5e..d134dfb 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -33,11 +33,11 @@ import org.meshtastic.sdk.AdminApi import org.meshtastic.sdk.AdminBatchScope import org.meshtastic.sdk.AdminEdit import org.meshtastic.sdk.AdminResult -import org.meshtastic.sdk.getOrThrow import org.meshtastic.sdk.ChannelIndex import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.SendFailure import org.meshtastic.sdk.SendState +import org.meshtastic.sdk.getOrThrow import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Instant @@ -266,17 +266,20 @@ internal class AdminApiImpl( // ── Backup / Restore ──────────────────────────────────────────────────── - override suspend fun backupPreferences(location: AdminMessage.BackupLocation): AdminResult = retryOnSessionExpiry { - submitAdminAck(AdminMessage(backup_preferences = location)) - } + override suspend fun backupPreferences(location: AdminMessage.BackupLocation): AdminResult = + retryOnSessionExpiry { + submitAdminAck(AdminMessage(backup_preferences = location)) + } - override suspend fun restorePreferences(location: AdminMessage.BackupLocation): AdminResult = retryOnSessionExpiry { - submitAdminAck(AdminMessage(restore_preferences = location)) - } + override suspend fun restorePreferences(location: AdminMessage.BackupLocation): AdminResult = + retryOnSessionExpiry { + submitAdminAck(AdminMessage(restore_preferences = location)) + } - override suspend fun removeBackupPreferences(location: AdminMessage.BackupLocation): AdminResult = retryOnSessionExpiry { - submitAdminAck(AdminMessage(remove_backup_preferences = location)) - } + override suspend fun removeBackupPreferences(location: AdminMessage.BackupLocation): AdminResult = + retryOnSessionExpiry { + submitAdminAck(AdminMessage(remove_backup_preferences = location)) + } // ── Node removal ──────────────────────────────────────────────────────── @@ -491,17 +494,16 @@ internal class AdminApiImpl( private fun localNode(): NodeId = targetNode ?: NodeId(engine.myNodeNumOrNull() ?: 0) - private inner class AdminBatchScopeImpl( - edit: AdminEditImpl, - ) : AdminBatchScope, AdminEdit by edit { + private inner class AdminBatchScopeImpl(edit: AdminEditImpl) : + AdminBatchScope, + AdminEdit by edit { override suspend fun getConfig(type: AdminMessage.ConfigType): Config = this@AdminApiImpl.getConfig(type).getOrThrow() override suspend fun getModuleConfig(type: AdminMessage.ModuleConfigType): ModuleConfig = this@AdminApiImpl.getModuleConfig(type).getOrThrow() - override suspend fun listChannels(): List = - this@AdminApiImpl.listChannels().getOrThrow() + override suspend fun listChannels(): List = this@AdminApiImpl.listChannels().getOrThrow() } private inner class AdminEditImpl : AdminEdit { @@ -582,10 +584,13 @@ private fun mapSendFailureToAdminResult(reason: SendFailure): AdminResult is SendFailure.Other -> when (reason.routingError) { Routing.Error.ADMIN_BAD_SESSION_KEY -> AdminResult.SessionKeyExpired + Routing.Error.NOT_AUTHORIZED, Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED, -> AdminResult.Unauthorized + Routing.Error.RATE_LIMIT_EXCEEDED -> AdminResult.RateLimited + else -> AdminResult.Failed(reason.routingError) } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt index 1904cba..e725fd2 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt @@ -13,7 +13,6 @@ import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.DeviceConnectionStatus import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery @@ -24,6 +23,7 @@ import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.LogSink import org.meshtastic.sdk.debug import org.meshtastic.sdk.warn +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo /** * Engine-actor-owned registry of in-flight admin/telemetry/routing RPC responses. @@ -90,19 +90,39 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { val resolved = when (entry.kind) { ResponseKind.AdminConfig -> decodeAdmin(decoded.payload) { it.get_config_response } + ResponseKind.AdminModuleConfig -> decodeAdmin(decoded.payload) { it.get_module_config_response } + ResponseKind.AdminOwner -> decodeAdmin(decoded.payload) { it.get_owner_response } + ResponseKind.AdminChannel -> decodeAdmin(decoded.payload) { it.get_channel_response } + ResponseKind.AdminDeviceMetadata -> decodeAdmin(decoded.payload) { it.get_device_metadata_response } - ResponseKind.AdminCannedMessages -> decodeAdmin(decoded.payload) { it.get_canned_message_module_messages_response } + + ResponseKind.AdminCannedMessages -> decodeAdmin(decoded.payload) { + it.get_canned_message_module_messages_response + } + ResponseKind.AdminRingtone -> decodeAdmin(decoded.payload) { it.get_ringtone_response } - ResponseKind.AdminDeviceConnectionStatus -> decodeAdmin(decoded.payload) { it.get_device_connection_status_response } - ResponseKind.AdminRemoteHardwarePins -> decodeAdmin(decoded.payload) { it.get_node_remote_hardware_pins_response } + + ResponseKind.AdminDeviceConnectionStatus -> decodeAdmin(decoded.payload) { + it.get_device_connection_status_response + } + + ResponseKind.AdminRemoteHardwarePins -> decodeAdmin(decoded.payload) { + it.get_node_remote_hardware_pins_response + } + ResponseKind.AdminDeviceUIConfig -> decodeAdmin(decoded.payload) { it.get_ui_config_response } + ResponseKind.Telemetry -> decodeTelemetry(decoded.payload, decoded.portnum) + ResponseKind.RouteDiscoveryReply -> decodeRoute(decoded.payload, decoded.portnum) + ResponseKind.NeighborInfoReply -> decodeNeighborInfo(decoded.payload, decoded.portnum) + ResponseKind.StoreForwardReply -> decodeStoreForwardHistory(decoded.payload, decoded.portnum) + ResponseKind.StoreForwardStatsReply -> decodeStoreForwardStats(decoded.payload, decoded.portnum) } @@ -212,7 +232,9 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { } StoreAndForward.RequestResponse.ROUTER_BUSY -> AdminResult.Failed(Routing.Error.NO_RESPONSE) + StoreAndForward.RequestResponse.ROUTER_ERROR -> AdminResult.Failed(Routing.Error.GOT_NAK) + else -> null } } @@ -229,7 +251,9 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { } StoreAndForward.RequestResponse.ROUTER_BUSY -> AdminResult.Failed(Routing.Error.NO_RESPONSE) + StoreAndForward.RequestResponse.ROUTER_ERROR -> AdminResult.Failed(Routing.Error.GOT_NAK) + else -> null } } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt index 27d146e..4e83dc7 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt @@ -76,10 +76,7 @@ internal fun mergeConfigs(existing: List, written: List): List, - written: List, -): List { +internal fun mergeModuleConfigs(existing: List, written: List): List { val writtenByKey = written.associateBy { it.sectionKey() }.filterKeys { it != null } val result = existing.map { cfg -> val key = cfg.sectionKey() diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index e0866d2..cdcbe0d 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -41,10 +41,10 @@ import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.AutoReconnectConfig import org.meshtastic.sdk.ChannelIndex -import org.meshtastic.sdk.CongestionLevel -import org.meshtastic.sdk.CongestionMetrics import org.meshtastic.sdk.ConfigBundle import org.meshtastic.sdk.ConfigPhase +import org.meshtastic.sdk.CongestionLevel +import org.meshtastic.sdk.CongestionMetrics import org.meshtastic.sdk.ConnectionState import org.meshtastic.sdk.DeviceStorage import org.meshtastic.sdk.DroppedFlow @@ -1337,7 +1337,9 @@ internal class MeshEngine( return } if (decoded.portnum == PortNum.UNKNOWN_APP) { - logger.warn(TAG) { "Dropping packet id=${packet.id} with unknown port from=0x${packet.from.toString(16)}" } + logger.warn(TAG) { + "Dropping packet id=${packet.id} with unknown port from=0x${packet.from.toString(16)}" + } events.tryEmit( MeshEvent.ProtocolWarning( "packet dropped for unknown port", @@ -2184,11 +2186,18 @@ internal class MeshEngine( val list = (current ?: emptyList()).toMutableList() when { idx < list.size -> list[idx] = channel + idx == list.size -> list.add(channel) + // idx > list.size: sparse gap — pad with DISABLED channels to avoid holes else -> { while (list.size < idx) { - list.add(org.meshtastic.proto.Channel(index = list.size, role = org.meshtastic.proto.Channel.Role.DISABLED)) + list.add( + org.meshtastic.proto.Channel( + index = list.size, + role = org.meshtastic.proto.Channel.Role.DISABLED, + ), + ) } list.add(channel) } @@ -2382,10 +2391,5 @@ internal class MeshEngine( const val INBOUND_PACKET_DEDUP_CAP: Int = 256 } - private data class InboundPacketKey( - val from: Int, - val to: Int, - val channel: Int, - val id: Int, - ) + private data class InboundPacketKey(val from: Int, val to: Int, val channel: Int, val id: Int) } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt index 2960fba..128ba31 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt @@ -10,7 +10,6 @@ package org.meshtastic.sdk.internal import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing @@ -18,6 +17,7 @@ import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.RoutingApi import kotlin.time.Duration +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo /** * Engine-backed [RoutingApi]. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt index e1cf967..c3fd1eb 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt @@ -119,7 +119,14 @@ internal class StoreForwardApiImpl( want_response = true, ), ) - return when (val result = engine.submitRpc(packet, requestId, ResponseKind.StoreForwardStatsReply, rpcTimeout)) { + return when ( + val result = engine.submitRpc( + packet, + requestId, + ResponseKind.StoreForwardStatsReply, + rpcTimeout, + ) + ) { is AdminResult.Success -> AdminResult.Success(result.value.toSdkStats()) AdminResult.Timeout -> AdminResult.Timeout AdminResult.NodeUnreachable -> AdminResult.NodeUnreachable @@ -212,8 +219,11 @@ internal class StoreForwardApiImpl( private fun looksLikeLegacyStoreForward(message: StoreAndForward): Boolean = when (message.rr) { StoreAndForward.RequestResponse.ROUTER_HEARTBEAT -> message.heartbeat != null + StoreAndForward.RequestResponse.ROUTER_HISTORY -> message.history != null + StoreAndForward.RequestResponse.ROUTER_STATS -> message.stats != null + StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST, StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, -> message.text != null @@ -235,6 +245,7 @@ internal class StoreForwardApiImpl( -> handleLinkProvide(sfpp) StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp) + else -> Unit } } @@ -253,6 +264,7 @@ internal class StoreForwardApiImpl( confirmed = confirmed, messageHash = when { providedHash != null -> providedHash + !isFragment && sfpp.message.size != 0 -> SfppHash.compute( payload = sfpp.message.toByteArray(), to = normalizedTo, @@ -424,11 +436,7 @@ internal class StoreForwardApiImpl( override fun hashCode(): Int = ((packetId * 31) + response.hashCode()) * 31 + payload.contentHashCode() } - private data class SfppFragmentKey( - val packetId: Int, - val from: Int, - val to: Int, - ) + private data class SfppFragmentKey(val packetId: Int, val from: Int, val to: Int) private data class SfppFragmentState( val firstHalf: ByteArray? = null, diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt index 0397b80..386776c 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt @@ -596,7 +596,8 @@ class AdminApiImplComprehensiveTest { @Test fun addContactSendsSharedContact() = runTest { - val contact = SharedContact(node_num = 77, user = User(id = "!0000004d", long_name = "Contact", short_name = "CT")) + val contact = + SharedContact(node_num = 77, user = User(id = "!0000004d", long_name = "Contact", short_name = "CT")) assertAckedOperation( call = { it.addContact(contact) }, requestMatches = { it.add_contact == contact }, @@ -795,7 +796,8 @@ class AdminApiImplComprehensiveTest { val result = client.admin.setTime(instant) runCurrent() - val packet = latestAdminPacket(transport, outboundBefore) { it.set_time_only == instant.epochSeconds.toInt() } + val packet = + latestAdminPacket(transport, outboundBefore) { it.set_time_only == instant.epochSeconds.toInt() } assertIs>(result) assertFalse(packet.want_ack) assertEquals(false, packet.decoded?.want_response) @@ -841,7 +843,11 @@ class AdminApiImplComprehensiveTest { val packet = latestAdminPacket(transport, outboundBefore) { it.get_device_metadata_request == true } assertEquals(remote.raw, packet.to) - transport.injectAdminResponse(packet.id, AdminMessage(get_device_metadata_response = expected), fromNode = remote.raw) + transport.injectAdminResponse( + packet.id, + AdminMessage(get_device_metadata_response = expected), + fromNode = remote.raw, + ) runCurrent() assertEquals(AdminResult.Success(expected), deferred.await()) @@ -857,8 +863,12 @@ class AdminApiImplComprehensiveTest { val (transport, client) = connectedClient( frames = handshakeFrames( org.meshtastic.proto.FromRadio(metadata = DeviceMetadata(firmware_version = "2.5.0")), - org.meshtastic.proto.FromRadio(config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))), - org.meshtastic.proto.FromRadio(moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = false))), + org.meshtastic.proto.FromRadio( + config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)), + ), + org.meshtastic.proto.FromRadio( + moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = false)), + ), ), ) client.connect() @@ -898,8 +908,12 @@ class AdminApiImplComprehensiveTest { val (transport, client) = connectedClient( frames = handshakeFrames( org.meshtastic.proto.FromRadio(metadata = DeviceMetadata(firmware_version = "2.5.0")), - org.meshtastic.proto.FromRadio(config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))), - org.meshtastic.proto.FromRadio(moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = false))), + org.meshtastic.proto.FromRadio( + config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)), + ), + org.meshtastic.proto.FromRadio( + moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = false)), + ), ), ) client.connect() diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt index 89ef94d..7f657c4 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt @@ -1,3 +1,10 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ @file:Suppress("DEPRECATION") /* @@ -7,6 +14,7 @@ * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) * SPDX-License-Identifier: GPL-3.0-or-later */ + package org.meshtastic.sdk import kotlinx.coroutines.test.runTest @@ -353,10 +361,7 @@ class ConfigBuildersTest { ) } - private suspend fun assertConfigWrite( - expected: Config, - call: suspend CapturingAdminApi.() -> AdminResult, - ) { + private suspend fun assertConfigWrite(expected: Config, call: suspend CapturingAdminApi.() -> AdminResult) { val admin = CapturingAdminApi() assertEquals(AdminResult.Success(Unit), admin.call()) assertEquals(listOf(expected), admin.configs) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt index 194d442..4acce38 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt @@ -185,9 +185,11 @@ class ExternalConfigChangeTest { runCurrent() // Inject a channel response WITH a non-zero request_id (simulates response to our RPC) - val payload = okio.ByteString.of(*AdminMessage.ADAPTER.encode( - AdminMessage(get_channel_response = Channel(index = 0, role = Channel.Role.PRIMARY)) - )) + val payload = okio.ByteString.of( + *AdminMessage.ADAPTER.encode( + AdminMessage(get_channel_response = Channel(index = 0, role = Channel.Role.PRIMARY)), + ), + ) val packet = MeshPacket( from = transport.nodeNum, to = 0, diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt index 77d9053..369860e 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt @@ -209,7 +209,8 @@ class HandshakeAndReconnectTest { assertIs(firstConnect.await().exceptionOrNull()) assertEquals(ConnectionState.Disconnected, firstClient.connection.value) - val retryClient = buildClient(ScriptedTransport(TransportIdentity("fake:drop-stage1-retry"), nowMs = { currentTime })) + val retryClient = + buildClient(ScriptedTransport(TransportIdentity("fake:drop-stage1-retry"), nowMs = { currentTime })) retryClient.connect() assertEquals(ConnectionState.Connected, retryClient.connection.value) } @@ -231,7 +232,8 @@ class HandshakeAndReconnectTest { assertIs(firstConnect.await().exceptionOrNull()) assertEquals(ConnectionState.Disconnected, firstClient.connection.value) - val retryClient = buildClient(ScriptedTransport(TransportIdentity("fake:drop-stage2-retry"), nowMs = { currentTime })) + val retryClient = + buildClient(ScriptedTransport(TransportIdentity("fake:drop-stage2-retry"), nowMs = { currentTime })) retryClient.connect() advanceTimeBy(STAGE2_PROGRESS_TICK_MS + 5_000L) runCurrent() @@ -257,7 +259,12 @@ class HandshakeAndReconnectTest { assertIs(error) assertEquals(ConnectionState.Disconnected, firstClient.connection.value) - val retryClient = buildClient(ScriptedTransport(TransportIdentity("fake:stage2-timeout-retry-success"), nowMs = { currentTime })) + val retryClient = + buildClient( + ScriptedTransport(TransportIdentity("fake:stage2-timeout-retry-success"), nowMs = { + currentTime + }), + ) retryClient.connect() advanceTimeBy(STAGE2_PROGRESS_TICK_MS + 5_000L) runCurrent() @@ -319,7 +326,9 @@ class HandshakeAndReconnectTest { val outboundBefore = transport.outboundPackets().size val expected = org.meshtastic.proto.Config( - lora = org.meshtastic.proto.Config.LoRaConfig(region = org.meshtastic.proto.Config.LoRaConfig.RegionCode.US), + lora = org.meshtastic.proto.Config.LoRaConfig( + region = org.meshtastic.proto.Config.LoRaConfig.RegionCode.US, + ), ) val deferred = backgroundScope.async { client.admin.forNode(remoteNode).getConfig(AdminMessage.ConfigType.LORA_CONFIG) @@ -699,7 +708,12 @@ class HandshakeAndReconnectTest { override suspend fun connect() { stateFlow.value = TransportState.Connecting - val shouldFail = if (connectTimes.isEmpty()) ConnectOutcome.Success else connectPlan.removeFirstOrNull() ?: ConnectOutcome.Success + val shouldFail = if (connectTimes.isEmpty()) { + ConnectOutcome.Success + } else { + connectPlan.removeFirstOrNull() + ?: ConnectOutcome.Success + } val attemptedAt = nowMs() if (shouldFail is ConnectOutcome.Fail) { connectTimes += attemptedAt @@ -790,7 +804,9 @@ class HandshakeAndReconnectTest { return } if (!autoCompleteStage1) return - inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(my_info = MyNodeInfo(my_node_num = nodeNum)))) + inbound.tryEmit( + encodeFromRadioFrame(org.meshtastic.proto.FromRadio(my_info = MyNodeInfo(my_node_num = nodeNum))), + ) inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(config_complete_id = NONCE_STAGE1))) } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt index 0a73977..f1e46ae 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt @@ -70,7 +70,13 @@ class MeshEngineEdgeCasesTest { } client.connect() - transport.injectFrame(rawFrame(encodedFromRadio(FromRadio(node_info = org.meshtastic.proto.NodeInfo(num = 7))), header0 = 0x00, header1 = 0x00)) + transport.injectFrame( + rawFrame( + encodedFromRadio(FromRadio(node_info = org.meshtastic.proto.NodeInfo(num = 7))), + header0 = 0x00, + header1 = 0x00, + ), + ) runCurrent() assertEquals(ConnectionState.Connected, client.connection.value) @@ -724,13 +730,10 @@ class MeshEngineEdgeCasesTest { private fun decodeToRadioOrNull(frame: Frame): ToRadio? { val bytes = frame.bytes.toByteArray() if (bytes.size < WireFraming.HEADER_SIZE) return null - return runCatching { ToRadio.ADAPTER.decode(bytes.copyOfRange(WireFraming.HEADER_SIZE, bytes.size)) }.getOrNull() + return runCatching { + ToRadio.ADAPTER.decode(bytes.copyOfRange(WireFraming.HEADER_SIZE, bytes.size)) + }.getOrNull() } - private data class CapturedLog( - val level: LogLevel, - val tag: String, - val message: String, - val cause: Throwable?, - ) + private data class CapturedLog(val level: LogLevel, val tag: String, val message: String, val cause: Throwable?) } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt index e4d5313..877ef7a 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt @@ -740,9 +740,8 @@ class P2AdminRpcTest { private fun adminPacketsSince( transport: FakeRadioTransport, outboundBefore: Int, - ): List> = - transport.outboundPackets().drop(outboundBefore) - .mapNotNull { packet -> adminOf(packet)?.let { packet to it } } + ): List> = transport.outboundPackets().drop(outboundBefore) + .mapNotNull { packet -> adminOf(packet)?.let { packet to it } } private fun buildRoutingErrorFrame(requestId: Int, error: Routing.Error): Frame { val payload = okio.ByteString.of(*Routing.ADAPTER.encode(Routing(error_reason = error))) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt index bf6b1f0..ba81666 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing @@ -22,6 +21,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.time.Duration.Companion.seconds +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo @OptIn(ExperimentalCoroutinesApi::class) class P2RoutingRpcTest { diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt index ba2d2ad..5006423 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt @@ -349,12 +349,13 @@ class RadioClientSendTest { } } - private fun TestScope.buildClient(transport: FakeRadioTransport = buildTransport()): RadioClient = RadioClient.Builder() - .transport(transport) - .storage(InMemoryStorageProvider()) - .coroutineContext(backgroundScope.coroutineContext) - .autoSyncTimeOnConnect(false) - .build() + private fun TestScope.buildClient(transport: FakeRadioTransport = buildTransport()): RadioClient = + RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .build() private suspend fun TestScope.withConnectedClient(block: suspend (RadioClient, FakeRadioTransport) -> Unit) { val transport = buildTransport() diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt index b6a8959..696e670 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt @@ -314,10 +314,9 @@ class StoreForwardApiStatsTest { ) } - private fun FakeRadioTransport.lastStoreForwardRequest(outboundBefore: Int) = - outboundPackets() - .drop(outboundBefore) - .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + private fun FakeRadioTransport.lastStoreForwardRequest(outboundBefore: Int) = outboundPackets() + .drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } private class SchedulerClock(private val nowMs: () -> Long) : kotlin.time.Clock { override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs()) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt index d808069..ddaf652 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt @@ -33,7 +33,6 @@ import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours - import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) @@ -84,8 +83,14 @@ class StoreForwardProtocolTest { runCurrent() assertEquals(listOf(server), client.storeForward.servers.value) - assertEquals(listOf(StoreForwardEvent.ServerDiscovered(server)), observed.filterIsInstance()) - assertEquals(listOf(StoreForwardEvent.Heartbeat(server)), observed.filterIsInstance()) + assertEquals( + listOf(StoreForwardEvent.ServerDiscovered(server)), + observed.filterIsInstance(), + ) + assertEquals( + listOf(StoreForwardEvent.Heartbeat(server)), + observed.filterIsInstance(), + ) collector.cancel() client.disconnect() @@ -401,7 +406,8 @@ class StoreForwardProtocolTest { client.packets.collect { packet -> val decoded = packet.decoded ?: return@collect if (decoded.portnum != PortNum.STORE_FORWARD_APP) return@collect - val message = runCatching { StoreAndForward.ADAPTER.decode(decoded.payload) }.getOrNull() ?: return@collect + val message = + runCatching { StoreAndForward.ADAPTER.decode(decoded.payload) }.getOrNull() ?: return@collect if (message.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT || message.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST ) { @@ -472,7 +478,12 @@ class StoreForwardProtocolTest { runCurrent() assertTrue(client.storeForward.servers.value.isEmpty()) - assertEquals(setOf(first, second), observed.filterIsInstance().map { it.nodeId }.toSet()) + assertEquals( + setOf(first, second), + observed.filterIsInstance().map { + it.nodeId + }.toSet(), + ) collector.cancel() } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt index 481355d..818e523 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt @@ -83,7 +83,11 @@ class TelemetryApiTest { relative_humidity = 62.0f, barometric_pressure = 1013.2f, ) - transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(environment_metrics = expected), fromNode = node.raw) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(environment_metrics = expected), + fromNode = node.raw, + ) runCurrent() val result = deferred.await() @@ -110,7 +114,11 @@ class TelemetryApiTest { pm100_standard = 20, particles_03um = 41, ) - transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(air_quality_metrics = expected), fromNode = node.raw) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(air_quality_metrics = expected), + fromNode = node.raw, + ) runCurrent() val result = deferred.await() @@ -132,7 +140,11 @@ class TelemetryApiTest { val request = transport.lastTelemetryRequest(outboundBefore) val expected = PowerMetrics(ch1_voltage = 4.18f, ch1_current = 0.42f, ch2_voltage = 5.0f) - transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(power_metrics = expected), fromNode = node.raw) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(power_metrics = expected), + fromNode = node.raw, + ) runCurrent() val result = deferred.await() @@ -161,7 +173,11 @@ class TelemetryApiTest { num_packets_rx = 9, num_online_nodes = 3, ) - transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(local_stats = expected), fromNode = localNodeNum) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(local_stats = expected), + fromNode = localNodeNum, + ) runCurrent() val result = deferred.await() @@ -183,7 +199,11 @@ class TelemetryApiTest { val request = transport.lastTelemetryRequest(outboundBefore) val expected = HealthMetrics(heart_bpm = 72, spO2 = 98, temperature = 36.7f) - transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(health_metrics = expected), fromNode = node.raw) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(health_metrics = expected), + fromNode = node.raw, + ) runCurrent() val result = deferred.await() @@ -212,7 +232,11 @@ class TelemetryApiTest { load5 = 17, load15 = 11, ) - transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(host_metrics = expected), fromNode = node.raw) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(host_metrics = expected), + fromNode = node.raw, + ) runCurrent() val result = deferred.await() diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt index af0d9ec..a65ca5d 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt @@ -7,12 +7,12 @@ */ package org.meshtastic.sdk +import org.meshtastic.proto.Channel import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue -import org.meshtastic.proto.Channel class ChannelHelpersTest { @Test diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt index 68ead79..9e843cc 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt @@ -7,11 +7,11 @@ */ package org.meshtastic.sdk -import kotlin.time.Duration.Companion.seconds import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds class CongestionTest { @Test fun levelIsLowWhenBothMetricsAreBelowMediumThreshold() { diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt index 7434e20..63cb8b9 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt @@ -196,7 +196,10 @@ class PayloadAccessorsTest { @Test fun tracerouteDecodes() { val route = org.meshtastic.proto.RouteDiscovery(route = listOf(100, 200, 300)) - val decoded = pkt(PortNum.TRACEROUTE_APP, org.meshtastic.proto.RouteDiscovery.ADAPTER.encode(route)).asTraceroute() + val decoded = pkt( + PortNum.TRACEROUTE_APP, + org.meshtastic.proto.RouteDiscovery.ADAPTER.encode(route), + ).asTraceroute() assertNotNull(decoded) assertEquals(listOf(100, 200, 300), decoded.route) } @@ -208,7 +211,10 @@ class PayloadAccessorsTest { @Test fun neighborInfoDecodes() { val ni = org.meshtastic.proto.NeighborInfo(node_id = 0xABCD, last_sent_by_id = 0x1234) - val decoded = pkt(PortNum.NEIGHBORINFO_APP, org.meshtastic.proto.NeighborInfo.ADAPTER.encode(ni)).asNeighborInfo() + val decoded = pkt( + PortNum.NEIGHBORINFO_APP, + org.meshtastic.proto.NeighborInfo.ADAPTER.encode(ni), + ).asNeighborInfo() assertNotNull(decoded) assertEquals(0xABCD, decoded.node_id) assertEquals(0x1234, decoded.last_sent_by_id) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt index e526792..8adfa52 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt @@ -151,13 +151,10 @@ class PresenceTimerTest { } } -private class SeededHeartbeatStorageProvider( - private val heartbeats: Map, -) : StorageProvider { - override suspend fun activate(identity: TransportIdentity): DeviceStorage = - InMemoryStorage().also { storage -> - heartbeats.forEach { (nodeId, heartbeatMs) -> - storage.saveHeartbeat(nodeId, heartbeatMs) - } +private class SeededHeartbeatStorageProvider(private val heartbeats: Map) : StorageProvider { + override suspend fun activate(identity: TransportIdentity): DeviceStorage = InMemoryStorage().also { storage -> + heartbeats.forEach { (nodeId, heartbeatMs) -> + storage.saveHeartbeat(nodeId, heartbeatMs) } + } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt index 8cb59c6..e752317 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt @@ -7,10 +7,10 @@ */ package org.meshtastic.sdk -import kotlin.time.Duration.Companion.seconds import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds class RetryPolicyTest { @Test fun noneReturnsNullImmediately() { diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt index 4a2e3aa..fda5f06 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt @@ -7,7 +7,9 @@ */ package org.meshtastic.sdk.ext -import org.meshtastic.sdk.* +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.StoreForwardEvent +import org.meshtastic.sdk.StoreForwardStats import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs diff --git a/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt b/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt index 0592609..8b7aaab 100644 --- a/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt +++ b/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt @@ -250,17 +250,11 @@ class StoreForwardApiImplSfppTest { client.disconnect() } - private fun FakeRadioTransport.injectSfpp( - message: StoreForwardPlusPlus, - fromNode: Int = 0x10203040, - ) { + private fun FakeRadioTransport.injectSfpp(message: StoreForwardPlusPlus, fromNode: Int = 0x10203040) { injectStoreForwardPayload(StoreForwardPlusPlus.ADAPTER.encode(message), fromNode) } - private fun FakeRadioTransport.injectStoreForwardPayload( - payload: ByteArray, - fromNode: Int = 0x10203040, - ) { + private fun FakeRadioTransport.injectStoreForwardPayload(payload: ByteArray, fromNode: Int = 0x10203040) { injectPacket( MeshPacket( id = 1, diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt index 0a51d01..79b6a18 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt @@ -181,19 +181,16 @@ internal object Scenarios { * within [budget]; FAIL on any failure outcome or timeout. Unlike cs2 (broadcast), this * exercises the full send → routing-ACK path. */ - suspend fun cs7UnicastDmText( - client: RadioClient, - peer: NodeId, - budget: Duration = 30.seconds, - ): ScenarioResult = runScenario("cs7", "unicast DM to $peer") { - val handle = client.sendDirectMessage(to = peer, text = "dm conformance probe") - val outcome = withTimeoutOrNull(budget) { handle.await() } - ?: error("did not reach terminal state in ${budget.inWholeSeconds}s") - when (outcome) { - SendOutcome.Success -> "id=${handle.id} acked by ${peer}" - is SendOutcome.Failure -> error("failed: ${outcome.reason::class.simpleName}") + suspend fun cs7UnicastDmText(client: RadioClient, peer: NodeId, budget: Duration = 30.seconds): ScenarioResult = + runScenario("cs7", "unicast DM to $peer") { + val handle = client.sendDirectMessage(to = peer, text = "dm conformance probe") + val outcome = withTimeoutOrNull(budget) { handle.await() } + ?: error("did not reach terminal state in ${budget.inWholeSeconds}s") + when (outcome) { + SendOutcome.Success -> "id=${handle.id} acked by $peer" + is SendOutcome.Failure -> error("failed: ${outcome.reason::class.simpleName}") + } } - } /** * Wrap a single-scenario block: time it, catch every exception, and produce a From d3c22e239f67b98be72c2e24c28e0a0367c8f72f Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 7 May 2026 07:42:11 -0500 Subject: [PATCH 34/36] Update core ABI dump Regenerated via :core:updateKotlinAbi to reflect new public API surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/api/core.klib.api | 793 ++++++++++++++++++++++++++++++++++++++++- core/api/jvm/core.api | 766 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 1549 insertions(+), 10 deletions(-) diff --git a/core/api/core.klib.api b/core/api/core.klib.api index 218c9c2..7ed643b 100644 --- a/core/api/core.klib.api +++ b/core/api/core.klib.api @@ -22,6 +22,32 @@ final enum class org.meshtastic.sdk/ConfigPhase : kotlin/Enum // org.meshtastic.sdk/ConfigPhase.values|values#static(){}[0] } +final enum class org.meshtastic.sdk/CongestionLevel : kotlin/Enum { // org.meshtastic.sdk/CongestionLevel|null[0] + enum entry CRITICAL // org.meshtastic.sdk/CongestionLevel.CRITICAL|null[0] + enum entry HIGH // org.meshtastic.sdk/CongestionLevel.HIGH|null[0] + enum entry LOW // org.meshtastic.sdk/CongestionLevel.LOW|null[0] + enum entry MEDIUM // org.meshtastic.sdk/CongestionLevel.MEDIUM|null[0] + + final val entries // org.meshtastic.sdk/CongestionLevel.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.meshtastic.sdk/CongestionLevel.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.meshtastic.sdk/CongestionLevel // org.meshtastic.sdk/CongestionLevel.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.meshtastic.sdk/CongestionLevel.values|values#static(){}[0] +} + +final enum class org.meshtastic.sdk/ConnectionQuality : kotlin/Enum { // org.meshtastic.sdk/ConnectionQuality|null[0] + enum entry DIRECT // org.meshtastic.sdk/ConnectionQuality.DIRECT|null[0] + enum entry MQTT // org.meshtastic.sdk/ConnectionQuality.MQTT|null[0] + enum entry RELAYED // org.meshtastic.sdk/ConnectionQuality.RELAYED|null[0] + enum entry UNKNOWN // org.meshtastic.sdk/ConnectionQuality.UNKNOWN|null[0] + + final val entries // org.meshtastic.sdk/ConnectionQuality.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.meshtastic.sdk/ConnectionQuality.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.meshtastic.sdk/ConnectionQuality // org.meshtastic.sdk/ConnectionQuality.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.meshtastic.sdk/ConnectionQuality.values|values#static(){}[0] +} + final enum class org.meshtastic.sdk/DroppedFlow : kotlin/Enum { // org.meshtastic.sdk/DroppedFlow|null[0] enum entry Events // org.meshtastic.sdk/DroppedFlow.Events|null[0] enum entry Packets // org.meshtastic.sdk/DroppedFlow.Packets|null[0] @@ -33,6 +59,18 @@ final enum class org.meshtastic.sdk/DroppedFlow : kotlin/Enum // org.meshtastic.sdk/DroppedFlow.values|values#static(){}[0] } +final enum class org.meshtastic.sdk/ExternalChangeKind : kotlin/Enum { // org.meshtastic.sdk/ExternalChangeKind|null[0] + enum entry CHANNEL // org.meshtastic.sdk/ExternalChangeKind.CHANNEL|null[0] + enum entry CONFIG // org.meshtastic.sdk/ExternalChangeKind.CONFIG|null[0] + enum entry MODULE_CONFIG // org.meshtastic.sdk/ExternalChangeKind.MODULE_CONFIG|null[0] + + final val entries // org.meshtastic.sdk/ExternalChangeKind.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.meshtastic.sdk/ExternalChangeKind.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.meshtastic.sdk/ExternalChangeKind // org.meshtastic.sdk/ExternalChangeKind.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.meshtastic.sdk/ExternalChangeKind.values|values#static(){}[0] +} + final enum class org.meshtastic.sdk/LogLevel : kotlin/Enum { // org.meshtastic.sdk/LogLevel|null[0] enum entry DEBUG // org.meshtastic.sdk/LogLevel.DEBUG|null[0] enum entry ERROR // org.meshtastic.sdk/LogLevel.ERROR|null[0] @@ -66,6 +104,19 @@ final enum class org.meshtastic.sdk/NodeField : kotlin/Enum // org.meshtastic.sdk/NodeField.values|values#static(){}[0] } +final enum class org.meshtastic.sdk/SignalQuality : kotlin/Enum { // org.meshtastic.sdk/SignalQuality|null[0] + enum entry FAIR // org.meshtastic.sdk/SignalQuality.FAIR|null[0] + enum entry GOOD // org.meshtastic.sdk/SignalQuality.GOOD|null[0] + enum entry NONE // org.meshtastic.sdk/SignalQuality.NONE|null[0] + enum entry POOR // org.meshtastic.sdk/SignalQuality.POOR|null[0] + + final val entries // org.meshtastic.sdk/SignalQuality.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.meshtastic.sdk/SignalQuality.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.meshtastic.sdk/SignalQuality // org.meshtastic.sdk/SignalQuality.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.meshtastic.sdk/SignalQuality.values|values#static(){}[0] +} + abstract fun interface org.meshtastic.sdk/LogSink { // org.meshtastic.sdk/LogSink|null[0] abstract fun log(org.meshtastic.sdk/LogLevel, kotlin/String, kotlin/String, kotlin/Throwable?) // org.meshtastic.sdk/LogSink.log|log(org.meshtastic.sdk.LogLevel;kotlin.String;kotlin.String;kotlin.Throwable?){}[0] @@ -76,23 +127,59 @@ abstract fun interface org.meshtastic.sdk/LogSink { // org.meshtastic.sdk/LogSin } abstract interface org.meshtastic.sdk/AdminApi { // org.meshtastic.sdk/AdminApi|null[0] + abstract fun forNode(org.meshtastic.sdk/NodeId): org.meshtastic.sdk/AdminApi // org.meshtastic.sdk/AdminApi.forNode|forNode(org.meshtastic.sdk.NodeId){}[0] + abstract suspend fun <#A1: kotlin/Any?> batch(kotlin.coroutines/SuspendFunction1): #A1 // org.meshtastic.sdk/AdminApi.batch|batch(kotlin.coroutines.SuspendFunction1){0§}[0] abstract suspend fun <#A1: kotlin/Any?> editSettings(kotlin.coroutines/SuspendFunction1): org.meshtastic.sdk/AdminResult<#A1> // org.meshtastic.sdk/AdminApi.editSettings|editSettings(kotlin.coroutines.SuspendFunction1){0§}[0] + abstract suspend fun addContact(org.meshtastic.proto/SharedContact): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.addContact|addContact(org.meshtastic.proto.SharedContact){}[0] + abstract suspend fun backupPreferences(org.meshtastic.proto/AdminMessage.BackupLocation = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.backupPreferences|backupPreferences(org.meshtastic.proto.AdminMessage.BackupLocation){}[0] + abstract suspend fun deleteFile(kotlin/String): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.deleteFile|deleteFile(kotlin.String){}[0] + abstract suspend fun enterDfuMode(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.enterDfuMode|enterDfuMode(){}[0] + abstract suspend fun exitSimulator(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.exitSimulator|exitSimulator(){}[0] abstract suspend fun factoryReset(kotlin/Boolean = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.factoryReset|factoryReset(kotlin.Boolean){}[0] + abstract suspend fun getCannedMessages(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getCannedMessages|getCannedMessages(){}[0] abstract suspend fun getChannel(org.meshtastic.sdk/ChannelIndex): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getChannel|getChannel(org.meshtastic.sdk.ChannelIndex){}[0] abstract suspend fun getConfig(org.meshtastic.proto/AdminMessage.ConfigType): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getConfig|getConfig(org.meshtastic.proto.AdminMessage.ConfigType){}[0] + abstract suspend fun getDeviceConnectionStatus(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getDeviceConnectionStatus|getDeviceConnectionStatus(){}[0] + abstract suspend fun getDeviceMetadata(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getDeviceMetadata|getDeviceMetadata(){}[0] abstract suspend fun getModuleConfig(org.meshtastic.proto/AdminMessage.ModuleConfigType): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getModuleConfig|getModuleConfig(org.meshtastic.proto.AdminMessage.ModuleConfigType){}[0] abstract suspend fun getOwner(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getOwner|getOwner(){}[0] + abstract suspend fun getRemoteHardwarePins(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getRemoteHardwarePins|getRemoteHardwarePins(){}[0] + abstract suspend fun getRingtone(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getRingtone|getRingtone(){}[0] + abstract suspend fun getUIConfig(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getUIConfig|getUIConfig(){}[0] + abstract suspend fun keyVerification(org.meshtastic.proto/KeyVerificationAdmin): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.keyVerification|keyVerification(org.meshtastic.proto.KeyVerificationAdmin){}[0] abstract suspend fun listChannels(): org.meshtastic.sdk/AdminResult> // org.meshtastic.sdk/AdminApi.listChannels|listChannels(){}[0] - abstract suspend fun nodeDbReset(kotlin/Boolean = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.nodeDbReset|nodeDbReset(kotlin.Boolean){}[0] + abstract suspend fun nodeDbReset(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.nodeDbReset|nodeDbReset(){}[0] + abstract suspend fun otaRequest(org.meshtastic.proto/AdminMessage.OTAEvent): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.otaRequest|otaRequest(org.meshtastic.proto.AdminMessage.OTAEvent){}[0] abstract suspend fun reboot(kotlin.time/Duration = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.reboot|reboot(kotlin.time.Duration){}[0] + abstract suspend fun rebootOta(kotlin.time/Duration = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.rebootOta|rebootOta(kotlin.time.Duration){}[0] + abstract suspend fun removeBackupPreferences(org.meshtastic.proto/AdminMessage.BackupLocation = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.removeBackupPreferences|removeBackupPreferences(org.meshtastic.proto.AdminMessage.BackupLocation){}[0] + abstract suspend fun removeFixedPosition(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.removeFixedPosition|removeFixedPosition(){}[0] + abstract suspend fun removeNode(org.meshtastic.sdk/NodeId): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.removeNode|removeNode(org.meshtastic.sdk.NodeId){}[0] + abstract suspend fun restorePreferences(org.meshtastic.proto/AdminMessage.BackupLocation = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.restorePreferences|restorePreferences(org.meshtastic.proto.AdminMessage.BackupLocation){}[0] + abstract suspend fun sendInputEvent(org.meshtastic.proto/AdminMessage.InputEvent): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.sendInputEvent|sendInputEvent(org.meshtastic.proto.AdminMessage.InputEvent){}[0] + abstract suspend fun setCannedMessages(kotlin/String): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setCannedMessages|setCannedMessages(kotlin.String){}[0] abstract suspend fun setChannel(org.meshtastic.proto/Channel): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setChannel|setChannel(org.meshtastic.proto.Channel){}[0] abstract suspend fun setConfig(org.meshtastic.proto/Config): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setConfig|setConfig(org.meshtastic.proto.Config){}[0] abstract suspend fun setFavorite(org.meshtastic.sdk/NodeId, kotlin/Boolean): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setFavorite|setFavorite(org.meshtastic.sdk.NodeId;kotlin.Boolean){}[0] + abstract suspend fun setFixedPosition(org.meshtastic.proto/Position): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setFixedPosition|setFixedPosition(org.meshtastic.proto.Position){}[0] + abstract suspend fun setHamMode(org.meshtastic.proto/HamParameters): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setHamMode|setHamMode(org.meshtastic.proto.HamParameters){}[0] abstract suspend fun setIgnored(org.meshtastic.sdk/NodeId, kotlin/Boolean): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setIgnored|setIgnored(org.meshtastic.sdk.NodeId;kotlin.Boolean){}[0] abstract suspend fun setModuleConfig(org.meshtastic.proto/ModuleConfig): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setModuleConfig|setModuleConfig(org.meshtastic.proto.ModuleConfig){}[0] abstract suspend fun setOwner(org.meshtastic.proto/User): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setOwner|setOwner(org.meshtastic.proto.User){}[0] + abstract suspend fun setRingtone(kotlin/String): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setRingtone|setRingtone(kotlin.String){}[0] + abstract suspend fun setScale(kotlin/Int): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setScale|setScale(kotlin.Int){}[0] + abstract suspend fun setSensorConfig(org.meshtastic.proto/SensorConfig): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setSensorConfig|setSensorConfig(org.meshtastic.proto.SensorConfig){}[0] abstract suspend fun setTime(kotlin.time/Instant? = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setTime|setTime(kotlin.time.Instant?){}[0] + abstract suspend fun setTimeOnly(kotlin/Int): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setTimeOnly|setTimeOnly(kotlin.Int){}[0] abstract suspend fun shutdown(kotlin.time/Duration = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.shutdown|shutdown(kotlin.time.Duration){}[0] + abstract suspend fun storeUIConfig(org.meshtastic.proto/DeviceUIConfig): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.storeUIConfig|storeUIConfig(org.meshtastic.proto.DeviceUIConfig){}[0] + abstract suspend fun toggleMuted(org.meshtastic.sdk/NodeId): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.toggleMuted|toggleMuted(org.meshtastic.sdk.NodeId){}[0] +} + +abstract interface org.meshtastic.sdk/AdminBatchScope : org.meshtastic.sdk/AdminEdit { // org.meshtastic.sdk/AdminBatchScope|null[0] + abstract suspend fun getConfig(org.meshtastic.proto/AdminMessage.ConfigType): org.meshtastic.proto/Config // org.meshtastic.sdk/AdminBatchScope.getConfig|getConfig(org.meshtastic.proto.AdminMessage.ConfigType){}[0] + abstract suspend fun getModuleConfig(org.meshtastic.proto/AdminMessage.ModuleConfigType): org.meshtastic.proto/ModuleConfig // org.meshtastic.sdk/AdminBatchScope.getModuleConfig|getModuleConfig(org.meshtastic.proto.AdminMessage.ModuleConfigType){}[0] + abstract suspend fun listChannels(): kotlin.collections/List // org.meshtastic.sdk/AdminBatchScope.listChannels|listChannels(){}[0] } abstract interface org.meshtastic.sdk/AdminEdit { // org.meshtastic.sdk/AdminEdit|null[0] @@ -156,13 +243,26 @@ abstract interface org.meshtastic.sdk/StorageProvider { // org.meshtastic.sdk/St abstract suspend fun activate(org.meshtastic.sdk/TransportIdentity): org.meshtastic.sdk/DeviceStorage // org.meshtastic.sdk/StorageProvider.activate|activate(org.meshtastic.sdk.TransportIdentity){}[0] } +abstract interface org.meshtastic.sdk/StoreForwardApi { // org.meshtastic.sdk/StoreForwardApi|null[0] + abstract val events // org.meshtastic.sdk/StoreForwardApi.events|{}events[0] + abstract fun (): kotlinx.coroutines.flow/Flow // org.meshtastic.sdk/StoreForwardApi.events.|(){}[0] + abstract val servers // org.meshtastic.sdk/StoreForwardApi.servers|{}servers[0] + abstract fun (): kotlinx.coroutines.flow/StateFlow> // org.meshtastic.sdk/StoreForwardApi.servers.|(){}[0] + + abstract suspend fun requestHistory(kotlin/Int? = ..., org.meshtastic.sdk/NodeId? = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/StoreForwardApi.requestHistory|requestHistory(kotlin.Int?;org.meshtastic.sdk.NodeId?){}[0] + abstract suspend fun requestStats(org.meshtastic.sdk/NodeId? = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/StoreForwardApi.requestStats|requestStats(org.meshtastic.sdk.NodeId?){}[0] +} + abstract interface org.meshtastic.sdk/TelemetryApi { // org.meshtastic.sdk/TelemetryApi|null[0] abstract fun observe(org.meshtastic.sdk/NodeId): kotlinx.coroutines.flow/Flow // org.meshtastic.sdk/TelemetryApi.observe|observe(org.meshtastic.sdk.NodeId){}[0] abstract suspend fun requestAirQuality(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestAirQuality|requestAirQuality(org.meshtastic.sdk.NodeId){}[0] abstract suspend fun requestDevice(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestDevice|requestDevice(org.meshtastic.sdk.NodeId){}[0] abstract suspend fun requestEnvironment(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestEnvironment|requestEnvironment(org.meshtastic.sdk.NodeId){}[0] + abstract suspend fun requestHealth(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestHealth|requestHealth(org.meshtastic.sdk.NodeId){}[0] + abstract suspend fun requestHost(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestHost|requestHost(org.meshtastic.sdk.NodeId){}[0] abstract suspend fun requestLocalStats(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestLocalStats|requestLocalStats(){}[0] abstract suspend fun requestPower(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestPower|requestPower(org.meshtastic.sdk.NodeId){}[0] + abstract suspend fun requestTrafficManagement(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestTrafficManagement|requestTrafficManagement(org.meshtastic.sdk.NodeId){}[0] } sealed interface <#A: out kotlin/Any?> org.meshtastic.sdk/AdminResult { // org.meshtastic.sdk/AdminResult|null[0] @@ -198,6 +298,12 @@ sealed interface <#A: out kotlin/Any?> org.meshtastic.sdk/AdminResult { // org.m final fun toString(): kotlin/String // org.meshtastic.sdk/AdminResult.NodeUnreachable.toString|toString(){}[0] } + final object RateLimited : org.meshtastic.sdk/AdminResult { // org.meshtastic.sdk/AdminResult.RateLimited|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/AdminResult.RateLimited.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/AdminResult.RateLimited.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/AdminResult.RateLimited.toString|toString(){}[0] + } + final object SessionKeyExpired : org.meshtastic.sdk/AdminResult { // org.meshtastic.sdk/AdminResult.SessionKeyExpired|null[0] final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/AdminResult.SessionKeyExpired.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // org.meshtastic.sdk/AdminResult.SessionKeyExpired.hashCode|hashCode(){}[0] @@ -291,6 +397,19 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| } } + final class CongestionWarning : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.CongestionWarning|null[0] + constructor (org.meshtastic.sdk/CongestionMetrics) // org.meshtastic.sdk/MeshEvent.CongestionWarning.|(org.meshtastic.sdk.CongestionMetrics){}[0] + + final val metrics // org.meshtastic.sdk/MeshEvent.CongestionWarning.metrics|{}metrics[0] + final fun (): org.meshtastic.sdk/CongestionMetrics // org.meshtastic.sdk/MeshEvent.CongestionWarning.metrics.|(){}[0] + + final fun component1(): org.meshtastic.sdk/CongestionMetrics // org.meshtastic.sdk/MeshEvent.CongestionWarning.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/CongestionMetrics = ...): org.meshtastic.sdk/MeshEvent.CongestionWarning // org.meshtastic.sdk/MeshEvent.CongestionWarning.copy|copy(org.meshtastic.sdk.CongestionMetrics){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.CongestionWarning.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.CongestionWarning.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.CongestionWarning.toString|toString(){}[0] + } + final class DeviceRebooted : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.DeviceRebooted|null[0] constructor (kotlin/String = ...) // org.meshtastic.sdk/MeshEvent.DeviceRebooted.|(kotlin.String){}[0] @@ -304,6 +423,19 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.DeviceRebooted.toString|toString(){}[0] } + final class ExternalConfigChange : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.ExternalConfigChange|null[0] + constructor (org.meshtastic.sdk/ExternalChangeKind) // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.|(org.meshtastic.sdk.ExternalChangeKind){}[0] + + final val kind // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.kind|{}kind[0] + final fun (): org.meshtastic.sdk/ExternalChangeKind // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.kind.|(){}[0] + + final fun component1(): org.meshtastic.sdk/ExternalChangeKind // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/ExternalChangeKind = ...): org.meshtastic.sdk/MeshEvent.ExternalConfigChange // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.copy|copy(org.meshtastic.sdk.ExternalChangeKind){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.toString|toString(){}[0] + } + final class IdentityRebound : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.IdentityRebound|null[0] constructor (org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId, kotlin/String = ...) // org.meshtastic.sdk/MeshEvent.IdentityRebound.|(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId;kotlin.String){}[0] @@ -336,6 +468,19 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.KeyVerification.toString|toString(){}[0] } + final class MqttDisconnected : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.MqttDisconnected|null[0] + constructor (kotlin/String? = ...) // org.meshtastic.sdk/MeshEvent.MqttDisconnected.|(kotlin.String?){}[0] + + final val reason // org.meshtastic.sdk/MeshEvent.MqttDisconnected.reason|{}reason[0] + final fun (): kotlin/String? // org.meshtastic.sdk/MeshEvent.MqttDisconnected.reason.|(){}[0] + + final fun component1(): kotlin/String? // org.meshtastic.sdk/MeshEvent.MqttDisconnected.component1|component1(){}[0] + final fun copy(kotlin/String? = ...): org.meshtastic.sdk/MeshEvent.MqttDisconnected // org.meshtastic.sdk/MeshEvent.MqttDisconnected.copy|copy(kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.MqttDisconnected.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.MqttDisconnected.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.MqttDisconnected.toString|toString(){}[0] + } + final class Notification : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.Notification|null[0] constructor (org.meshtastic.proto/ClientNotification) // org.meshtastic.sdk/MeshEvent.Notification.|(org.meshtastic.proto.ClientNotification){}[0] @@ -419,6 +564,12 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.TransportError.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.TransportError.toString|toString(){}[0] } + + final object MqttConnected : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.MqttConnected|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.MqttConnected.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.MqttConnected.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.MqttConnected.toString|toString(){}[0] + } } sealed interface org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChange|null[0] @@ -435,6 +586,19 @@ sealed interface org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChang final fun toString(): kotlin/String // org.meshtastic.sdk/NodeChange.Added.toString|toString(){}[0] } + final class CameOnline : org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChange.CameOnline|null[0] + constructor (org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/NodeChange.CameOnline.|(org.meshtastic.sdk.NodeId){}[0] + + final val nodeId // org.meshtastic.sdk/NodeChange.CameOnline.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NodeChange.CameOnline.nodeId.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NodeChange.CameOnline.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/NodeChange.CameOnline // org.meshtastic.sdk/NodeChange.CameOnline.copy|copy(org.meshtastic.sdk.NodeId){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/NodeChange.CameOnline.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/NodeChange.CameOnline.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/NodeChange.CameOnline.toString|toString(){}[0] + } + final class Removed : org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChange.Removed|null[0] constructor (org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/NodeChange.Removed.|(org.meshtastic.sdk.NodeId){}[0] @@ -476,6 +640,22 @@ sealed interface org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChang final fun hashCode(): kotlin/Int // org.meshtastic.sdk/NodeChange.Updated.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // org.meshtastic.sdk/NodeChange.Updated.toString|toString(){}[0] } + + final class WentOffline : org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChange.WentOffline|null[0] + constructor (org.meshtastic.sdk/NodeId, kotlin/Int) // org.meshtastic.sdk/NodeChange.WentOffline.|(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + + final val lastHeard // org.meshtastic.sdk/NodeChange.WentOffline.lastHeard|{}lastHeard[0] + final fun (): kotlin/Int // org.meshtastic.sdk/NodeChange.WentOffline.lastHeard.|(){}[0] + final val nodeId // org.meshtastic.sdk/NodeChange.WentOffline.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NodeChange.WentOffline.nodeId.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NodeChange.WentOffline.component1|component1(){}[0] + final fun component2(): kotlin/Int // org.meshtastic.sdk/NodeChange.WentOffline.component2|component2(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., kotlin/Int = ...): org.meshtastic.sdk/NodeChange.WentOffline // org.meshtastic.sdk/NodeChange.WentOffline.copy|copy(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/NodeChange.WentOffline.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/NodeChange.WentOffline.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/NodeChange.WentOffline.toString|toString(){}[0] + } } sealed interface org.meshtastic.sdk/SendFailure { // org.meshtastic.sdk/SendFailure|null[0] @@ -620,6 +800,120 @@ sealed interface org.meshtastic.sdk/SendState { // org.meshtastic.sdk/SendState| } } +sealed interface org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent|null[0] + final class Heartbeat : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.Heartbeat|null[0] + constructor (org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.|(org.meshtastic.sdk.NodeId){}[0] + + final val server // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.server|{}server[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.server.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/StoreForwardEvent.Heartbeat // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.copy|copy(org.meshtastic.sdk.NodeId){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.toString|toString(){}[0] + } + + final class HistoryReplayComplete : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete|null[0] + constructor (org.meshtastic.sdk/NodeId, kotlin/Int) // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.|(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + + final val delivered // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.delivered|{}delivered[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.delivered.|(){}[0] + final val server // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.server|{}server[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.server.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.component1|component1(){}[0] + final fun component2(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.component2|component2(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., kotlin/Int = ...): org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.copy|copy(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.toString|toString(){}[0] + } + + final class HistoryReplayStarted : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted|null[0] + constructor (org.meshtastic.sdk/NodeId, kotlin/Int) // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.|(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + + final val messageCount // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.messageCount|{}messageCount[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.messageCount.|(){}[0] + final val server // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.server|{}server[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.server.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.component1|component1(){}[0] + final fun component2(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.component2|component2(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., kotlin/Int = ...): org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.copy|copy(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.toString|toString(){}[0] + } + + final class ServerDiscovered : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered|null[0] + constructor (org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.|(org.meshtastic.sdk.NodeId){}[0] + + final val nodeId // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.nodeId.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.copy|copy(org.meshtastic.sdk.NodeId){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.toString|toString(){}[0] + } + + final class ServerLost : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.ServerLost|null[0] + constructor (org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/StoreForwardEvent.ServerLost.|(org.meshtastic.sdk.NodeId){}[0] + + final val nodeId // org.meshtastic.sdk/StoreForwardEvent.ServerLost.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.ServerLost.nodeId.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.ServerLost.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/StoreForwardEvent.ServerLost // org.meshtastic.sdk/StoreForwardEvent.ServerLost.copy|copy(org.meshtastic.sdk.NodeId){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.ServerLost.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.ServerLost.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.ServerLost.toString|toString(){}[0] + } + + final class SfppCanonAnnounced : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced|null[0] + constructor (kotlin/ByteArray, kotlin/Long) // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.|(kotlin.ByteArray;kotlin.Long){}[0] + + final val messageHash // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.messageHash|{}messageHash[0] + final fun (): kotlin/ByteArray // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.messageHash.|(){}[0] + final val rxTime // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.rxTime|{}rxTime[0] + final fun (): kotlin/Long // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.rxTime.|(){}[0] + + final fun component1(): kotlin/ByteArray // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.component1|component1(){}[0] + final fun component2(): kotlin/Long // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.component2|component2(){}[0] + final fun copy(kotlin/ByteArray = ..., kotlin/Long = ...): org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.copy|copy(kotlin.ByteArray;kotlin.Long){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.toString|toString(){}[0] + } + + final class SfppLinkProvided : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided|null[0] + constructor (kotlin/Int, kotlin/Int, kotlin/Int, kotlin/ByteArray?, kotlin/Boolean) // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.|(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.ByteArray?;kotlin.Boolean){}[0] + + final val confirmed // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.confirmed|{}confirmed[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.confirmed.|(){}[0] + final val from // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.from|{}from[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.from.|(){}[0] + final val messageHash // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.messageHash|{}messageHash[0] + final fun (): kotlin/ByteArray? // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.messageHash.|(){}[0] + final val packetId // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.packetId|{}packetId[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.packetId.|(){}[0] + final val to // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.to|{}to[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.to.|(){}[0] + + final fun component1(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.component1|component1(){}[0] + final fun component2(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.component2|component2(){}[0] + final fun component3(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.component3|component3(){}[0] + final fun component4(): kotlin/ByteArray? // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.component4|component4(){}[0] + final fun component5(): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.component5|component5(){}[0] + final fun copy(kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/ByteArray? = ..., kotlin/Boolean = ...): org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.copy|copy(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.ByteArray?;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.toString|toString(){}[0] + } +} + sealed interface org.meshtastic.sdk/TransportSpec { // org.meshtastic.sdk/TransportSpec|null[0] open val identity // org.meshtastic.sdk/TransportSpec.identity|{}identity[0] open fun (): org.meshtastic.sdk/TransportIdentity // org.meshtastic.sdk/TransportSpec.identity.|(){}[0] @@ -788,10 +1082,12 @@ final class org.meshtastic.sdk/BatteryStatus { // org.meshtastic.sdk/BatteryStat } final class org.meshtastic.sdk/ConfigBundle { // org.meshtastic.sdk/ConfigBundle|null[0] - constructor (org.meshtastic.proto/MyNodeInfo, org.meshtastic.proto/DeviceMetadata, kotlin.collections/List, kotlin.collections/List) // org.meshtastic.sdk/ConfigBundle.|(org.meshtastic.proto.MyNodeInfo;org.meshtastic.proto.DeviceMetadata;kotlin.collections.List;kotlin.collections.List){}[0] + constructor (org.meshtastic.proto/MyNodeInfo, org.meshtastic.proto/DeviceMetadata, kotlin.collections/List, kotlin.collections/List, org.meshtastic.proto/DeviceUIConfig? = ...) // org.meshtastic.sdk/ConfigBundle.|(org.meshtastic.proto.MyNodeInfo;org.meshtastic.proto.DeviceMetadata;kotlin.collections.List;kotlin.collections.List;org.meshtastic.proto.DeviceUIConfig?){}[0] final val configs // org.meshtastic.sdk/ConfigBundle.configs|{}configs[0] final fun (): kotlin.collections/List // org.meshtastic.sdk/ConfigBundle.configs.|(){}[0] + final val deviceUIConfig // org.meshtastic.sdk/ConfigBundle.deviceUIConfig|{}deviceUIConfig[0] + final fun (): org.meshtastic.proto/DeviceUIConfig? // org.meshtastic.sdk/ConfigBundle.deviceUIConfig.|(){}[0] final val metadata // org.meshtastic.sdk/ConfigBundle.metadata|{}metadata[0] final fun (): org.meshtastic.proto/DeviceMetadata // org.meshtastic.sdk/ConfigBundle.metadata.|(){}[0] final val moduleConfigs // org.meshtastic.sdk/ConfigBundle.moduleConfigs|{}moduleConfigs[0] @@ -803,12 +1099,100 @@ final class org.meshtastic.sdk/ConfigBundle { // org.meshtastic.sdk/ConfigBundle final fun component2(): org.meshtastic.proto/DeviceMetadata // org.meshtastic.sdk/ConfigBundle.component2|component2(){}[0] final fun component3(): kotlin.collections/List // org.meshtastic.sdk/ConfigBundle.component3|component3(){}[0] final fun component4(): kotlin.collections/List // org.meshtastic.sdk/ConfigBundle.component4|component4(){}[0] - final fun copy(org.meshtastic.proto/MyNodeInfo = ..., org.meshtastic.proto/DeviceMetadata = ..., kotlin.collections/List = ..., kotlin.collections/List = ...): org.meshtastic.sdk/ConfigBundle // org.meshtastic.sdk/ConfigBundle.copy|copy(org.meshtastic.proto.MyNodeInfo;org.meshtastic.proto.DeviceMetadata;kotlin.collections.List;kotlin.collections.List){}[0] + final fun component5(): org.meshtastic.proto/DeviceUIConfig? // org.meshtastic.sdk/ConfigBundle.component5|component5(){}[0] + final fun copy(org.meshtastic.proto/MyNodeInfo = ..., org.meshtastic.proto/DeviceMetadata = ..., kotlin.collections/List = ..., kotlin.collections/List = ..., org.meshtastic.proto/DeviceUIConfig? = ...): org.meshtastic.sdk/ConfigBundle // org.meshtastic.sdk/ConfigBundle.copy|copy(org.meshtastic.proto.MyNodeInfo;org.meshtastic.proto.DeviceMetadata;kotlin.collections.List;kotlin.collections.List;org.meshtastic.proto.DeviceUIConfig?){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/ConfigBundle.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // org.meshtastic.sdk/ConfigBundle.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // org.meshtastic.sdk/ConfigBundle.toString|toString(){}[0] } +final class org.meshtastic.sdk/CongestionMetrics { // org.meshtastic.sdk/CongestionMetrics|null[0] + constructor (kotlin/Float, kotlin/Float) // org.meshtastic.sdk/CongestionMetrics.|(kotlin.Float;kotlin.Float){}[0] + + final val airUtilTx // org.meshtastic.sdk/CongestionMetrics.airUtilTx|{}airUtilTx[0] + final fun (): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.airUtilTx.|(){}[0] + final val canSendNonUrgent // org.meshtastic.sdk/CongestionMetrics.canSendNonUrgent|{}canSendNonUrgent[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/CongestionMetrics.canSendNonUrgent.|(){}[0] + final val channelUtil // org.meshtastic.sdk/CongestionMetrics.channelUtil|{}channelUtil[0] + final fun (): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.channelUtil.|(){}[0] + final val level // org.meshtastic.sdk/CongestionMetrics.level|{}level[0] + final fun (): org.meshtastic.sdk/CongestionLevel // org.meshtastic.sdk/CongestionMetrics.level.|(){}[0] + final val suggestedBackoff // org.meshtastic.sdk/CongestionMetrics.suggestedBackoff|{}suggestedBackoff[0] + final fun (): kotlin.time/Duration // org.meshtastic.sdk/CongestionMetrics.suggestedBackoff.|(){}[0] + + final fun component1(): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.component1|component1(){}[0] + final fun component2(): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.component2|component2(){}[0] + final fun copy(kotlin/Float = ..., kotlin/Float = ...): org.meshtastic.sdk/CongestionMetrics // org.meshtastic.sdk/CongestionMetrics.copy|copy(kotlin.Float;kotlin.Float){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/CongestionMetrics.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/CongestionMetrics.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/CongestionMetrics.toString|toString(){}[0] + + final object Companion { // org.meshtastic.sdk/CongestionMetrics.Companion|null[0] + final const val CRITICAL_THRESHOLD // org.meshtastic.sdk/CongestionMetrics.Companion.CRITICAL_THRESHOLD|{}CRITICAL_THRESHOLD[0] + final fun (): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.Companion.CRITICAL_THRESHOLD.|(){}[0] + final const val HIGH_THRESHOLD // org.meshtastic.sdk/CongestionMetrics.Companion.HIGH_THRESHOLD|{}HIGH_THRESHOLD[0] + final fun (): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.Companion.HIGH_THRESHOLD.|(){}[0] + final const val MEDIUM_THRESHOLD // org.meshtastic.sdk/CongestionMetrics.Companion.MEDIUM_THRESHOLD|{}MEDIUM_THRESHOLD[0] + final fun (): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.Companion.MEDIUM_THRESHOLD.|(){}[0] + } +} + +final class org.meshtastic.sdk/DeviceCapabilities { // org.meshtastic.sdk/DeviceCapabilities|null[0] + constructor (kotlin/String?) // org.meshtastic.sdk/DeviceCapabilities.|(kotlin.String?){}[0] + + final val canMuteNode // org.meshtastic.sdk/DeviceCapabilities.canMuteNode|{}canMuteNode[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.canMuteNode.|(){}[0] + final val canSendVerifiedContacts // org.meshtastic.sdk/DeviceCapabilities.canSendVerifiedContacts|{}canSendVerifiedContacts[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.canSendVerifiedContacts.|(){}[0] + final val canToggleTelemetryEnabled // org.meshtastic.sdk/DeviceCapabilities.canToggleTelemetryEnabled|{}canToggleTelemetryEnabled[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.canToggleTelemetryEnabled.|(){}[0] + final val canToggleUnmessageable // org.meshtastic.sdk/DeviceCapabilities.canToggleUnmessageable|{}canToggleUnmessageable[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.canToggleUnmessageable.|(){}[0] + final val firmwareVersion // org.meshtastic.sdk/DeviceCapabilities.firmwareVersion|{}firmwareVersion[0] + final fun (): kotlin/String? // org.meshtastic.sdk/DeviceCapabilities.firmwareVersion.|(){}[0] + final val supportsEsp32Ota // org.meshtastic.sdk/DeviceCapabilities.supportsEsp32Ota|{}supportsEsp32Ota[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsEsp32Ota.|(){}[0] + final val supportsQrCodeSharing // org.meshtastic.sdk/DeviceCapabilities.supportsQrCodeSharing|{}supportsQrCodeSharing[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsQrCodeSharing.|(){}[0] + final val supportsSecondaryChannelLocation // org.meshtastic.sdk/DeviceCapabilities.supportsSecondaryChannelLocation|{}supportsSecondaryChannelLocation[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsSecondaryChannelLocation.|(){}[0] + final val supportsStatusMessage // org.meshtastic.sdk/DeviceCapabilities.supportsStatusMessage|{}supportsStatusMessage[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsStatusMessage.|(){}[0] + final val supportsTakConfig // org.meshtastic.sdk/DeviceCapabilities.supportsTakConfig|{}supportsTakConfig[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsTakConfig.|(){}[0] + final val supportsTrafficManagementConfig // org.meshtastic.sdk/DeviceCapabilities.supportsTrafficManagementConfig|{}supportsTrafficManagementConfig[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsTrafficManagementConfig.|(){}[0] + + final fun component1(): kotlin/String? // org.meshtastic.sdk/DeviceCapabilities.component1|component1(){}[0] + final fun copy(kotlin/String? = ...): org.meshtastic.sdk/DeviceCapabilities // org.meshtastic.sdk/DeviceCapabilities.copy|copy(kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/DeviceCapabilities.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/DeviceCapabilities.toString|toString(){}[0] +} + +final class org.meshtastic.sdk/DeviceVersion : kotlin/Comparable { // org.meshtastic.sdk/DeviceVersion|null[0] + constructor (kotlin/String) // org.meshtastic.sdk/DeviceVersion.|(kotlin.String){}[0] + + final val asInt // org.meshtastic.sdk/DeviceVersion.asInt|{}asInt[0] + final fun (): kotlin/Int // org.meshtastic.sdk/DeviceVersion.asInt.|(){}[0] + final val versionString // org.meshtastic.sdk/DeviceVersion.versionString|{}versionString[0] + final fun (): kotlin/String // org.meshtastic.sdk/DeviceVersion.versionString.|(){}[0] + + final fun compareTo(org.meshtastic.sdk/DeviceVersion): kotlin/Int // org.meshtastic.sdk/DeviceVersion.compareTo|compareTo(org.meshtastic.sdk.DeviceVersion){}[0] + final fun component1(): kotlin/String // org.meshtastic.sdk/DeviceVersion.component1|component1(){}[0] + final fun copy(kotlin/String = ...): org.meshtastic.sdk/DeviceVersion // org.meshtastic.sdk/DeviceVersion.copy|copy(kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/DeviceVersion.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/DeviceVersion.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/DeviceVersion.toString|toString(){}[0] + + final object Companion { // org.meshtastic.sdk/DeviceVersion.Companion|null[0] + final val ABS_MIN_SUPPORTED // org.meshtastic.sdk/DeviceVersion.Companion.ABS_MIN_SUPPORTED|{}ABS_MIN_SUPPORTED[0] + final fun (): org.meshtastic.sdk/DeviceVersion // org.meshtastic.sdk/DeviceVersion.Companion.ABS_MIN_SUPPORTED.|(){}[0] + final val MIN_SUPPORTED // org.meshtastic.sdk/DeviceVersion.Companion.MIN_SUPPORTED|{}MIN_SUPPORTED[0] + final fun (): org.meshtastic.sdk/DeviceVersion // org.meshtastic.sdk/DeviceVersion.Companion.MIN_SUPPORTED.|(){}[0] + } +} + final class org.meshtastic.sdk/Frame { // org.meshtastic.sdk/Frame|null[0] constructor (kotlinx.io.bytestring/ByteString) // org.meshtastic.sdk/Frame.|(kotlinx.io.bytestring.ByteString){}[0] @@ -841,6 +1225,111 @@ final class org.meshtastic.sdk/LatLng { // org.meshtastic.sdk/LatLng|null[0] final fun toString(): kotlin/String // org.meshtastic.sdk/LatLng.toString|toString(){}[0] } +final class org.meshtastic.sdk/MeshNode { // org.meshtastic.sdk/MeshNode|null[0] + constructor (org.meshtastic.proto/NodeInfo, kotlin/Boolean, org.meshtastic.sdk/ConnectionQuality, org.meshtastic.sdk/SignalQuality) // org.meshtastic.sdk/MeshNode.|(org.meshtastic.proto.NodeInfo;kotlin.Boolean;org.meshtastic.sdk.ConnectionQuality;org.meshtastic.sdk.SignalQuality){}[0] + + final val airUtilTx // org.meshtastic.sdk/MeshNode.airUtilTx|{}airUtilTx[0] + final fun (): kotlin/Float? // org.meshtastic.sdk/MeshNode.airUtilTx.|(){}[0] + final val altitude // org.meshtastic.sdk/MeshNode.altitude|{}altitude[0] + final fun (): kotlin/Int? // org.meshtastic.sdk/MeshNode.altitude.|(){}[0] + final val batteryLevel // org.meshtastic.sdk/MeshNode.batteryLevel|{}batteryLevel[0] + final fun (): kotlin/Int? // org.meshtastic.sdk/MeshNode.batteryLevel.|(){}[0] + final val channelUtilization // org.meshtastic.sdk/MeshNode.channelUtilization|{}channelUtilization[0] + final fun (): kotlin/Float? // org.meshtastic.sdk/MeshNode.channelUtilization.|(){}[0] + final val connectionQuality // org.meshtastic.sdk/MeshNode.connectionQuality|{}connectionQuality[0] + final fun (): org.meshtastic.sdk/ConnectionQuality // org.meshtastic.sdk/MeshNode.connectionQuality.|(){}[0] + final val deviceMetrics // org.meshtastic.sdk/MeshNode.deviceMetrics|{}deviceMetrics[0] + final fun (): org.meshtastic.proto/DeviceMetrics? // org.meshtastic.sdk/MeshNode.deviceMetrics.|(){}[0] + final val hopsAway // org.meshtastic.sdk/MeshNode.hopsAway|{}hopsAway[0] + final fun (): kotlin/Int? // org.meshtastic.sdk/MeshNode.hopsAway.|(){}[0] + final val hwModel // org.meshtastic.sdk/MeshNode.hwModel|{}hwModel[0] + final fun (): org.meshtastic.proto/HardwareModel? // org.meshtastic.sdk/MeshNode.hwModel.|(){}[0] + final val isFavorite // org.meshtastic.sdk/MeshNode.isFavorite|{}isFavorite[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/MeshNode.isFavorite.|(){}[0] + final val isIgnored // org.meshtastic.sdk/MeshNode.isIgnored|{}isIgnored[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/MeshNode.isIgnored.|(){}[0] + final val isMuted // org.meshtastic.sdk/MeshNode.isMuted|{}isMuted[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/MeshNode.isMuted.|(){}[0] + final val isOnline // org.meshtastic.sdk/MeshNode.isOnline|{}isOnline[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/MeshNode.isOnline.|(){}[0] + final val lastHeard // org.meshtastic.sdk/MeshNode.lastHeard|{}lastHeard[0] + final fun (): kotlin/Int // org.meshtastic.sdk/MeshNode.lastHeard.|(){}[0] + final val latitude // org.meshtastic.sdk/MeshNode.latitude|{}latitude[0] + final fun (): kotlin/Double? // org.meshtastic.sdk/MeshNode.latitude.|(){}[0] + final val longName // org.meshtastic.sdk/MeshNode.longName|{}longName[0] + final fun (): kotlin/String? // org.meshtastic.sdk/MeshNode.longName.|(){}[0] + final val longitude // org.meshtastic.sdk/MeshNode.longitude|{}longitude[0] + final fun (): kotlin/Double? // org.meshtastic.sdk/MeshNode.longitude.|(){}[0] + final val meshId // org.meshtastic.sdk/MeshNode.meshId|{}meshId[0] + final fun (): kotlin/String? // org.meshtastic.sdk/MeshNode.meshId.|(){}[0] + final val nodeId // org.meshtastic.sdk/MeshNode.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/MeshNode.nodeId.|(){}[0] + final val nodeNum // org.meshtastic.sdk/MeshNode.nodeNum|{}nodeNum[0] + final fun (): kotlin/Int // org.meshtastic.sdk/MeshNode.nodeNum.|(){}[0] + final val position // org.meshtastic.sdk/MeshNode.position|{}position[0] + final fun (): org.meshtastic.proto/Position? // org.meshtastic.sdk/MeshNode.position.|(){}[0] + final val raw // org.meshtastic.sdk/MeshNode.raw|{}raw[0] + final fun (): org.meshtastic.proto/NodeInfo // org.meshtastic.sdk/MeshNode.raw.|(){}[0] + final val shortName // org.meshtastic.sdk/MeshNode.shortName|{}shortName[0] + final fun (): kotlin/String? // org.meshtastic.sdk/MeshNode.shortName.|(){}[0] + final val signalQuality // org.meshtastic.sdk/MeshNode.signalQuality|{}signalQuality[0] + final fun (): org.meshtastic.sdk/SignalQuality // org.meshtastic.sdk/MeshNode.signalQuality.|(){}[0] + final val snr // org.meshtastic.sdk/MeshNode.snr|{}snr[0] + final fun (): kotlin/Float // org.meshtastic.sdk/MeshNode.snr.|(){}[0] + final val user // org.meshtastic.sdk/MeshNode.user|{}user[0] + final fun (): org.meshtastic.proto/User? // org.meshtastic.sdk/MeshNode.user.|(){}[0] + final val viaMqtt // org.meshtastic.sdk/MeshNode.viaMqtt|{}viaMqtt[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/MeshNode.viaMqtt.|(){}[0] + final val voltage // org.meshtastic.sdk/MeshNode.voltage|{}voltage[0] + final fun (): kotlin/Float? // org.meshtastic.sdk/MeshNode.voltage.|(){}[0] + + final fun component1(): org.meshtastic.proto/NodeInfo // org.meshtastic.sdk/MeshNode.component1|component1(){}[0] + final fun component2(): kotlin/Boolean // org.meshtastic.sdk/MeshNode.component2|component2(){}[0] + final fun component3(): org.meshtastic.sdk/ConnectionQuality // org.meshtastic.sdk/MeshNode.component3|component3(){}[0] + final fun component4(): org.meshtastic.sdk/SignalQuality // org.meshtastic.sdk/MeshNode.component4|component4(){}[0] + final fun copy(org.meshtastic.proto/NodeInfo = ..., kotlin/Boolean = ..., org.meshtastic.sdk/ConnectionQuality = ..., org.meshtastic.sdk/SignalQuality = ...): org.meshtastic.sdk/MeshNode // org.meshtastic.sdk/MeshNode.copy|copy(org.meshtastic.proto.NodeInfo;kotlin.Boolean;org.meshtastic.sdk.ConnectionQuality;org.meshtastic.sdk.SignalQuality){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshNode.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshNode.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshNode.toString|toString(){}[0] +} + +final class org.meshtastic.sdk/MeshTopology { // org.meshtastic.sdk/MeshTopology|null[0] + constructor () // org.meshtastic.sdk/MeshTopology.|(){}[0] + + final suspend fun addNeighborInfo(org.meshtastic.sdk/NeighborInfo) // org.meshtastic.sdk/MeshTopology.addNeighborInfo|addNeighborInfo(org.meshtastic.sdk.NeighborInfo){}[0] + final suspend fun allEdges(): kotlin.collections/List // org.meshtastic.sdk/MeshTopology.allEdges|allEdges(){}[0] + final suspend fun clear() // org.meshtastic.sdk/MeshTopology.clear|clear(){}[0] + final suspend fun edgeCount(): kotlin/Int // org.meshtastic.sdk/MeshTopology.edgeCount|edgeCount(){}[0] + final suspend fun getEdge(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId): org.meshtastic.sdk/MeshTopology.Edge? // org.meshtastic.sdk/MeshTopology.getEdge|getEdge(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId){}[0] + final suspend fun getNeighbors(org.meshtastic.sdk/NodeId): kotlin.collections/List // org.meshtastic.sdk/MeshTopology.getNeighbors|getNeighbors(org.meshtastic.sdk.NodeId){}[0] + final suspend fun isDirectReach(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId): kotlin/Boolean // org.meshtastic.sdk/MeshTopology.isDirectReach|isDirectReach(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId){}[0] + final suspend fun nodes(): kotlin.collections/Set // org.meshtastic.sdk/MeshTopology.nodes|nodes(){}[0] + final suspend fun removeNode(org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/MeshTopology.removeNode|removeNode(org.meshtastic.sdk.NodeId){}[0] + final suspend fun shortestPath(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId): kotlin.collections/List // org.meshtastic.sdk/MeshTopology.shortestPath|shortestPath(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId){}[0] + + final class Edge { // org.meshtastic.sdk/MeshTopology.Edge|null[0] + constructor (org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId, kotlin/Float, kotlin/Int = ...) // org.meshtastic.sdk/MeshTopology.Edge.|(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId;kotlin.Float;kotlin.Int){}[0] + + final val from // org.meshtastic.sdk/MeshTopology.Edge.from|{}from[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/MeshTopology.Edge.from.|(){}[0] + final val lastUpdated // org.meshtastic.sdk/MeshTopology.Edge.lastUpdated|{}lastUpdated[0] + final fun (): kotlin/Int // org.meshtastic.sdk/MeshTopology.Edge.lastUpdated.|(){}[0] + final val snr // org.meshtastic.sdk/MeshTopology.Edge.snr|{}snr[0] + final fun (): kotlin/Float // org.meshtastic.sdk/MeshTopology.Edge.snr.|(){}[0] + final val to // org.meshtastic.sdk/MeshTopology.Edge.to|{}to[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/MeshTopology.Edge.to.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/MeshTopology.Edge.component1|component1(){}[0] + final fun component2(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/MeshTopology.Edge.component2|component2(){}[0] + final fun component3(): kotlin/Float // org.meshtastic.sdk/MeshTopology.Edge.component3|component3(){}[0] + final fun component4(): kotlin/Int // org.meshtastic.sdk/MeshTopology.Edge.component4|component4(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/NodeId = ..., kotlin/Float = ..., kotlin/Int = ...): org.meshtastic.sdk/MeshTopology.Edge // org.meshtastic.sdk/MeshTopology.Edge.copy|copy(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId;kotlin.Float;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshTopology.Edge.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshTopology.Edge.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshTopology.Edge.toString|toString(){}[0] + } +} + final class org.meshtastic.sdk/MessageHandle { // org.meshtastic.sdk/MessageHandle|null[0] final val id // org.meshtastic.sdk/MessageHandle.id|{}id[0] final fun (): org.meshtastic.sdk/MessageId // org.meshtastic.sdk/MessageHandle.id.|(){}[0] @@ -851,9 +1340,51 @@ final class org.meshtastic.sdk/MessageHandle { // org.meshtastic.sdk/MessageHand final suspend fun await(): org.meshtastic.sdk/SendOutcome // org.meshtastic.sdk/MessageHandle.await|await(){}[0] } +final class org.meshtastic.sdk/NeighborInfo { // org.meshtastic.sdk/NeighborInfo|null[0] + constructor (org.meshtastic.sdk/NodeId, kotlin.collections/List, kotlin/Int = ...) // org.meshtastic.sdk/NeighborInfo.|(org.meshtastic.sdk.NodeId;kotlin.collections.List;kotlin.Int){}[0] + + final val lastUpdated // org.meshtastic.sdk/NeighborInfo.lastUpdated|{}lastUpdated[0] + final fun (): kotlin/Int // org.meshtastic.sdk/NeighborInfo.lastUpdated.|(){}[0] + final val neighbors // org.meshtastic.sdk/NeighborInfo.neighbors|{}neighbors[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/NeighborInfo.neighbors.|(){}[0] + final val nodeId // org.meshtastic.sdk/NeighborInfo.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NeighborInfo.nodeId.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NeighborInfo.component1|component1(){}[0] + final fun component2(): kotlin.collections/List // org.meshtastic.sdk/NeighborInfo.component2|component2(){}[0] + final fun component3(): kotlin/Int // org.meshtastic.sdk/NeighborInfo.component3|component3(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., kotlin.collections/List = ..., kotlin/Int = ...): org.meshtastic.sdk/NeighborInfo // org.meshtastic.sdk/NeighborInfo.copy|copy(org.meshtastic.sdk.NodeId;kotlin.collections.List;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/NeighborInfo.equals|equals(kotlin.Any?){}[0] + final fun format(kotlin/Function1 = ...): kotlin/String // org.meshtastic.sdk/NeighborInfo.format|format(kotlin.Function1){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/NeighborInfo.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/NeighborInfo.toString|toString(){}[0] + + final class Neighbor { // org.meshtastic.sdk/NeighborInfo.Neighbor|null[0] + constructor (org.meshtastic.sdk/NodeId, kotlin/Float) // org.meshtastic.sdk/NeighborInfo.Neighbor.|(org.meshtastic.sdk.NodeId;kotlin.Float){}[0] + + final val nodeId // org.meshtastic.sdk/NeighborInfo.Neighbor.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NeighborInfo.Neighbor.nodeId.|(){}[0] + final val snr // org.meshtastic.sdk/NeighborInfo.Neighbor.snr|{}snr[0] + final fun (): kotlin/Float // org.meshtastic.sdk/NeighborInfo.Neighbor.snr.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NeighborInfo.Neighbor.component1|component1(){}[0] + final fun component2(): kotlin/Float // org.meshtastic.sdk/NeighborInfo.Neighbor.component2|component2(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., kotlin/Float = ...): org.meshtastic.sdk/NeighborInfo.Neighbor // org.meshtastic.sdk/NeighborInfo.Neighbor.copy|copy(org.meshtastic.sdk.NodeId;kotlin.Float){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/NeighborInfo.Neighbor.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/NeighborInfo.Neighbor.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/NeighborInfo.Neighbor.toString|toString(){}[0] + } + + final object Companion { // org.meshtastic.sdk/NeighborInfo.Companion|null[0] + final fun fromProto(kotlin/Int, kotlin.collections/List, kotlin.collections/List, kotlin/Int = ...): org.meshtastic.sdk/NeighborInfo // org.meshtastic.sdk/NeighborInfo.Companion.fromProto|fromProto(kotlin.Int;kotlin.collections.List;kotlin.collections.List;kotlin.Int){}[0] + } +} + final class org.meshtastic.sdk/RadioClient : kotlin/AutoCloseable { // org.meshtastic.sdk/RadioClient|null[0] final val admin // org.meshtastic.sdk/RadioClient.admin|{}admin[0] final fun (): org.meshtastic.sdk/AdminApi // org.meshtastic.sdk/RadioClient.admin.|(){}[0] + final val channels // org.meshtastic.sdk/RadioClient.channels|{}channels[0] + final fun (): kotlinx.coroutines.flow/StateFlow?> // org.meshtastic.sdk/RadioClient.channels.|(){}[0] final val configBundle // org.meshtastic.sdk/RadioClient.configBundle|{}configBundle[0] final fun (): kotlinx.coroutines.flow/StateFlow // org.meshtastic.sdk/RadioClient.configBundle.|(){}[0] final val connection // org.meshtastic.sdk/RadioClient.connection|{}connection[0] @@ -868,11 +1399,16 @@ final class org.meshtastic.sdk/RadioClient : kotlin/AutoCloseable { // org.mesht final fun (): kotlinx.coroutines.flow/Flow // org.meshtastic.sdk/RadioClient.packets.|(){}[0] final val routing // org.meshtastic.sdk/RadioClient.routing|{}routing[0] final fun (): org.meshtastic.sdk/RoutingApi // org.meshtastic.sdk/RadioClient.routing.|(){}[0] + final val storeForward // org.meshtastic.sdk/RadioClient.storeForward|{}storeForward[0] + final fun (): org.meshtastic.sdk/StoreForwardApi // org.meshtastic.sdk/RadioClient.storeForward.|(){}[0] final val telemetry // org.meshtastic.sdk/RadioClient.telemetry|{}telemetry[0] final fun (): org.meshtastic.sdk/TelemetryApi // org.meshtastic.sdk/RadioClient.telemetry.|(){}[0] final fun close() // org.meshtastic.sdk/RadioClient.close|close(){}[0] + final fun requestNodeInfo(org.meshtastic.sdk/NodeId): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.requestNodeInfo|requestNodeInfo(org.meshtastic.sdk.NodeId){}[0] final fun send(org.meshtastic.proto/MeshPacket): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.send|send(org.meshtastic.proto.MeshPacket){}[0] + final fun sendRaw(org.meshtastic.proto/ToRadio) // org.meshtastic.sdk/RadioClient.sendRaw|sendRaw(org.meshtastic.proto.ToRadio){}[0] + final fun sendReaction(kotlin/String, org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/ChannelIndex = ..., kotlin/Int): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.sendReaction|sendReaction(kotlin.String;org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex;kotlin.Int){}[0] final fun sendText(kotlin/String, org.meshtastic.sdk/ChannelIndex = ..., org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.sendText|sendText(kotlin.String;org.meshtastic.sdk.ChannelIndex;org.meshtastic.sdk.NodeId){}[0] final suspend fun connect() // org.meshtastic.sdk/RadioClient.connect|connect(){}[0] final suspend fun disconnect() // org.meshtastic.sdk/RadioClient.disconnect|disconnect(){}[0] @@ -891,6 +1427,7 @@ final class org.meshtastic.sdk/RadioClient : kotlin/AutoCloseable { // org.mesht final fun coroutineContext(kotlin.coroutines/CoroutineContext): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.coroutineContext|coroutineContext(kotlin.coroutines.CoroutineContext){}[0] final fun disableBleHeartbeat(): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.disableBleHeartbeat|disableBleHeartbeat(){}[0] final fun logger(org.meshtastic.sdk/LogSink): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.logger|logger(org.meshtastic.sdk.LogSink){}[0] + final fun presenceTimeout(kotlin.time/Duration): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.presenceTimeout|presenceTimeout(kotlin.time.Duration){}[0] final fun protocolLogging(org.meshtastic.sdk/LogLevel, org.meshtastic.sdk/PayloadRedactor = ...): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.protocolLogging|protocolLogging(org.meshtastic.sdk.LogLevel;org.meshtastic.sdk.PayloadRedactor){}[0] final fun rpcTimeout(kotlin.time/Duration): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.rpcTimeout|rpcTimeout(kotlin.time.Duration){}[0] final fun sendTimeout(kotlin.time/Duration): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.sendTimeout|sendTimeout(kotlin.time.Duration){}[0] @@ -925,6 +1462,35 @@ final class org.meshtastic.sdk/RadioMetrics { // org.meshtastic.sdk/RadioMetrics final fun toString(): kotlin/String // org.meshtastic.sdk/RadioMetrics.toString|toString(){}[0] } +final class org.meshtastic.sdk/RouteDiscoveryResult { // org.meshtastic.sdk/RouteDiscoveryResult|null[0] + constructor (kotlin.collections/List, kotlin.collections/List, kotlin.collections/List = ..., kotlin.collections/List = ...) // org.meshtastic.sdk/RouteDiscoveryResult.|(kotlin.collections.List;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List){}[0] + + final val hopsAway // org.meshtastic.sdk/RouteDiscoveryResult.hopsAway|{}hopsAway[0] + final fun (): kotlin/Int // org.meshtastic.sdk/RouteDiscoveryResult.hopsAway.|(){}[0] + final val route // org.meshtastic.sdk/RouteDiscoveryResult.route|{}route[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.route.|(){}[0] + final val routeBack // org.meshtastic.sdk/RouteDiscoveryResult.routeBack|{}routeBack[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.routeBack.|(){}[0] + final val snrBack // org.meshtastic.sdk/RouteDiscoveryResult.snrBack|{}snrBack[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.snrBack.|(){}[0] + final val snrTowards // org.meshtastic.sdk/RouteDiscoveryResult.snrTowards|{}snrTowards[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.snrTowards.|(){}[0] + + final fun component1(): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.component1|component1(){}[0] + final fun component2(): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.component2|component2(){}[0] + final fun component3(): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.component3|component3(){}[0] + final fun component4(): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.component4|component4(){}[0] + final fun copy(kotlin.collections/List = ..., kotlin.collections/List = ..., kotlin.collections/List = ..., kotlin.collections/List = ...): org.meshtastic.sdk/RouteDiscoveryResult // org.meshtastic.sdk/RouteDiscoveryResult.copy|copy(kotlin.collections.List;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/RouteDiscoveryResult.equals|equals(kotlin.Any?){}[0] + final fun formatRoute(kotlin/Function1): kotlin/String // org.meshtastic.sdk/RouteDiscoveryResult.formatRoute|formatRoute(kotlin.Function1){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/RouteDiscoveryResult.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/RouteDiscoveryResult.toString|toString(){}[0] + + final object Companion { // org.meshtastic.sdk/RouteDiscoveryResult.Companion|null[0] + final fun fromProto(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId, kotlin.collections/List, kotlin.collections/List, kotlin.collections/List = ..., kotlin.collections/List = ...): org.meshtastic.sdk/RouteDiscoveryResult // org.meshtastic.sdk/RouteDiscoveryResult.Companion.fromProto|fromProto(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List){}[0] + } +} + final class org.meshtastic.sdk/SendBuilder { // org.meshtastic.sdk/SendBuilder|null[0] final fun channel(org.meshtastic.sdk/ChannelIndex) // org.meshtastic.sdk/SendBuilder.channel|channel(org.meshtastic.sdk.ChannelIndex){}[0] final fun data(org.meshtastic.proto/PortNum, kotlin/ByteArray) // org.meshtastic.sdk/SendBuilder.data|data(org.meshtastic.proto.PortNum;kotlin.ByteArray){}[0] @@ -953,6 +1519,34 @@ final class org.meshtastic.sdk/SessionPasskey { // org.meshtastic.sdk/SessionPas final fun toString(): kotlin/String // org.meshtastic.sdk/SessionPasskey.toString|toString(){}[0] } +final class org.meshtastic.sdk/StoreForwardStats { // org.meshtastic.sdk/StoreForwardStats|null[0] + constructor (kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Boolean = ...) // org.meshtastic.sdk/StoreForwardStats.|(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Boolean){}[0] + + final val heartbeat // org.meshtastic.sdk/StoreForwardStats.heartbeat|{}heartbeat[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/StoreForwardStats.heartbeat.|(){}[0] + final val messagesMax // org.meshtastic.sdk/StoreForwardStats.messagesMax|{}messagesMax[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.messagesMax.|(){}[0] + final val messagesStored // org.meshtastic.sdk/StoreForwardStats.messagesStored|{}messagesStored[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.messagesStored.|(){}[0] + final val requests // org.meshtastic.sdk/StoreForwardStats.requests|{}requests[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.requests.|(){}[0] + final val requestsFailed // org.meshtastic.sdk/StoreForwardStats.requestsFailed|{}requestsFailed[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.requestsFailed.|(){}[0] + final val uptime // org.meshtastic.sdk/StoreForwardStats.uptime|{}uptime[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.uptime.|(){}[0] + + final fun component1(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.component1|component1(){}[0] + final fun component2(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.component2|component2(){}[0] + final fun component3(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.component3|component3(){}[0] + final fun component4(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.component4|component4(){}[0] + final fun component5(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.component5|component5(){}[0] + final fun component6(): kotlin/Boolean // org.meshtastic.sdk/StoreForwardStats.component6|component6(){}[0] + final fun copy(kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Boolean = ...): org.meshtastic.sdk/StoreForwardStats // org.meshtastic.sdk/StoreForwardStats.copy|copy(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardStats.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardStats.toString|toString(){}[0] +} + final value class org.meshtastic.sdk/ChannelIndex { // org.meshtastic.sdk/ChannelIndex|null[0] constructor (kotlin/Int) // org.meshtastic.sdk/ChannelIndex.|(kotlin.Int){}[0] @@ -1013,6 +1607,35 @@ final value class org.meshtastic.sdk/TransportIdentity { // org.meshtastic.sdk/T } } +sealed class org.meshtastic.sdk/AdminResultException : kotlin/Exception { // org.meshtastic.sdk/AdminResultException|null[0] + final class NodeUnreachable : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.NodeUnreachable|null[0] + constructor () // org.meshtastic.sdk/AdminResultException.NodeUnreachable.|(){}[0] + } + + final class RateLimited : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.RateLimited|null[0] + constructor () // org.meshtastic.sdk/AdminResultException.RateLimited.|(){}[0] + } + + final class RoutingFailed : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.RoutingFailed|null[0] + constructor (org.meshtastic.proto/Routing.Error) // org.meshtastic.sdk/AdminResultException.RoutingFailed.|(org.meshtastic.proto.Routing.Error){}[0] + + final val error // org.meshtastic.sdk/AdminResultException.RoutingFailed.error|{}error[0] + final fun (): org.meshtastic.proto/Routing.Error // org.meshtastic.sdk/AdminResultException.RoutingFailed.error.|(){}[0] + } + + final class SessionKeyExpired : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.SessionKeyExpired|null[0] + constructor () // org.meshtastic.sdk/AdminResultException.SessionKeyExpired.|(){}[0] + } + + final class Timeout : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.Timeout|null[0] + constructor () // org.meshtastic.sdk/AdminResultException.Timeout.|(){}[0] + } + + final class Unauthorized : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.Unauthorized|null[0] + constructor () // org.meshtastic.sdk/AdminResultException.Unauthorized.|(){}[0] + } +} + sealed class org.meshtastic.sdk/MeshtasticException : kotlin/Exception { // org.meshtastic.sdk/MeshtasticException|null[0] final var operation // org.meshtastic.sdk/MeshtasticException.operation|{}operation[0] final fun (): kotlin/String? // org.meshtastic.sdk/MeshtasticException.operation.|(){}[0] @@ -1069,6 +1692,60 @@ sealed class org.meshtastic.sdk/MeshtasticException : kotlin/Exception { // org. } } +sealed class org.meshtastic.sdk/RetryPolicy { // org.meshtastic.sdk/RetryPolicy|null[0] + final val maxRetries // org.meshtastic.sdk/RetryPolicy.maxRetries|{}maxRetries[0] + final fun (): kotlin/Int // org.meshtastic.sdk/RetryPolicy.maxRetries.|(){}[0] + + final fun delayForAttempt(kotlin/Int): kotlin.time/Duration? // org.meshtastic.sdk/RetryPolicy.delayForAttempt|delayForAttempt(kotlin.Int){}[0] + + final class ExponentialBackoff : org.meshtastic.sdk/RetryPolicy { // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff|null[0] + constructor (kotlin/Int = ..., kotlin.time/Duration = ..., kotlin.time/Duration = ..., kotlin/Double = ..., kotlin/Double = ...) // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.|(kotlin.Int;kotlin.time.Duration;kotlin.time.Duration;kotlin.Double;kotlin.Double){}[0] + + final val initialDelay // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.initialDelay|{}initialDelay[0] + final fun (): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.initialDelay.|(){}[0] + final val jitterFactor // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.jitterFactor|{}jitterFactor[0] + final fun (): kotlin/Double // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.jitterFactor.|(){}[0] + final val maxAttempts // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.maxAttempts|{}maxAttempts[0] + final fun (): kotlin/Int // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.maxAttempts.|(){}[0] + final val maxDelay // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.maxDelay|{}maxDelay[0] + final fun (): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.maxDelay.|(){}[0] + final val multiplier // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.multiplier|{}multiplier[0] + final fun (): kotlin/Double // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.multiplier.|(){}[0] + + final fun component1(): kotlin/Int // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.component1|component1(){}[0] + final fun component2(): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.component2|component2(){}[0] + final fun component3(): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.component3|component3(){}[0] + final fun component4(): kotlin/Double // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.component4|component4(){}[0] + final fun component5(): kotlin/Double // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.component5|component5(){}[0] + final fun copy(kotlin/Int = ..., kotlin.time/Duration = ..., kotlin.time/Duration = ..., kotlin/Double = ..., kotlin/Double = ...): org.meshtastic.sdk/RetryPolicy.ExponentialBackoff // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.copy|copy(kotlin.Int;kotlin.time.Duration;kotlin.time.Duration;kotlin.Double;kotlin.Double){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.toString|toString(){}[0] + } + + final class Fixed : org.meshtastic.sdk/RetryPolicy { // org.meshtastic.sdk/RetryPolicy.Fixed|null[0] + constructor (kotlin/Int = ..., kotlin.time/Duration = ...) // org.meshtastic.sdk/RetryPolicy.Fixed.|(kotlin.Int;kotlin.time.Duration){}[0] + + final val delay // org.meshtastic.sdk/RetryPolicy.Fixed.delay|{}delay[0] + final fun (): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.Fixed.delay.|(){}[0] + final val maxAttempts // org.meshtastic.sdk/RetryPolicy.Fixed.maxAttempts|{}maxAttempts[0] + final fun (): kotlin/Int // org.meshtastic.sdk/RetryPolicy.Fixed.maxAttempts.|(){}[0] + + final fun component1(): kotlin/Int // org.meshtastic.sdk/RetryPolicy.Fixed.component1|component1(){}[0] + final fun component2(): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.Fixed.component2|component2(){}[0] + final fun copy(kotlin/Int = ..., kotlin.time/Duration = ...): org.meshtastic.sdk/RetryPolicy.Fixed // org.meshtastic.sdk/RetryPolicy.Fixed.copy|copy(kotlin.Int;kotlin.time.Duration){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/RetryPolicy.Fixed.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/RetryPolicy.Fixed.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/RetryPolicy.Fixed.toString|toString(){}[0] + } + + final object None : org.meshtastic.sdk/RetryPolicy { // org.meshtastic.sdk/RetryPolicy.None|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/RetryPolicy.None.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/RetryPolicy.None.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/RetryPolicy.None.toString|toString(){}[0] + } +} + sealed class org.meshtastic.sdk/TelemetryReading { // org.meshtastic.sdk/TelemetryReading|null[0] final class AirQuality : org.meshtastic.sdk/TelemetryReading { // org.meshtastic.sdk/TelemetryReading.AirQuality|null[0] constructor (org.meshtastic.proto/AirQualityMetrics) // org.meshtastic.sdk/TelemetryReading.AirQuality.|(org.meshtastic.proto.AirQualityMetrics){}[0] @@ -1123,6 +1800,35 @@ sealed class org.meshtastic.sdk/TelemetryReading { // org.meshtastic.sdk/Telemet } } +final object org.meshtastic.sdk/ChannelHelpers { // org.meshtastic.sdk/ChannelHelpers|null[0] + final const val MAX_NAME_LENGTH // org.meshtastic.sdk/ChannelHelpers.MAX_NAME_LENGTH|{}MAX_NAME_LENGTH[0] + final fun (): kotlin/Int // org.meshtastic.sdk/ChannelHelpers.MAX_NAME_LENGTH.|(){}[0] + final const val MAX_PSK_LENGTH // org.meshtastic.sdk/ChannelHelpers.MAX_PSK_LENGTH|{}MAX_PSK_LENGTH[0] + final fun (): kotlin/Int // org.meshtastic.sdk/ChannelHelpers.MAX_PSK_LENGTH.|(){}[0] + final const val MIN_PSK_LENGTH // org.meshtastic.sdk/ChannelHelpers.MIN_PSK_LENGTH|{}MIN_PSK_LENGTH[0] + final fun (): kotlin/Int // org.meshtastic.sdk/ChannelHelpers.MIN_PSK_LENGTH.|(){}[0] + + final fun createSettings(kotlin/String, kotlin/ByteArray = ...): org.meshtastic.proto/ChannelSettings? // org.meshtastic.sdk/ChannelHelpers.createSettings|createSettings(kotlin.String;kotlin.ByteArray){}[0] + final fun findEmptySlot(kotlin.collections/List, kotlin/Int = ...): kotlin/Int? // org.meshtastic.sdk/ChannelHelpers.findEmptySlot|findEmptySlot(kotlin.collections.List;kotlin.Int){}[0] + final fun validate(kotlin/String, kotlin/ByteArray, org.meshtastic.proto/Channel.Role = ...): org.meshtastic.sdk/ChannelHelpers.ValidationResult // org.meshtastic.sdk/ChannelHelpers.validate|validate(kotlin.String;kotlin.ByteArray;org.meshtastic.proto.Channel.Role){}[0] + + final class ValidationResult { // org.meshtastic.sdk/ChannelHelpers.ValidationResult|null[0] + constructor (kotlin/Boolean, kotlin.collections/List = ...) // org.meshtastic.sdk/ChannelHelpers.ValidationResult.|(kotlin.Boolean;kotlin.collections.List){}[0] + + final val errors // org.meshtastic.sdk/ChannelHelpers.ValidationResult.errors|{}errors[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/ChannelHelpers.ValidationResult.errors.|(){}[0] + final val isValid // org.meshtastic.sdk/ChannelHelpers.ValidationResult.isValid|{}isValid[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/ChannelHelpers.ValidationResult.isValid.|(){}[0] + + final fun component1(): kotlin/Boolean // org.meshtastic.sdk/ChannelHelpers.ValidationResult.component1|component1(){}[0] + final fun component2(): kotlin.collections/List // org.meshtastic.sdk/ChannelHelpers.ValidationResult.component2|component2(){}[0] + final fun copy(kotlin/Boolean = ..., kotlin.collections/List = ...): org.meshtastic.sdk/ChannelHelpers.ValidationResult // org.meshtastic.sdk/ChannelHelpers.ValidationResult.copy|copy(kotlin.Boolean;kotlin.collections.List){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/ChannelHelpers.ValidationResult.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/ChannelHelpers.ValidationResult.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/ChannelHelpers.ValidationResult.toString|toString(){}[0] + } +} + final object org.meshtastic.sdk/ChannelUrl { // org.meshtastic.sdk/ChannelUrl|null[0] final const val PREFIX // org.meshtastic.sdk/ChannelUrl.PREFIX|{}PREFIX[0] final fun (): kotlin/String // org.meshtastic.sdk/ChannelUrl.PREFIX.|(){}[0] @@ -1131,6 +1837,27 @@ final object org.meshtastic.sdk/ChannelUrl { // org.meshtastic.sdk/ChannelUrl|nu final fun parse(kotlin/String): org.meshtastic.proto/ChannelSet? // org.meshtastic.sdk/ChannelUrl.parse|parse(kotlin.String){}[0] } +final object org.meshtastic.sdk/PositionUtils { // org.meshtastic.sdk/PositionUtils|null[0] + final fun bearing(kotlin/Double, kotlin/Double, kotlin/Double, kotlin/Double): kotlin/Double // org.meshtastic.sdk/PositionUtils.bearing|bearing(kotlin.Double;kotlin.Double;kotlin.Double;kotlin.Double){}[0] + final fun bearing(org.meshtastic.sdk/LatLng, org.meshtastic.sdk/LatLng): kotlin/Double // org.meshtastic.sdk/PositionUtils.bearing|bearing(org.meshtastic.sdk.LatLng;org.meshtastic.sdk.LatLng){}[0] + final fun distance(kotlin/Double, kotlin/Double, kotlin/Double, kotlin/Double): kotlin/Double // org.meshtastic.sdk/PositionUtils.distance|distance(kotlin.Double;kotlin.Double;kotlin.Double;kotlin.Double){}[0] + final fun distance(org.meshtastic.sdk/LatLng, org.meshtastic.sdk/LatLng): kotlin/Double // org.meshtastic.sdk/PositionUtils.distance|distance(org.meshtastic.sdk.LatLng;org.meshtastic.sdk.LatLng){}[0] + final fun intToDegrees(kotlin/Int): kotlin/Double // org.meshtastic.sdk/PositionUtils.intToDegrees|intToDegrees(kotlin.Int){}[0] + final fun isValidPosition(kotlin/Double, kotlin/Double): kotlin/Boolean // org.meshtastic.sdk/PositionUtils.isValidPosition|isValidPosition(kotlin.Double;kotlin.Double){}[0] +} + +final object org.meshtastic.sdk/SfppHash { // org.meshtastic.sdk/SfppHash|null[0] + final fun compute(kotlin/ByteArray, kotlin/Int, kotlin/Int, kotlin/Int): kotlin/ByteArray // org.meshtastic.sdk/SfppHash.compute|compute(kotlin.ByteArray;kotlin.Int;kotlin.Int;kotlin.Int){}[0] +} + +final object org.meshtastic.sdk/SharedContactUrl { // org.meshtastic.sdk/SharedContactUrl|null[0] + final const val PREFIX // org.meshtastic.sdk/SharedContactUrl.PREFIX|{}PREFIX[0] + final fun (): kotlin/String // org.meshtastic.sdk/SharedContactUrl.PREFIX.|(){}[0] + + final fun encode(org.meshtastic.proto/SharedContact): kotlin/String // org.meshtastic.sdk/SharedContactUrl.encode|encode(org.meshtastic.proto.SharedContact){}[0] + final fun parse(kotlin/String): org.meshtastic.proto/SharedContact? // org.meshtastic.sdk/SharedContactUrl.parse|parse(kotlin.String){}[0] +} + final object org.meshtastic.sdk/WireCodec { // org.meshtastic.sdk/WireCodec|null[0] final fun decodeFromRadio(kotlin/ByteArray): org.meshtastic.proto/FromRadio // org.meshtastic.sdk/WireCodec.decodeFromRadio|decodeFromRadio(kotlin.ByteArray){}[0] final fun encodeToRadio(org.meshtastic.proto/ToRadio): kotlin/ByteArray // org.meshtastic.sdk/WireCodec.encodeToRadio|encodeToRadio(org.meshtastic.proto.ToRadio){}[0] @@ -1160,21 +1887,38 @@ final object org.meshtastic.sdk/WireFraming { // org.meshtastic.sdk/WireFraming| final const val org.meshtastic.sdk/DATA_PAYLOAD_LEN // org.meshtastic.sdk/DATA_PAYLOAD_LEN|{}DATA_PAYLOAD_LEN[0] final fun (): kotlin/Int // org.meshtastic.sdk/DATA_PAYLOAD_LEN.|(){}[0] +final val org.meshtastic.sdk/DEFAULT_ONLINE_THRESHOLD // org.meshtastic.sdk/DEFAULT_ONLINE_THRESHOLD|{}DEFAULT_ONLINE_THRESHOLD[0] + final fun (): kotlin.time/Duration // org.meshtastic.sdk/DEFAULT_ONLINE_THRESHOLD.|(){}[0] final val org.meshtastic.sdk/DefaultPsk // org.meshtastic.sdk/DefaultPsk|{}DefaultPsk[0] final fun (): kotlin/ByteArray // org.meshtastic.sdk/DefaultPsk.|(){}[0] +final val org.meshtastic.sdk/connectionQuality // org.meshtastic.sdk/connectionQuality|@org.meshtastic.proto.NodeInfo{}connectionQuality[0] + final fun (org.meshtastic.proto/NodeInfo).(): org.meshtastic.sdk/ConnectionQuality // org.meshtastic.sdk/connectionQuality.|@org.meshtastic.proto.NodeInfo(){}[0] final val org.meshtastic.sdk/displayId // org.meshtastic.sdk/displayId|@org.meshtastic.proto.NodeInfo{}displayId[0] final fun (org.meshtastic.proto/NodeInfo).(): kotlin/String // org.meshtastic.sdk/displayId.|@org.meshtastic.proto.NodeInfo(){}[0] final val org.meshtastic.sdk/displayName // org.meshtastic.sdk/displayName|@org.meshtastic.proto.HardwareModel{}displayName[0] final fun (org.meshtastic.proto/HardwareModel).(): kotlin/String // org.meshtastic.sdk/displayName.|@org.meshtastic.proto.HardwareModel(){}[0] final val org.meshtastic.sdk/isBroadcast // org.meshtastic.sdk/isBroadcast|@org.meshtastic.sdk.NodeId{}isBroadcast[0] final fun (org.meshtastic.sdk/NodeId).(): kotlin/Boolean // org.meshtastic.sdk/isBroadcast.|@org.meshtastic.sdk.NodeId(){}[0] +final val org.meshtastic.sdk/isInProgress // org.meshtastic.sdk/isInProgress|@org.meshtastic.sdk.ConnectionState{}isInProgress[0] + final fun (org.meshtastic.sdk/ConnectionState).(): kotlin/Boolean // org.meshtastic.sdk/isInProgress.|@org.meshtastic.sdk.ConnectionState(){}[0] +final val org.meshtastic.sdk/isSuccess // org.meshtastic.sdk/isSuccess|@org.meshtastic.sdk.AdminResult<0:0>{0§}isSuccess[0] + final fun <#A1: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A1>).(): kotlin/Boolean // org.meshtastic.sdk/isSuccess.|@org.meshtastic.sdk.AdminResult<0:0>(){0§}[0] final val org.meshtastic.sdk/isUnicast // org.meshtastic.sdk/isUnicast|@org.meshtastic.sdk.NodeId{}isUnicast[0] final fun (org.meshtastic.sdk/NodeId).(): kotlin/Boolean // org.meshtastic.sdk/isUnicast.|@org.meshtastic.sdk.NodeId(){}[0] +final val org.meshtastic.sdk/isUsable // org.meshtastic.sdk/isUsable|@org.meshtastic.sdk.ConnectionState{}isUsable[0] + final fun (org.meshtastic.sdk/ConnectionState).(): kotlin/Boolean // org.meshtastic.sdk/isUsable.|@org.meshtastic.sdk.ConnectionState(){}[0] final val org.meshtastic.sdk/longName // org.meshtastic.sdk/longName|@org.meshtastic.proto.NodeInfo{}longName[0] final fun (org.meshtastic.proto/NodeInfo).(): kotlin/String // org.meshtastic.sdk/longName.|@org.meshtastic.proto.NodeInfo(){}[0] final val org.meshtastic.sdk/shortName // org.meshtastic.sdk/shortName|@org.meshtastic.proto.NodeInfo{}shortName[0] final fun (org.meshtastic.proto/NodeInfo).(): kotlin/String // org.meshtastic.sdk/shortName.|@org.meshtastic.proto.NodeInfo(){}[0] - +final val org.meshtastic.sdk/signalQuality // org.meshtastic.sdk/signalQuality|@org.meshtastic.proto.NodeInfo{}signalQuality[0] + final fun (org.meshtastic.proto/NodeInfo).(): org.meshtastic.sdk/SignalQuality // org.meshtastic.sdk/signalQuality.|@org.meshtastic.proto.NodeInfo(){}[0] +final val org.meshtastic.sdk/statusMessage // org.meshtastic.sdk/statusMessage|@org.meshtastic.sdk.ConnectionState{}statusMessage[0] + final fun (org.meshtastic.sdk/ConnectionState).(): kotlin/String // org.meshtastic.sdk/statusMessage.|@org.meshtastic.sdk.ConnectionState(){}[0] +final val org.meshtastic.sdk/textMessages // org.meshtastic.sdk/textMessages|@org.meshtastic.sdk.RadioClient{}textMessages[0] + final fun (org.meshtastic.sdk/RadioClient).(): kotlinx.coroutines.flow/Flow // org.meshtastic.sdk/textMessages.|@org.meshtastic.sdk.RadioClient(){}[0] + +final fun (kotlin.collections/Iterable).org.meshtastic.sdk/toMeshNodes(kotlin/Int): kotlin.collections/List // org.meshtastic.sdk/toMeshNodes|toMeshNodes@kotlin.collections.Iterable(kotlin.Int){}[0] final fun (kotlin.time/Instant).org.meshtastic.sdk/relativeTo(kotlin.time/Instant = ...): kotlin/String // org.meshtastic.sdk/relativeTo|relativeTo@kotlin.time.Instant(kotlin.time.Instant){}[0] final fun (kotlin.time/Instant).org.meshtastic.sdk/toFirmwareSeconds(): kotlin/Int // org.meshtastic.sdk/toFirmwareSeconds|toFirmwareSeconds@kotlin.time.Instant(){}[0] final fun (kotlin.time/Instant?).org.meshtastic.sdk/relativeToOrNever(kotlin.time/Instant = ...): kotlin/String // org.meshtastic.sdk/relativeToOrNever|relativeToOrNever@kotlin.time.Instant?(kotlin.time.Instant){}[0] @@ -1188,12 +1932,15 @@ final fun (org.meshtastic.proto/ChannelSettings.Companion).org.meshtastic.sdk/ha final fun (org.meshtastic.proto/DeviceMetrics).org.meshtastic.sdk/toBatteryStatus(): org.meshtastic.sdk/BatteryStatus? // org.meshtastic.sdk/toBatteryStatus|toBatteryStatus@org.meshtastic.proto.DeviceMetrics(){}[0] final fun (org.meshtastic.proto/FromRadio).org.meshtastic.sdk/toByteArray(): kotlin/ByteArray // org.meshtastic.sdk/toByteArray|toByteArray@org.meshtastic.proto.FromRadio(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asAdminMessage(): org.meshtastic.proto/AdminMessage? // org.meshtastic.sdk/asAdminMessage|asAdminMessage@org.meshtastic.proto.MeshPacket(){}[0] +final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asNeighborInfo(): org.meshtastic.proto/NeighborInfo? // org.meshtastic.sdk/asNeighborInfo|asNeighborInfo@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asNodeInfo(): org.meshtastic.proto/NodeInfo? // org.meshtastic.sdk/asNodeInfo|asNodeInfo@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asNodeInfoUser(): org.meshtastic.proto/User? // org.meshtastic.sdk/asNodeInfoUser|asNodeInfoUser@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asPosition(): org.meshtastic.proto/Position? // org.meshtastic.sdk/asPosition|asPosition@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asRouting(): org.meshtastic.proto/Routing? // org.meshtastic.sdk/asRouting|asRouting@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asTelemetry(): org.meshtastic.proto/Telemetry? // org.meshtastic.sdk/asTelemetry|asTelemetry@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asText(): kotlin/String? // org.meshtastic.sdk/asText|asText@org.meshtastic.proto.MeshPacket(){}[0] +final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asTraceroute(): org.meshtastic.proto/RouteDiscovery? // org.meshtastic.sdk/asTraceroute|asTraceroute@org.meshtastic.proto.MeshPacket(){}[0] +final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asWaypoint(): org.meshtastic.proto/Waypoint? // org.meshtastic.sdk/asWaypoint|asWaypoint@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsAdmin(): org.meshtastic.proto/AdminMessage? // org.meshtastic.sdk/decodeAsAdmin|decodeAsAdmin@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsNodeInfo(): org.meshtastic.proto/NodeInfo? // org.meshtastic.sdk/decodeAsNodeInfo|decodeAsNodeInfo@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsPosition(): org.meshtastic.proto/Position? // org.meshtastic.sdk/decodeAsPosition|decodeAsPosition@org.meshtastic.proto.MeshPacket(){}[0] @@ -1204,25 +1951,63 @@ final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsUser(): o final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/signalQuality(): kotlin/Int? // org.meshtastic.sdk/signalQuality|signalQuality@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/toByteArray(): kotlin/ByteArray // org.meshtastic.sdk/toByteArray|toByteArray@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/toRadioMetrics(): org.meshtastic.sdk/RadioMetrics? // org.meshtastic.sdk/toRadioMetrics|toRadioMetrics@org.meshtastic.proto.MeshPacket(){}[0] +final fun (org.meshtastic.proto/NodeInfo).org.meshtastic.sdk/isOnline(kotlin/Int, kotlin.time/Duration = ...): kotlin/Boolean // org.meshtastic.sdk/isOnline|isOnline@org.meshtastic.proto.NodeInfo(kotlin.Int;kotlin.time.Duration){}[0] +final fun (org.meshtastic.proto/NodeInfo).org.meshtastic.sdk/toMeshNode(kotlin/Int): org.meshtastic.sdk/MeshNode // org.meshtastic.sdk/toMeshNode|toMeshNode@org.meshtastic.proto.NodeInfo(kotlin.Int){}[0] final fun (org.meshtastic.proto/Position).org.meshtastic.sdk/toLatLng(): org.meshtastic.sdk/LatLng? // org.meshtastic.sdk/toLatLng|toLatLng@org.meshtastic.proto.Position(){}[0] final fun (org.meshtastic.proto/Routing.Error).org.meshtastic.sdk/actionableMessage(): kotlin/String // org.meshtastic.sdk/actionableMessage|actionableMessage@org.meshtastic.proto.Routing.Error(){}[0] final fun (org.meshtastic.proto/Routing.Error).org.meshtastic.sdk/suggestedAction(): kotlin/String? // org.meshtastic.sdk/suggestedAction|suggestedAction@org.meshtastic.proto.Routing.Error(){}[0] +final fun (org.meshtastic.proto/SharedContact).org.meshtastic.sdk/toUrl(): kotlin/String // org.meshtastic.sdk/toUrl|toUrl@org.meshtastic.proto.SharedContact(){}[0] final fun (org.meshtastic.proto/Telemetry).org.meshtastic.sdk/toBatteryStatus(): org.meshtastic.sdk/BatteryStatus? // org.meshtastic.sdk/toBatteryStatus|toBatteryStatus@org.meshtastic.proto.Telemetry(){}[0] final fun (org.meshtastic.proto/Telemetry).org.meshtastic.sdk/toReading(): org.meshtastic.sdk/TelemetryReading? // org.meshtastic.sdk/toReading|toReading@org.meshtastic.proto.Telemetry(){}[0] final fun (org.meshtastic.proto/ToRadio).org.meshtastic.sdk/toByteArray(): kotlin/ByteArray // org.meshtastic.sdk/toByteArray|toByteArray@org.meshtastic.proto.ToRadio(){}[0] final fun (org.meshtastic.sdk/NodeId).org.meshtastic.sdk/isLocal(org.meshtastic.sdk/NodeId? = ...): kotlin/Boolean // org.meshtastic.sdk/isLocal|isLocal@org.meshtastic.sdk.NodeId(org.meshtastic.sdk.NodeId?){}[0] +final fun (org.meshtastic.sdk/NodeId).org.meshtastic.sdk/toDefaultId(): kotlin/String // org.meshtastic.sdk/toDefaultId|toDefaultId@org.meshtastic.sdk.NodeId(){}[0] final fun (org.meshtastic.sdk/NodeId).org.meshtastic.sdk/toHex(): kotlin/String // org.meshtastic.sdk/toHex|toHex@org.meshtastic.sdk.NodeId(){}[0] +final fun (org.meshtastic.sdk/NodeId.Companion).org.meshtastic.sdk/fromDefaultId(kotlin/String): org.meshtastic.sdk/NodeId? // org.meshtastic.sdk/fromDefaultId|fromDefaultId@org.meshtastic.sdk.NodeId.Companion(kotlin.String){}[0] final fun (org.meshtastic.sdk/NodeId.Companion).org.meshtastic.sdk/fromHex(kotlin/String): org.meshtastic.sdk/NodeId? // org.meshtastic.sdk/fromHex|fromHex@org.meshtastic.sdk.NodeId.Companion(kotlin.String){}[0] final fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/requestPosition(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/ChannelIndex = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/requestPosition|requestPosition@org.meshtastic.sdk.RadioClient(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex){}[0] final fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/sendDirectMessageEncrypted(org.meshtastic.sdk/NodeId, kotlin/String, kotlin/Boolean = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/sendDirectMessageEncrypted|sendDirectMessageEncrypted@org.meshtastic.sdk.RadioClient(org.meshtastic.sdk.NodeId;kotlin.String;kotlin.Boolean){}[0] final fun (org.meshtastic.sdk/SendFailure).org.meshtastic.sdk/humanMessage(): kotlin/String // org.meshtastic.sdk/humanMessage|humanMessage@org.meshtastic.sdk.SendFailure(){}[0] final fun <#A: com.squareup.wire/Message<#A, *>> (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAs(com.squareup.wire/ProtoAdapter<#A>): #A? // org.meshtastic.sdk/decodeAs|decodeAs@org.meshtastic.proto.MeshPacket(com.squareup.wire.ProtoAdapter<0:0>){0§>}[0] +final fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrElse(#A): #A // org.meshtastic.sdk/getOrElse|getOrElse@org.meshtastic.sdk.AdminResult<0:0>(0:0){0§}[0] +final fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrNull(): #A? // org.meshtastic.sdk/getOrNull|getOrNull@org.meshtastic.sdk.AdminResult<0:0>(){0§}[0] +final fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrThrow(): #A // org.meshtastic.sdk/getOrThrow|getOrThrow@org.meshtastic.sdk.AdminResult<0:0>(){0§}[0] +final fun org.meshtastic.sdk/channelNameHashDjb2(kotlin/String): kotlin/UInt // org.meshtastic.sdk/channelNameHashDjb2|channelNameHashDjb2(kotlin.String){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/debug(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/debug|debug@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/error(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/error|error@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/info(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/info|info@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/verbose(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/verbose|verbose@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/warn(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/warn|warn@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/fold(kotlin/Function1<#A, #B>, kotlin/Function1, #B>): #B // org.meshtastic.sdk/fold|fold@org.meshtastic.sdk.AdminResult<0:0>(kotlin.Function1<0:0,0:1>;kotlin.Function1,0:1>){0§;1§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/map(kotlin/Function1<#A, #B>): org.meshtastic.sdk/AdminResult<#B> // org.meshtastic.sdk/map|map@org.meshtastic.sdk.AdminResult<0:0>(kotlin.Function1<0:0,0:1>){0§;1§}[0] +final inline fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrElse(kotlin/Function1, #A>): #A // org.meshtastic.sdk/getOrElse|getOrElse@org.meshtastic.sdk.AdminResult<0:0>(kotlin.Function1,0:0>){0§}[0] +final inline fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/onFailure(kotlin/Function1, kotlin/Unit>): org.meshtastic.sdk/AdminResult<#A> // org.meshtastic.sdk/onFailure|onFailure@org.meshtastic.sdk.AdminResult<0:0>(kotlin.Function1,kotlin.Unit>){0§}[0] +final inline fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/onSuccess(kotlin/Function1<#A, kotlin/Unit>): org.meshtastic.sdk/AdminResult<#A> // org.meshtastic.sdk/onSuccess|onSuccess@org.meshtastic.sdk.AdminResult<0:0>(kotlin.Function1<0:0,kotlin.Unit>){0§}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setAmbientLightingConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setAmbientLightingConfig|setAmbientLightingConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setAudioConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setAudioConfig|setAudioConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setBluetoothConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setBluetoothConfig|setBluetoothConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setCannedMessageConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setCannedMessageConfig|setCannedMessageConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setDetectionSensorConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setDetectionSensorConfig|setDetectionSensorConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setDeviceConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setDeviceConfig|setDeviceConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setDisplayConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setDisplayConfig|setDisplayConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setExternalNotificationConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setExternalNotificationConfig|setExternalNotificationConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setLoraConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setLoraConfig|setLoraConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setMqttConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setMqttConfig|setMqttConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setNeighborInfoConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setNeighborInfoConfig|setNeighborInfoConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setNetworkConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setNetworkConfig|setNetworkConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setPaxcounterConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setPaxcounterConfig|setPaxcounterConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setPositionConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setPositionConfig|setPositionConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setPowerConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setPowerConfig|setPowerConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setRangeTestConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setRangeTestConfig|setRangeTestConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setRemoteHardwareConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setRemoteHardwareConfig|setRemoteHardwareConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setSecurityConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setSecurityConfig|setSecurityConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setSerialConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setSerialConfig|setSerialConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setStatusMessageConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setStatusMessageConfig|setStatusMessageConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setStoreForwardConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setStoreForwardConfig|setStoreForwardConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setTelemetryConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setTelemetryConfig|setTelemetryConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setTrafficManagementConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setTrafficManagementConfig|setTrafficManagementConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] final suspend fun (org.meshtastic.sdk/MessageHandle).org.meshtastic.sdk/retry(): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/retry|retry@org.meshtastic.sdk.MessageHandle(){}[0] +final suspend fun (org.meshtastic.sdk/MessageHandle).org.meshtastic.sdk/retryWith(org.meshtastic.sdk/RetryPolicy): org.meshtastic.sdk/SendOutcome // org.meshtastic.sdk/retryWith|retryWith@org.meshtastic.sdk.MessageHandle(org.meshtastic.sdk.RetryPolicy){}[0] final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/awaitInitialNodeDb(): kotlin.collections/Map // org.meshtastic.sdk/awaitInitialNodeDb|awaitInitialNodeDb@org.meshtastic.sdk.RadioClient(){}[0] final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/connectAndAwaitReady(kotlin.time/Duration = ...): org.meshtastic.sdk/ConfigBundle // org.meshtastic.sdk/connectAndAwaitReady|connectAndAwaitReady@org.meshtastic.sdk.RadioClient(kotlin.time.Duration){}[0] final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/send(kotlin/Function1): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/send|send@org.meshtastic.sdk.RadioClient(kotlin.Function1){}[0] diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api index b43d6e3..492b344 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -1,36 +1,78 @@ public abstract interface class org/meshtastic/sdk/AdminApi { + public abstract fun addContact (Lorg/meshtastic/proto/SharedContact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun backupPreferences (Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun backupPreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun batch (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteFile (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun editSettings (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun enterDfuMode (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun exitSimulator (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun factoryReset (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun factoryReset$default (Lorg/meshtastic/sdk/AdminApi;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun forNode-LO_6HKw (I)Lorg/meshtastic/sdk/AdminApi; + public abstract fun getCannedMessages (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getChannel-KtkxZUM (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getConfig (Lorg/meshtastic/proto/AdminMessage$ConfigType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getDeviceConnectionStatus (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getDeviceMetadata (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getModuleConfig (Lorg/meshtastic/proto/AdminMessage$ModuleConfigType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getOwner (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getRemoteHardwarePins (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getRingtone (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getUIConfig (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun keyVerification (Lorg/meshtastic/proto/KeyVerificationAdmin;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun listChannels (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun nodeDbReset (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun nodeDbReset$default (Lorg/meshtastic/sdk/AdminApi;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun nodeDbReset (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun otaRequest (Lorg/meshtastic/proto/AdminMessage$OTAEvent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun reboot-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun reboot-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun rebootOta-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun rebootOta-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun removeBackupPreferences (Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun removeBackupPreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun removeFixedPosition (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun removeNode-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun restorePreferences (Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun restorePreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun sendInputEvent (Lorg/meshtastic/proto/AdminMessage$InputEvent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setCannedMessages (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setChannel (Lorg/meshtastic/proto/Channel;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setConfig (Lorg/meshtastic/proto/Config;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setFavorite-TEw7vI0 (IZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setFixedPosition (Lorg/meshtastic/proto/Position;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setHamMode (Lorg/meshtastic/proto/HamParameters;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setIgnored-TEw7vI0 (IZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setModuleConfig (Lorg/meshtastic/proto/ModuleConfig;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setOwner (Lorg/meshtastic/proto/User;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setRingtone (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setScale (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setSensorConfig (Lorg/meshtastic/proto/SensorConfig;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setTime (Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun setTime$default (Lorg/meshtastic/sdk/AdminApi;Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun setTimeOnly (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun shutdown-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun shutdown-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun storeUIConfig (Lorg/meshtastic/proto/DeviceUIConfig;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun toggleMuted-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class org/meshtastic/sdk/AdminApi$DefaultImpls { + public static synthetic fun backupPreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun factoryReset$default (Lorg/meshtastic/sdk/AdminApi;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun nodeDbReset$default (Lorg/meshtastic/sdk/AdminApi;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun reboot-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun rebootOta-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun removeBackupPreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun restorePreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun setTime$default (Lorg/meshtastic/sdk/AdminApi;Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun shutdown-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } +public abstract interface class org/meshtastic/sdk/AdminBatchScope : org/meshtastic/sdk/AdminEdit { + public abstract fun getConfig (Lorg/meshtastic/proto/AdminMessage$ConfigType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getModuleConfig (Lorg/meshtastic/proto/AdminMessage$ModuleConfigType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun listChannels (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class org/meshtastic/sdk/AdminEdit { public abstract fun setChannel (Lorg/meshtastic/proto/Channel;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setConfig (Lorg/meshtastic/proto/Config;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -61,6 +103,13 @@ public final class org/meshtastic/sdk/AdminResult$NodeUnreachable : org/meshtast public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/AdminResult$RateLimited : org/meshtastic/sdk/AdminResult { + public static final field INSTANCE Lorg/meshtastic/sdk/AdminResult$RateLimited; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/AdminResult$SessionKeyExpired : org/meshtastic/sdk/AdminResult { public static final field INSTANCE Lorg/meshtastic/sdk/AdminResult$SessionKeyExpired; public fun equals (Ljava/lang/Object;)Z @@ -93,6 +142,35 @@ public final class org/meshtastic/sdk/AdminResult$Unauthorized : org/meshtastic/ public fun toString ()Ljava/lang/String; } +public abstract class org/meshtastic/sdk/AdminResultException : java/lang/Exception { + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class org/meshtastic/sdk/AdminResultException$NodeUnreachable : org/meshtastic/sdk/AdminResultException { + public fun ()V +} + +public final class org/meshtastic/sdk/AdminResultException$RateLimited : org/meshtastic/sdk/AdminResultException { + public fun ()V +} + +public final class org/meshtastic/sdk/AdminResultException$RoutingFailed : org/meshtastic/sdk/AdminResultException { + public fun (Lorg/meshtastic/proto/Routing$Error;)V + public final fun getError ()Lorg/meshtastic/proto/Routing$Error; +} + +public final class org/meshtastic/sdk/AdminResultException$SessionKeyExpired : org/meshtastic/sdk/AdminResultException { + public fun ()V +} + +public final class org/meshtastic/sdk/AdminResultException$Timeout : org/meshtastic/sdk/AdminResultException { + public fun ()V +} + +public final class org/meshtastic/sdk/AdminResultException$Unauthorized : org/meshtastic/sdk/AdminResultException { + public fun ()V +} + public final class org/meshtastic/sdk/AutoReconnectConfig { public static final field Companion Lorg/meshtastic/sdk/AutoReconnectConfig$Companion; public synthetic fun (ZJJLjava/lang/Integer;DDILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -146,6 +224,33 @@ public final class org/meshtastic/sdk/BatteryStatusKt { public static final fun toBatteryStatus (Lorg/meshtastic/proto/Telemetry;)Lorg/meshtastic/sdk/BatteryStatus; } +public final class org/meshtastic/sdk/ChannelHelpers { + public static final field INSTANCE Lorg/meshtastic/sdk/ChannelHelpers; + public static final field MAX_NAME_LENGTH I + public static final field MAX_PSK_LENGTH I + public static final field MIN_PSK_LENGTH I + public final fun createSettings (Ljava/lang/String;[B)Lorg/meshtastic/proto/ChannelSettings; + public static synthetic fun createSettings$default (Lorg/meshtastic/sdk/ChannelHelpers;Ljava/lang/String;[BILjava/lang/Object;)Lorg/meshtastic/proto/ChannelSettings; + public final fun findEmptySlot (Ljava/util/List;I)Ljava/lang/Integer; + public static synthetic fun findEmptySlot$default (Lorg/meshtastic/sdk/ChannelHelpers;Ljava/util/List;IILjava/lang/Object;)Ljava/lang/Integer; + public final fun validate (Ljava/lang/String;[BLorg/meshtastic/proto/Channel$Role;)Lorg/meshtastic/sdk/ChannelHelpers$ValidationResult; + public static synthetic fun validate$default (Lorg/meshtastic/sdk/ChannelHelpers;Ljava/lang/String;[BLorg/meshtastic/proto/Channel$Role;ILjava/lang/Object;)Lorg/meshtastic/sdk/ChannelHelpers$ValidationResult; +} + +public final class org/meshtastic/sdk/ChannelHelpers$ValidationResult { + public fun (ZLjava/util/List;)V + public synthetic fun (ZLjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()Ljava/util/List; + public final fun copy (ZLjava/util/List;)Lorg/meshtastic/sdk/ChannelHelpers$ValidationResult; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/ChannelHelpers$ValidationResult;ZLjava/util/List;ILjava/lang/Object;)Lorg/meshtastic/sdk/ChannelHelpers$ValidationResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getErrors ()Ljava/util/List; + public fun hashCode ()I + public final fun isValid ()Z + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/ChannelIndex { public static final field Companion Lorg/meshtastic/sdk/ChannelIndex$Companion; public static final field MAX_CHANNEL_INDEX I @@ -174,22 +279,52 @@ public final class org/meshtastic/sdk/ChannelUrl { } public final class org/meshtastic/sdk/ChannelUrlsKt { + public static final fun channelNameHashDjb2 (Ljava/lang/String;)I public static final fun default (Lorg/meshtastic/proto/Channel$Companion;)Lorg/meshtastic/proto/Channel; public static final fun getDefaultPsk ()[B public static final fun hash (Lorg/meshtastic/proto/ChannelSettings$Companion;Ljava/lang/String;[B)I public static final fun toUrl (Lorg/meshtastic/proto/ChannelSet;)Ljava/lang/String; } +public final class org/meshtastic/sdk/ConfigBuildersKt { + public static final fun setAmbientLightingConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setAudioConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setBluetoothConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setCannedMessageConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setDetectionSensorConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setDeviceConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setDisplayConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setExternalNotificationConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setLoraConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setMqttConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setNeighborInfoConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setNetworkConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setPaxcounterConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setPositionConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setPowerConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setRangeTestConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setRemoteHardwareConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setSecurityConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setSerialConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setStatusMessageConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setStoreForwardConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setTelemetryConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setTrafficManagementConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class org/meshtastic/sdk/ConfigBundle { - public fun (Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;)V + public fun (Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;Lorg/meshtastic/proto/DeviceUIConfig;)V + public synthetic fun (Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;Lorg/meshtastic/proto/DeviceUIConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lorg/meshtastic/proto/MyNodeInfo; public final fun component2 ()Lorg/meshtastic/proto/DeviceMetadata; public final fun component3 ()Ljava/util/List; public final fun component4 ()Ljava/util/List; - public final fun copy (Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;)Lorg/meshtastic/sdk/ConfigBundle; - public static synthetic fun copy$default (Lorg/meshtastic/sdk/ConfigBundle;Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lorg/meshtastic/sdk/ConfigBundle; + public final fun component5 ()Lorg/meshtastic/proto/DeviceUIConfig; + public final fun copy (Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;Lorg/meshtastic/proto/DeviceUIConfig;)Lorg/meshtastic/sdk/ConfigBundle; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/ConfigBundle;Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;Lorg/meshtastic/proto/DeviceUIConfig;ILjava/lang/Object;)Lorg/meshtastic/sdk/ConfigBundle; public fun equals (Ljava/lang/Object;)Z public final fun getConfigs ()Ljava/util/List; + public final fun getDeviceUIConfig ()Lorg/meshtastic/proto/DeviceUIConfig; public final fun getMetadata ()Lorg/meshtastic/proto/DeviceMetadata; public final fun getModuleConfigs ()Ljava/util/List; public final fun getMyInfo ()Lorg/meshtastic/proto/MyNodeInfo; @@ -206,11 +341,55 @@ public final class org/meshtastic/sdk/ConfigPhase : java/lang/Enum { public static fun values ()[Lorg/meshtastic/sdk/ConfigPhase; } +public final class org/meshtastic/sdk/CongestionLevel : java/lang/Enum { + public static final field CRITICAL Lorg/meshtastic/sdk/CongestionLevel; + public static final field HIGH Lorg/meshtastic/sdk/CongestionLevel; + public static final field LOW Lorg/meshtastic/sdk/CongestionLevel; + public static final field MEDIUM Lorg/meshtastic/sdk/CongestionLevel; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/meshtastic/sdk/CongestionLevel; + public static fun values ()[Lorg/meshtastic/sdk/CongestionLevel; +} + +public final class org/meshtastic/sdk/CongestionMetrics { + public static final field CRITICAL_THRESHOLD F + public static final field Companion Lorg/meshtastic/sdk/CongestionMetrics$Companion; + public static final field HIGH_THRESHOLD F + public static final field MEDIUM_THRESHOLD F + public fun (FF)V + public final fun component1 ()F + public final fun component2 ()F + public final fun copy (FF)Lorg/meshtastic/sdk/CongestionMetrics; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/CongestionMetrics;FFILjava/lang/Object;)Lorg/meshtastic/sdk/CongestionMetrics; + public fun equals (Ljava/lang/Object;)Z + public final fun getAirUtilTx ()F + public final fun getCanSendNonUrgent ()Z + public final fun getChannelUtil ()F + public final fun getLevel ()Lorg/meshtastic/sdk/CongestionLevel; + public final fun getSuggestedBackoff ()Lkotlin/time/Duration; + public final fun getSuggestedBackoff-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/CongestionMetrics$Companion { +} + public final class org/meshtastic/sdk/ConnectKt { public static final fun connectAndAwaitReady-8Mi8wO0 (Lorg/meshtastic/sdk/RadioClient;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun connectAndAwaitReady-8Mi8wO0$default (Lorg/meshtastic/sdk/RadioClient;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } +public final class org/meshtastic/sdk/ConnectionQuality : java/lang/Enum { + public static final field DIRECT Lorg/meshtastic/sdk/ConnectionQuality; + public static final field MQTT Lorg/meshtastic/sdk/ConnectionQuality; + public static final field RELAYED Lorg/meshtastic/sdk/ConnectionQuality; + public static final field UNKNOWN Lorg/meshtastic/sdk/ConnectionQuality; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/meshtastic/sdk/ConnectionQuality; + public static fun values ()[Lorg/meshtastic/sdk/ConnectionQuality; +} + public abstract interface class org/meshtastic/sdk/ConnectionState { } @@ -265,6 +444,27 @@ public final class org/meshtastic/sdk/ConnectionState$Reconnecting : org/meshtas public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/DeviceCapabilities { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lorg/meshtastic/sdk/DeviceCapabilities; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/DeviceCapabilities;Ljava/lang/String;ILjava/lang/Object;)Lorg/meshtastic/sdk/DeviceCapabilities; + public fun equals (Ljava/lang/Object;)Z + public final fun getCanMuteNode ()Z + public final fun getCanSendVerifiedContacts ()Z + public final fun getCanToggleTelemetryEnabled ()Z + public final fun getCanToggleUnmessageable ()Z + public final fun getFirmwareVersion ()Ljava/lang/String; + public final fun getSupportsEsp32Ota ()Z + public final fun getSupportsQrCodeSharing ()Z + public final fun getSupportsSecondaryChannelLocation ()Z + public final fun getSupportsStatusMessage ()Z + public final fun getSupportsTakConfig ()Z + public final fun getSupportsTrafficManagementConfig ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class org/meshtastic/sdk/DeviceStorage : java/lang/AutoCloseable { public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun close ()V @@ -282,6 +482,26 @@ public abstract interface class org/meshtastic/sdk/DeviceStorage : java/lang/Aut public abstract fun saveSessionPasskey (Lorg/meshtastic/sdk/SessionPasskey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class org/meshtastic/sdk/DeviceVersion : java/lang/Comparable { + public static final field Companion Lorg/meshtastic/sdk/DeviceVersion$Companion; + public fun (Ljava/lang/String;)V + public synthetic fun compareTo (Ljava/lang/Object;)I + public fun compareTo (Lorg/meshtastic/sdk/DeviceVersion;)I + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lorg/meshtastic/sdk/DeviceVersion; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/DeviceVersion;Ljava/lang/String;ILjava/lang/Object;)Lorg/meshtastic/sdk/DeviceVersion; + public fun equals (Ljava/lang/Object;)Z + public final fun getAsInt ()I + public final fun getVersionString ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/DeviceVersion$Companion { + public final fun getABS_MIN_SUPPORTED ()Lorg/meshtastic/sdk/DeviceVersion; + public final fun getMIN_SUPPORTED ()Lorg/meshtastic/sdk/DeviceVersion; +} + public final class org/meshtastic/sdk/DroppedFlow : java/lang/Enum { public static final field Events Lorg/meshtastic/sdk/DroppedFlow; public static final field Packets Lorg/meshtastic/sdk/DroppedFlow; @@ -290,6 +510,15 @@ public final class org/meshtastic/sdk/DroppedFlow : java/lang/Enum { public static fun values ()[Lorg/meshtastic/sdk/DroppedFlow; } +public final class org/meshtastic/sdk/ExternalChangeKind : java/lang/Enum { + public static final field CHANNEL Lorg/meshtastic/sdk/ExternalChangeKind; + public static final field CONFIG Lorg/meshtastic/sdk/ExternalChangeKind; + public static final field MODULE_CONFIG Lorg/meshtastic/sdk/ExternalChangeKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/meshtastic/sdk/ExternalChangeKind; + public static fun values ()[Lorg/meshtastic/sdk/ExternalChangeKind; +} + public final class org/meshtastic/sdk/FirmwareTimeKt { public static final fun firmwareSecondsToInstant (I)Lkotlin/time/Instant; public static final fun relativeTo (Lkotlin/time/Instant;Lkotlin/time/Instant;)Ljava/lang/String; @@ -374,6 +603,17 @@ public final class org/meshtastic/sdk/LoggingKt { public abstract interface class org/meshtastic/sdk/MeshEvent { } +public final class org/meshtastic/sdk/MeshEvent$CongestionWarning : org/meshtastic/sdk/MeshEvent { + public fun (Lorg/meshtastic/sdk/CongestionMetrics;)V + public final fun component1 ()Lorg/meshtastic/sdk/CongestionMetrics; + public final fun copy (Lorg/meshtastic/sdk/CongestionMetrics;)Lorg/meshtastic/sdk/MeshEvent$CongestionWarning; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$CongestionWarning;Lorg/meshtastic/sdk/CongestionMetrics;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$CongestionWarning; + public fun equals (Ljava/lang/Object;)Z + public final fun getMetrics ()Lorg/meshtastic/sdk/CongestionMetrics; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/MeshEvent$DeviceRebooted : org/meshtastic/sdk/MeshEvent { public fun ()V public fun (Ljava/lang/String;)V @@ -387,6 +627,17 @@ public final class org/meshtastic/sdk/MeshEvent$DeviceRebooted : org/meshtastic/ public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/MeshEvent$ExternalConfigChange : org/meshtastic/sdk/MeshEvent { + public fun (Lorg/meshtastic/sdk/ExternalChangeKind;)V + public final fun component1 ()Lorg/meshtastic/sdk/ExternalChangeKind; + public final fun copy (Lorg/meshtastic/sdk/ExternalChangeKind;)Lorg/meshtastic/sdk/MeshEvent$ExternalConfigChange; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$ExternalConfigChange;Lorg/meshtastic/sdk/ExternalChangeKind;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$ExternalConfigChange; + public fun equals (Ljava/lang/Object;)Z + public final fun getKind ()Lorg/meshtastic/sdk/ExternalChangeKind; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/MeshEvent$IdentityRebound : org/meshtastic/sdk/MeshEvent { public synthetic fun (IILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (IILjava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -420,6 +671,26 @@ public final class org/meshtastic/sdk/MeshEvent$KeyVerification : org/meshtastic public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/MeshEvent$MqttConnected : org/meshtastic/sdk/MeshEvent { + public static final field INSTANCE Lorg/meshtastic/sdk/MeshEvent$MqttConnected; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/MeshEvent$MqttDisconnected : org/meshtastic/sdk/MeshEvent { + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lorg/meshtastic/sdk/MeshEvent$MqttDisconnected; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$MqttDisconnected;Ljava/lang/String;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$MqttDisconnected; + public fun equals (Ljava/lang/Object;)Z + public final fun getReason ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/MeshEvent$Notification : org/meshtastic/sdk/MeshEvent { public fun (Lorg/meshtastic/proto/ClientNotification;)V public final fun component1 ()Lorg/meshtastic/proto/ClientNotification; @@ -508,9 +779,93 @@ public final class org/meshtastic/sdk/MeshEvent$TransportError : org/meshtastic/ public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/MeshNode { + public fun (Lorg/meshtastic/proto/NodeInfo;ZLorg/meshtastic/sdk/ConnectionQuality;Lorg/meshtastic/sdk/SignalQuality;)V + public final fun component1 ()Lorg/meshtastic/proto/NodeInfo; + public final fun component2 ()Z + public final fun component3 ()Lorg/meshtastic/sdk/ConnectionQuality; + public final fun component4 ()Lorg/meshtastic/sdk/SignalQuality; + public final fun copy (Lorg/meshtastic/proto/NodeInfo;ZLorg/meshtastic/sdk/ConnectionQuality;Lorg/meshtastic/sdk/SignalQuality;)Lorg/meshtastic/sdk/MeshNode; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshNode;Lorg/meshtastic/proto/NodeInfo;ZLorg/meshtastic/sdk/ConnectionQuality;Lorg/meshtastic/sdk/SignalQuality;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshNode; + public fun equals (Ljava/lang/Object;)Z + public final fun getAirUtilTx ()Ljava/lang/Float; + public final fun getAltitude ()Ljava/lang/Integer; + public final fun getBatteryLevel ()Ljava/lang/Integer; + public final fun getChannelUtilization ()Ljava/lang/Float; + public final fun getConnectionQuality ()Lorg/meshtastic/sdk/ConnectionQuality; + public final fun getDeviceMetrics ()Lorg/meshtastic/proto/DeviceMetrics; + public final fun getHopsAway ()Ljava/lang/Integer; + public final fun getHwModel ()Lorg/meshtastic/proto/HardwareModel; + public final fun getLastHeard ()I + public final fun getLatitude ()Ljava/lang/Double; + public final fun getLongName ()Ljava/lang/String; + public final fun getLongitude ()Ljava/lang/Double; + public final fun getMeshId ()Ljava/lang/String; + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public final fun getNodeNum ()I + public final fun getPosition ()Lorg/meshtastic/proto/Position; + public final fun getRaw ()Lorg/meshtastic/proto/NodeInfo; + public final fun getShortName ()Ljava/lang/String; + public final fun getSignalQuality ()Lorg/meshtastic/sdk/SignalQuality; + public final fun getSnr ()F + public final fun getUser ()Lorg/meshtastic/proto/User; + public final fun getViaMqtt ()Z + public final fun getVoltage ()Ljava/lang/Float; + public fun hashCode ()I + public final fun isFavorite ()Z + public final fun isIgnored ()Z + public final fun isMuted ()Z + public final fun isOnline ()Z + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/MeshNodeKt { + public static final fun toMeshNode (Lorg/meshtastic/proto/NodeInfo;I)Lorg/meshtastic/sdk/MeshNode; + public static final fun toMeshNodes (Ljava/lang/Iterable;I)Ljava/util/List; +} + public abstract interface annotation class org/meshtastic/sdk/MeshSendDsl : java/lang/annotation/Annotation { } +public final class org/meshtastic/sdk/MeshTopology { + public fun ()V + public final fun addNeighborInfo (Lorg/meshtastic/sdk/NeighborInfo;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun allEdges (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun edgeCount (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getEdge-52y08Yw (IILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getNeighbors-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun isDirectReach-52y08Yw (IILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun nodes (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun removeNode-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun shortestPath-52y08Yw (IILkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/meshtastic/sdk/MeshTopology$Edge { + public synthetic fun (IIFIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (IIFILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/NodeId;FI)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()Lorg/meshtastic/sdk/NodeId; + public final fun component2-Unx0hic ()I + public final fun component3 ()F + public final fun component4 ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/NodeId;FI)Lorg/meshtastic/sdk/MeshTopology$Edge; + public final fun copy-Lh4n1GE (IIFI)Lorg/meshtastic/sdk/MeshTopology$Edge; + public static synthetic fun copy-Lh4n1GE$default (Lorg/meshtastic/sdk/MeshTopology$Edge;IIFIILjava/lang/Object;)Lorg/meshtastic/sdk/MeshTopology$Edge; + public fun equals (Ljava/lang/Object;)Z + public final fun getFrom ()Lorg/meshtastic/sdk/NodeId; + public final fun getFrom-Unx0hic ()I + public final fun getLastUpdated ()I + public final fun getSnr ()F + public final fun getTo ()Lorg/meshtastic/sdk/NodeId; + public final fun getTo-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract class org/meshtastic/sdk/MeshtasticException : java/lang/Exception { public static final field Companion Lorg/meshtastic/sdk/MeshtasticException$Companion; public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -581,6 +936,7 @@ public final class org/meshtastic/sdk/MessageHandle { public final class org/meshtastic/sdk/MessageHandleRetryKt { public static final fun retry (Lorg/meshtastic/sdk/MessageHandle;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun retryWith (Lorg/meshtastic/sdk/MessageHandle;Lorg/meshtastic/sdk/RetryPolicy;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class org/meshtastic/sdk/MessageHelpersKt { @@ -611,6 +967,51 @@ public final class org/meshtastic/sdk/MessageId { public final synthetic fun unbox-impl ()I } +public final class org/meshtastic/sdk/NeighborInfo { + public static final field Companion Lorg/meshtastic/sdk/NeighborInfo$Companion; + public synthetic fun (ILjava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ILjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;Ljava/util/List;I)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()Ljava/util/List; + public final fun component3 ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;Ljava/util/List;I)Lorg/meshtastic/sdk/NeighborInfo; + public final fun copy-TEw7vI0 (ILjava/util/List;I)Lorg/meshtastic/sdk/NeighborInfo; + public static synthetic fun copy-TEw7vI0$default (Lorg/meshtastic/sdk/NeighborInfo;ILjava/util/List;IILjava/lang/Object;)Lorg/meshtastic/sdk/NeighborInfo; + public fun equals (Ljava/lang/Object;)Z + public final fun format (Lkotlin/jvm/functions/Function1;)Ljava/lang/String; + public static synthetic fun format$default (Lorg/meshtastic/sdk/NeighborInfo;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/String; + public final fun getLastUpdated ()I + public final fun getNeighbors ()Ljava/util/List; + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/NeighborInfo$Companion { + public final fun fromProto (ILjava/util/List;Ljava/util/List;I)Lorg/meshtastic/sdk/NeighborInfo; + public static synthetic fun fromProto$default (Lorg/meshtastic/sdk/NeighborInfo$Companion;ILjava/util/List;Ljava/util/List;IILjava/lang/Object;)Lorg/meshtastic/sdk/NeighborInfo; +} + +public final class org/meshtastic/sdk/NeighborInfo$Neighbor { + public synthetic fun (IFLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;F)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()F + public final fun copy (Lorg/meshtastic/sdk/NodeId;F)Lorg/meshtastic/sdk/NeighborInfo$Neighbor; + public final fun copy-UyS-FeY (IF)Lorg/meshtastic/sdk/NeighborInfo$Neighbor; + public static synthetic fun copy-UyS-FeY$default (Lorg/meshtastic/sdk/NeighborInfo$Neighbor;IFILjava/lang/Object;)Lorg/meshtastic/sdk/NeighborInfo$Neighbor; + public fun equals (Ljava/lang/Object;)Z + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public final fun getSnr ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class org/meshtastic/sdk/NodeChange { } @@ -625,6 +1026,21 @@ public final class org/meshtastic/sdk/NodeChange$Added : org/meshtastic/sdk/Node public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/NodeChange$CameOnline : org/meshtastic/sdk/NodeChange { + public synthetic fun (ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/NodeChange$CameOnline; + public final fun copy-LO_6HKw (I)Lorg/meshtastic/sdk/NodeChange$CameOnline; + public static synthetic fun copy-LO_6HKw$default (Lorg/meshtastic/sdk/NodeChange$CameOnline;IILjava/lang/Object;)Lorg/meshtastic/sdk/NodeChange$CameOnline; + public fun equals (Ljava/lang/Object;)Z + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/NodeChange$Removed : org/meshtastic/sdk/NodeChange { public synthetic fun (ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lorg/meshtastic/sdk/NodeId;)V @@ -664,6 +1080,23 @@ public final class org/meshtastic/sdk/NodeChange$Updated : org/meshtastic/sdk/No public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/NodeChange$WentOffline : org/meshtastic/sdk/NodeChange { + public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;I)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;I)Lorg/meshtastic/sdk/NodeChange$WentOffline; + public final fun copy-UyS-FeY (II)Lorg/meshtastic/sdk/NodeChange$WentOffline; + public static synthetic fun copy-UyS-FeY$default (Lorg/meshtastic/sdk/NodeChange$WentOffline;IIILjava/lang/Object;)Lorg/meshtastic/sdk/NodeChange$WentOffline; + public fun equals (Ljava/lang/Object;)Z + public final fun getLastHeard ()I + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/NodeField : java/lang/Enum { public static final field Battery Lorg/meshtastic/sdk/NodeField; public static final field DeviceInfo Lorg/meshtastic/sdk/NodeField; @@ -703,6 +1136,7 @@ public final class org/meshtastic/sdk/NodeId$Companion { } public final class org/meshtastic/sdk/NodeIdsKt { + public static final fun fromDefaultId (Lorg/meshtastic/sdk/NodeId$Companion;Ljava/lang/String;)Lorg/meshtastic/sdk/NodeId; public static final fun fromHex (Lorg/meshtastic/sdk/NodeId$Companion;Ljava/lang/String;)Lorg/meshtastic/sdk/NodeId; public static final fun isBroadcast (Lorg/meshtastic/sdk/NodeId;)Z public static final fun isBroadcast-LO_6HKw (I)Z @@ -711,6 +1145,8 @@ public final class org/meshtastic/sdk/NodeIdsKt { public static synthetic fun isLocal-e0xYr-o$default (ILorg/meshtastic/sdk/NodeId;ILjava/lang/Object;)Z public static final fun isUnicast (Lorg/meshtastic/sdk/NodeId;)Z public static final fun isUnicast-LO_6HKw (I)Z + public static final fun toDefaultId (Lorg/meshtastic/sdk/NodeId;)Ljava/lang/String; + public static final fun toDefaultId-LO_6HKw (I)Ljava/lang/String; public static final fun toHex (Lorg/meshtastic/sdk/NodeId;)Ljava/lang/String; public static final fun toHex-LO_6HKw (I)Ljava/lang/String; } @@ -722,6 +1158,15 @@ public final class org/meshtastic/sdk/NodeInfosKt { public static final fun getShortName (Lorg/meshtastic/proto/NodeInfo;)Ljava/lang/String; } +public final class org/meshtastic/sdk/NodeStatusKt { + public static final fun getConnectionQuality (Lorg/meshtastic/proto/NodeInfo;)Lorg/meshtastic/sdk/ConnectionQuality; + public static final fun getDEFAULT_ONLINE_THRESHOLD ()J + public static final fun getSignalQuality (Lorg/meshtastic/proto/NodeInfo;)Lorg/meshtastic/sdk/SignalQuality; + public static final fun isOnline (Lorg/meshtastic/proto/NodeInfo;ILkotlin/time/Duration;)Z + public static final fun isOnline-SxA4cEA (Lorg/meshtastic/proto/NodeInfo;IJ)Z + public static synthetic fun isOnline-SxA4cEA$default (Lorg/meshtastic/proto/NodeInfo;IJILjava/lang/Object;)Z +} + public final class org/meshtastic/sdk/PacketDecodeKt { public static final fun decodeAs (Lorg/meshtastic/proto/MeshPacket;Lcom/squareup/wire/ProtoAdapter;)Lcom/squareup/wire/Message; public static final fun decodeAsAdmin (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/AdminMessage; @@ -735,12 +1180,16 @@ public final class org/meshtastic/sdk/PacketDecodeKt { public final class org/meshtastic/sdk/PayloadAccessorsKt { public static final fun asAdminMessage (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/AdminMessage; + public static final fun asNeighborInfo (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/NeighborInfo; public static final fun asNodeInfo (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/NodeInfo; public static final fun asNodeInfoUser (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/User; public static final fun asPosition (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Position; public static final fun asRouting (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Routing; public static final fun asTelemetry (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Telemetry; public static final fun asText (Lorg/meshtastic/proto/MeshPacket;)Ljava/lang/String; + public static final fun asTraceroute (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/RouteDiscovery; + public static final fun asWaypoint (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Waypoint; + public static final fun getTextMessages (Lorg/meshtastic/sdk/RadioClient;)Lkotlinx/coroutines/flow/Flow; } public abstract interface class org/meshtastic/sdk/PayloadRedactor { @@ -751,6 +1200,16 @@ public final class org/meshtastic/sdk/PayloadRedactor$Companion { public final fun getDefault ()Lorg/meshtastic/sdk/PayloadRedactor; } +public final class org/meshtastic/sdk/PositionUtils { + public static final field INSTANCE Lorg/meshtastic/sdk/PositionUtils; + public final fun bearing (DDDD)D + public final fun bearing (Lorg/meshtastic/sdk/LatLng;Lorg/meshtastic/sdk/LatLng;)D + public final fun distance (DDDD)D + public final fun distance (Lorg/meshtastic/sdk/LatLng;Lorg/meshtastic/sdk/LatLng;)D + public final fun intToDegrees (I)D + public final fun isValidPosition (DD)Z +} + public final class org/meshtastic/sdk/ProtoBytesKt { public static final fun toByteArray (Lorg/meshtastic/proto/FromRadio;)[B public static final fun toByteArray (Lorg/meshtastic/proto/MeshPacket;)[B @@ -767,6 +1226,7 @@ public final class org/meshtastic/sdk/RadioClient : java/lang/AutoCloseable { public final fun connect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun disconnect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getAdmin ()Lorg/meshtastic/sdk/AdminApi; + public final fun getChannels ()Lkotlinx/coroutines/flow/StateFlow; public final fun getConfigBundle ()Lkotlinx/coroutines/flow/StateFlow; public final fun getConnection ()Lkotlinx/coroutines/flow/StateFlow; public final fun getEvents ()Lkotlinx/coroutines/flow/Flow; @@ -774,13 +1234,20 @@ public final class org/meshtastic/sdk/RadioClient : java/lang/AutoCloseable { public final fun getOwnNode ()Lkotlinx/coroutines/flow/StateFlow; public final fun getPackets ()Lkotlinx/coroutines/flow/Flow; public final fun getRouting ()Lorg/meshtastic/sdk/RoutingApi; + public final fun getStoreForward ()Lorg/meshtastic/sdk/StoreForwardApi; public final fun getTelemetry ()Lorg/meshtastic/sdk/TelemetryApi; public final fun nodeSnapshot (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun requestNodeInfo (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/MessageHandle; + public final fun requestNodeInfo-LO_6HKw (I)Lorg/meshtastic/sdk/MessageHandle; public final fun send (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/sdk/MessageHandle; public final fun send-IJD_lpo (Lorg/meshtastic/proto/PortNum;Lkotlinx/io/Buffer;IIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun send-IJD_lpo (Lorg/meshtastic/proto/PortNum;[BIIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun send-IJD_lpo$default (Lorg/meshtastic/sdk/RadioClient;Lorg/meshtastic/proto/PortNum;Lkotlinx/io/Buffer;IIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun send-IJD_lpo$default (Lorg/meshtastic/sdk/RadioClient;Lorg/meshtastic/proto/PortNum;[BIIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun sendRaw (Lorg/meshtastic/proto/ToRadio;)V + public final fun sendReaction (Ljava/lang/String;Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/ChannelIndex;I)Lorg/meshtastic/sdk/MessageHandle; + public final fun sendReaction-soBxvt8 (Ljava/lang/String;III)Lorg/meshtastic/sdk/MessageHandle; + public static synthetic fun sendReaction-soBxvt8$default (Lorg/meshtastic/sdk/RadioClient;Ljava/lang/String;IIIILjava/lang/Object;)Lorg/meshtastic/sdk/MessageHandle; public final fun sendText (Ljava/lang/String;Lorg/meshtastic/sdk/ChannelIndex;Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/MessageHandle; public final fun sendText-0mzNYbQ (Ljava/lang/String;II)Lorg/meshtastic/sdk/MessageHandle; public static synthetic fun sendText-0mzNYbQ$default (Lorg/meshtastic/sdk/RadioClient;Ljava/lang/String;IIILjava/lang/Object;)Lorg/meshtastic/sdk/MessageHandle; @@ -798,6 +1265,8 @@ public final class org/meshtastic/sdk/RadioClient$Builder { public final fun coroutineContext (Lkotlin/coroutines/CoroutineContext;)Lorg/meshtastic/sdk/RadioClient$Builder; public final fun disableBleHeartbeat ()Lorg/meshtastic/sdk/RadioClient$Builder; public final fun logger (Lorg/meshtastic/sdk/LogSink;)Lorg/meshtastic/sdk/RadioClient$Builder; + public final fun presenceTimeout (Lkotlin/time/Duration;)Lorg/meshtastic/sdk/RadioClient$Builder; + public final fun presenceTimeout-LRDsOJo (J)Lorg/meshtastic/sdk/RadioClient$Builder; public final fun protocolLogging (Lorg/meshtastic/sdk/LogLevel;Lorg/meshtastic/sdk/PayloadRedactor;)Lorg/meshtastic/sdk/RadioClient$Builder; public static synthetic fun protocolLogging$default (Lorg/meshtastic/sdk/RadioClient$Builder;Lorg/meshtastic/sdk/LogLevel;Lorg/meshtastic/sdk/PayloadRedactor;ILjava/lang/Object;)Lorg/meshtastic/sdk/RadioClient$Builder; public final fun rpcTimeout (Lkotlin/time/Duration;)Lorg/meshtastic/sdk/RadioClient$Builder; @@ -847,6 +1316,105 @@ public abstract interface class org/meshtastic/sdk/RadioTransport { public abstract fun send (Lorg/meshtastic/sdk/Frame;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class org/meshtastic/sdk/ResultKt { + public static final fun fold (Lorg/meshtastic/sdk/AdminResult;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static final fun getOrElse (Lorg/meshtastic/sdk/AdminResult;Ljava/lang/Object;)Ljava/lang/Object; + public static final fun getOrElse (Lorg/meshtastic/sdk/AdminResult;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static final fun getOrNull (Lorg/meshtastic/sdk/AdminResult;)Ljava/lang/Object; + public static final fun getOrThrow (Lorg/meshtastic/sdk/AdminResult;)Ljava/lang/Object; + public static final fun getStatusMessage (Lorg/meshtastic/sdk/ConnectionState;)Ljava/lang/String; + public static final fun isInProgress (Lorg/meshtastic/sdk/ConnectionState;)Z + public static final fun isSuccess (Lorg/meshtastic/sdk/AdminResult;)Z + public static final fun isUsable (Lorg/meshtastic/sdk/ConnectionState;)Z + public static final fun map (Lorg/meshtastic/sdk/AdminResult;Lkotlin/jvm/functions/Function1;)Lorg/meshtastic/sdk/AdminResult; + public static final fun onFailure (Lorg/meshtastic/sdk/AdminResult;Lkotlin/jvm/functions/Function1;)Lorg/meshtastic/sdk/AdminResult; + public static final fun onSuccess (Lorg/meshtastic/sdk/AdminResult;Lkotlin/jvm/functions/Function1;)Lorg/meshtastic/sdk/AdminResult; +} + +public abstract class org/meshtastic/sdk/RetryPolicy { + public final fun delayForAttempt (I)Lkotlin/time/Duration; + public final fun delayForAttempt-LV8wdWc (I)Lkotlin/time/Duration; + public final fun getMaxRetries ()I +} + +public final class org/meshtastic/sdk/RetryPolicy$ExponentialBackoff : org/meshtastic/sdk/RetryPolicy { + public synthetic fun (IJJDDILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (IJJDDLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ILkotlin/time/Duration;Lkotlin/time/Duration;DD)V + public final fun component1 ()I + public final fun component2 ()Lkotlin/time/Duration; + public final fun component2-UwyO8pc ()J + public final fun component3 ()Lkotlin/time/Duration; + public final fun component3-UwyO8pc ()J + public final fun component4 ()D + public final fun component5 ()D + public final fun copy (ILkotlin/time/Duration;Lkotlin/time/Duration;DD)Lorg/meshtastic/sdk/RetryPolicy$ExponentialBackoff; + public final fun copy-jKevqZI (IJJDD)Lorg/meshtastic/sdk/RetryPolicy$ExponentialBackoff; + public static synthetic fun copy-jKevqZI$default (Lorg/meshtastic/sdk/RetryPolicy$ExponentialBackoff;IJJDDILjava/lang/Object;)Lorg/meshtastic/sdk/RetryPolicy$ExponentialBackoff; + public fun equals (Ljava/lang/Object;)Z + public final fun getInitialDelay ()Lkotlin/time/Duration; + public final fun getInitialDelay-UwyO8pc ()J + public final fun getJitterFactor ()D + public final fun getMaxAttempts ()I + public final fun getMaxDelay ()Lkotlin/time/Duration; + public final fun getMaxDelay-UwyO8pc ()J + public final fun getMultiplier ()D + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/RetryPolicy$Fixed : org/meshtastic/sdk/RetryPolicy { + public synthetic fun (IJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (IJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ILkotlin/time/Duration;)V + public final fun component1 ()I + public final fun component2 ()Lkotlin/time/Duration; + public final fun component2-UwyO8pc ()J + public final fun copy (ILkotlin/time/Duration;)Lorg/meshtastic/sdk/RetryPolicy$Fixed; + public final fun copy-HG0u8IE (IJ)Lorg/meshtastic/sdk/RetryPolicy$Fixed; + public static synthetic fun copy-HG0u8IE$default (Lorg/meshtastic/sdk/RetryPolicy$Fixed;IJILjava/lang/Object;)Lorg/meshtastic/sdk/RetryPolicy$Fixed; + public fun equals (Ljava/lang/Object;)Z + public final fun getDelay ()Lkotlin/time/Duration; + public final fun getDelay-UwyO8pc ()J + public final fun getMaxAttempts ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/RetryPolicy$None : org/meshtastic/sdk/RetryPolicy { + public static final field INSTANCE Lorg/meshtastic/sdk/RetryPolicy$None; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/RouteDiscoveryResult { + public static final field Companion Lorg/meshtastic/sdk/RouteDiscoveryResult$Companion; + public fun (Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;)V + public synthetic fun (Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Ljava/util/List; + public final fun copy (Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Lorg/meshtastic/sdk/RouteDiscoveryResult; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/RouteDiscoveryResult;Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lorg/meshtastic/sdk/RouteDiscoveryResult; + public fun equals (Ljava/lang/Object;)Z + public final fun formatRoute (Lkotlin/jvm/functions/Function1;)Ljava/lang/String; + public final fun getHopsAway ()I + public final fun getRoute ()Ljava/util/List; + public final fun getRouteBack ()Ljava/util/List; + public final fun getSnrBack ()Ljava/util/List; + public final fun getSnrTowards ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/RouteDiscoveryResult$Companion { + public final fun fromProto (Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/NodeId;Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Lorg/meshtastic/sdk/RouteDiscoveryResult; + public final fun fromProto-MM27f9Y (IILjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Lorg/meshtastic/sdk/RouteDiscoveryResult; + public static synthetic fun fromProto-MM27f9Y$default (Lorg/meshtastic/sdk/RouteDiscoveryResult$Companion;IILjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lorg/meshtastic/sdk/RouteDiscoveryResult; +} + public abstract interface class org/meshtastic/sdk/RoutingApi { public static final field Companion Lorg/meshtastic/sdk/RoutingApi$Companion; public static final field DEFAULT_HOP_LIMIT I @@ -1053,10 +1621,187 @@ public final class org/meshtastic/sdk/SessionPasskey { public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/SfppHash { + public static final field INSTANCE Lorg/meshtastic/sdk/SfppHash; + public final fun compute ([BIII)[B +} + +public final class org/meshtastic/sdk/SharedContactUrl { + public static final field INSTANCE Lorg/meshtastic/sdk/SharedContactUrl; + public static final field PREFIX Ljava/lang/String; + public final fun encode (Lorg/meshtastic/proto/SharedContact;)Ljava/lang/String; + public final fun parse (Ljava/lang/String;)Lorg/meshtastic/proto/SharedContact; +} + +public final class org/meshtastic/sdk/SharedContactUrlKt { + public static final fun toUrl (Lorg/meshtastic/proto/SharedContact;)Ljava/lang/String; +} + +public final class org/meshtastic/sdk/SignalQuality : java/lang/Enum { + public static final field FAIR Lorg/meshtastic/sdk/SignalQuality; + public static final field GOOD Lorg/meshtastic/sdk/SignalQuality; + public static final field NONE Lorg/meshtastic/sdk/SignalQuality; + public static final field POOR Lorg/meshtastic/sdk/SignalQuality; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/meshtastic/sdk/SignalQuality; + public static fun values ()[Lorg/meshtastic/sdk/SignalQuality; +} + public abstract interface class org/meshtastic/sdk/StorageProvider { public abstract fun activate-153iwNM (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class org/meshtastic/sdk/StoreForwardApi { + public abstract fun getEvents ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getServers ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun requestHistory-kfYOmcw (Ljava/lang/Integer;Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun requestHistory-kfYOmcw$default (Lorg/meshtastic/sdk/StoreForwardApi;Ljava/lang/Integer;Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun requestStats-J5zNcmc (Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun requestStats-J5zNcmc$default (Lorg/meshtastic/sdk/StoreForwardApi;Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class org/meshtastic/sdk/StoreForwardApi$DefaultImpls { + public static synthetic fun requestHistory-kfYOmcw$default (Lorg/meshtastic/sdk/StoreForwardApi;Ljava/lang/Integer;Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun requestStats-J5zNcmc$default (Lorg/meshtastic/sdk/StoreForwardApi;Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public abstract interface class org/meshtastic/sdk/StoreForwardEvent { +} + +public final class org/meshtastic/sdk/StoreForwardEvent$Heartbeat : org/meshtastic/sdk/StoreForwardEvent { + public synthetic fun (ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/StoreForwardEvent$Heartbeat; + public final fun copy-LO_6HKw (I)Lorg/meshtastic/sdk/StoreForwardEvent$Heartbeat; + public static synthetic fun copy-LO_6HKw$default (Lorg/meshtastic/sdk/StoreForwardEvent$Heartbeat;IILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$Heartbeat; + public fun equals (Ljava/lang/Object;)Z + public final fun getServer ()Lorg/meshtastic/sdk/NodeId; + public final fun getServer-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$HistoryReplayComplete : org/meshtastic/sdk/StoreForwardEvent { + public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;I)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;I)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayComplete; + public final fun copy-UyS-FeY (II)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayComplete; + public static synthetic fun copy-UyS-FeY$default (Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayComplete;IIILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayComplete; + public fun equals (Ljava/lang/Object;)Z + public final fun getDelivered ()I + public final fun getServer ()Lorg/meshtastic/sdk/NodeId; + public final fun getServer-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$HistoryReplayStarted : org/meshtastic/sdk/StoreForwardEvent { + public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;I)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;I)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayStarted; + public final fun copy-UyS-FeY (II)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayStarted; + public static synthetic fun copy-UyS-FeY$default (Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayStarted;IIILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayStarted; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessageCount ()I + public final fun getServer ()Lorg/meshtastic/sdk/NodeId; + public final fun getServer-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$ServerDiscovered : org/meshtastic/sdk/StoreForwardEvent { + public synthetic fun (ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/StoreForwardEvent$ServerDiscovered; + public final fun copy-LO_6HKw (I)Lorg/meshtastic/sdk/StoreForwardEvent$ServerDiscovered; + public static synthetic fun copy-LO_6HKw$default (Lorg/meshtastic/sdk/StoreForwardEvent$ServerDiscovered;IILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$ServerDiscovered; + public fun equals (Ljava/lang/Object;)Z + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$ServerLost : org/meshtastic/sdk/StoreForwardEvent { + public synthetic fun (ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/StoreForwardEvent$ServerLost; + public final fun copy-LO_6HKw (I)Lorg/meshtastic/sdk/StoreForwardEvent$ServerLost; + public static synthetic fun copy-LO_6HKw$default (Lorg/meshtastic/sdk/StoreForwardEvent$ServerLost;IILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$ServerLost; + public fun equals (Ljava/lang/Object;)Z + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$SfppCanonAnnounced : org/meshtastic/sdk/StoreForwardEvent { + public fun ([BJ)V + public final fun component1 ()[B + public final fun component2 ()J + public final fun copy ([BJ)Lorg/meshtastic/sdk/StoreForwardEvent$SfppCanonAnnounced; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/StoreForwardEvent$SfppCanonAnnounced;[BJILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$SfppCanonAnnounced; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessageHash ()[B + public final fun getRxTime ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$SfppLinkProvided : org/meshtastic/sdk/StoreForwardEvent { + public fun (III[BZ)V + public final fun component1 ()I + public final fun component2 ()I + public final fun component3 ()I + public final fun component4 ()[B + public final fun component5 ()Z + public final fun copy (III[BZ)Lorg/meshtastic/sdk/StoreForwardEvent$SfppLinkProvided; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/StoreForwardEvent$SfppLinkProvided;III[BZILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$SfppLinkProvided; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfirmed ()Z + public final fun getFrom ()I + public final fun getMessageHash ()[B + public final fun getPacketId ()I + public final fun getTo ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardStats { + public fun ()V + public fun (IIIIIZ)V + public synthetic fun (IIIIIZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()I + public final fun component3 ()I + public final fun component4 ()I + public final fun component5 ()I + public final fun component6 ()Z + public final fun copy (IIIIIZ)Lorg/meshtastic/sdk/StoreForwardStats; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/StoreForwardStats;IIIIIZILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardStats; + public fun equals (Ljava/lang/Object;)Z + public final fun getHeartbeat ()Z + public final fun getMessagesMax ()I + public final fun getMessagesStored ()I + public final fun getRequests ()I + public final fun getRequestsFailed ()I + public final fun getUptime ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class org/meshtastic/sdk/TelemetryApi { public abstract fun observe-LO_6HKw (I)Lkotlinx/coroutines/flow/Flow; public abstract fun requestAirQuality-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -1065,16 +1810,25 @@ public abstract interface class org/meshtastic/sdk/TelemetryApi { public static synthetic fun requestDevice-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun requestEnvironment-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun requestEnvironment-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun requestHealth-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun requestHealth-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun requestHost-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun requestHost-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun requestLocalStats (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun requestPower-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun requestPower-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun requestTrafficManagement-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun requestTrafficManagement-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public final class org/meshtastic/sdk/TelemetryApi$DefaultImpls { public static synthetic fun requestAirQuality-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun requestDevice-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun requestEnvironment-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun requestHealth-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun requestHost-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun requestPower-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun requestTrafficManagement-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public abstract class org/meshtastic/sdk/TelemetryReading { From 02e01acad8d034c023341c5efc3ffaf973a35198 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 7 May 2026 08:28:37 -0500 Subject: [PATCH 35/36] Update testing module ABI dump Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- testing/api/jvm/testing.api | 3 +++ testing/api/testing.klib.api | 2 ++ 2 files changed, 5 insertions(+) diff --git a/testing/api/jvm/testing.api b/testing/api/jvm/testing.api index c918033..30cf75b 100644 --- a/testing/api/jvm/testing.api +++ b/testing/api/jvm/testing.api @@ -15,12 +15,15 @@ public final class org/meshtastic/sdk/testing/FakeRadioTransport : org/meshtasti public final fun injectFrame (Lorg/meshtastic/sdk/Frame;)V public final fun injectNeighborInfoResponse (ILorg/meshtastic/proto/NeighborInfo;I)V public static synthetic fun injectNeighborInfoResponse$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;ILorg/meshtastic/proto/NeighborInfo;IILjava/lang/Object;)V + public final fun injectPacket (Lorg/meshtastic/proto/MeshPacket;)V public final fun injectRouteReply (ILorg/meshtastic/proto/RouteDiscovery;I)V public static synthetic fun injectRouteReply$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;ILorg/meshtastic/proto/RouteDiscovery;IILjava/lang/Object;)V public final fun injectRoutingAck (II)V public static synthetic fun injectRoutingAck$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;IIILjava/lang/Object;)V public final fun injectRoutingError (ILorg/meshtastic/proto/Routing$Error;I)V public static synthetic fun injectRoutingError$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;ILorg/meshtastic/proto/Routing$Error;IILjava/lang/Object;)V + public final fun injectStoreForwardResponse (ILorg/meshtastic/proto/StoreAndForward;I)V + public static synthetic fun injectStoreForwardResponse$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;ILorg/meshtastic/proto/StoreAndForward;IILjava/lang/Object;)V public final fun injectTelemetryResponse (ILorg/meshtastic/proto/Telemetry;I)V public static synthetic fun injectTelemetryResponse$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;ILorg/meshtastic/proto/Telemetry;IILjava/lang/Object;)V public final fun outboundFrames ()Ljava/util/List; diff --git a/testing/api/testing.klib.api b/testing/api/testing.klib.api index 2c311cb..ba132d3 100644 --- a/testing/api/testing.klib.api +++ b/testing/api/testing.klib.api @@ -22,9 +22,11 @@ final class org.meshtastic.sdk.testing/FakeRadioTransport : org.meshtastic.sdk/R final fun injectAdminResponse(kotlin/Int, org.meshtastic.proto/AdminMessage, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectAdminResponse|injectAdminResponse(kotlin.Int;org.meshtastic.proto.AdminMessage;kotlin.Int){}[0] final fun injectFrame(org.meshtastic.sdk/Frame) // org.meshtastic.sdk.testing/FakeRadioTransport.injectFrame|injectFrame(org.meshtastic.sdk.Frame){}[0] final fun injectNeighborInfoResponse(kotlin/Int, org.meshtastic.proto/NeighborInfo, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectNeighborInfoResponse|injectNeighborInfoResponse(kotlin.Int;org.meshtastic.proto.NeighborInfo;kotlin.Int){}[0] + final fun injectPacket(org.meshtastic.proto/MeshPacket) // org.meshtastic.sdk.testing/FakeRadioTransport.injectPacket|injectPacket(org.meshtastic.proto.MeshPacket){}[0] final fun injectRouteReply(kotlin/Int, org.meshtastic.proto/RouteDiscovery, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectRouteReply|injectRouteReply(kotlin.Int;org.meshtastic.proto.RouteDiscovery;kotlin.Int){}[0] final fun injectRoutingAck(kotlin/Int, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectRoutingAck|injectRoutingAck(kotlin.Int;kotlin.Int){}[0] final fun injectRoutingError(kotlin/Int, org.meshtastic.proto/Routing.Error, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectRoutingError|injectRoutingError(kotlin.Int;org.meshtastic.proto.Routing.Error;kotlin.Int){}[0] + final fun injectStoreForwardResponse(kotlin/Int, org.meshtastic.proto/StoreAndForward, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectStoreForwardResponse|injectStoreForwardResponse(kotlin.Int;org.meshtastic.proto.StoreAndForward;kotlin.Int){}[0] final fun injectTelemetryResponse(kotlin/Int, org.meshtastic.proto/Telemetry, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectTelemetryResponse|injectTelemetryResponse(kotlin.Int;org.meshtastic.proto.Telemetry;kotlin.Int){}[0] final fun outboundFrames(): kotlin.collections/List // org.meshtastic.sdk.testing/FakeRadioTransport.outboundFrames|outboundFrames(){}[0] final fun outboundPackets(): kotlin.collections/List // org.meshtastic.sdk.testing/FakeRadioTransport.outboundPackets|outboundPackets(){}[0] From ef0b432362d76069f830a088aeb3155ab4696eea Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 7 May 2026 12:13:32 -0500 Subject: [PATCH 36/36] =?UTF-8?q?fix(core):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20topology=20KDoc,=20immutable=20cache,=20RetryPolicy?= =?UTF-8?q?=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MeshTopology: clarify KDoc that Mutex is for consumer-side concurrency, not engine-actor hot path (ADR-002 compliant) - MeshTopology.nodes(): return immutable copy via toSet() to prevent cache corruption through external mutation - RetryPolicy: fix KDoc example from policy.execute(handle) to handle.retryWith(policy) matching actual API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: James Rich --- .../kotlin/org/meshtastic/sdk/MeshTopology.kt | 12 ++++++++---- .../kotlin/org/meshtastic/sdk/RetryPolicy.kt | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt index abb32ec..8048d16 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt @@ -21,8 +21,11 @@ import kotlinx.coroutines.sync.withLock * val neighbors = topology.getNeighbors(nodeA) * ``` * - * **Thread-safe** — all mutations and reads are guarded by an internal [Mutex]. Safe to call - * concurrently from the engine actor and UI collectors. + * **Thread-safe** — all mutations and reads are guarded by an internal [Mutex], so this class is + * safe to call concurrently from any coroutine context. This is a consumer-side utility; it is + * **not** used inside the engine actor's hot path and therefore does not violate the single-writer + * invariant (ADR-002). + * * The graph is directed — if node A reports node B as a neighbor, that's a directed edge A→B. * Undirected queries consider both directions. */ @@ -67,13 +70,14 @@ public class MeshTopology { /** All nodes that have reported neighbors or been reported as a neighbor. */ public suspend fun nodes(): Set = mutex.withLock { - cachedNodes?.let { return@withLock it } + cachedNodes?.let { return@withLock it.toSet() } val result = mutableSetOf() adjacency.forEach { (reporter, neighbors) -> result.add(reporter) result.addAll(neighbors.keys) } - result.also { cachedNodes = it } + cachedNodes = result + result.toSet() } /** Get all outgoing edges from a node (nodes it reported as neighbors). */ diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt index fbc094c..6685d43 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt @@ -19,7 +19,7 @@ import kotlin.time.Duration.Companion.seconds * ```kotlin * val policy = RetryPolicy.ExponentialBackoff() * val handle = client.sendText("hello") - * policy.execute(handle) // suspends until delivered or max attempts exhausted + * handle.retryWith(policy) // suspends until success, non-retryable failure, or max attempts exhausted * ``` */ public sealed class RetryPolicy {