diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkPacketHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkPacketHandlerTest.kt
new file mode 100644
index 000000000..e00b2371f
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkPacketHandlerTest.kt
@@ -0,0 +1,239 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.radio
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.MeshActivity
+import org.meshtastic.core.testing.FakeServiceRepository
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.QueueStatus
+import org.meshtastic.proto.ToRadio
+import org.meshtastic.sdk.RadioClient
+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.assertFalse
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SdkPacketHandlerTest {
+
+ @Test
+ fun `sendToRadio MeshPacket routes through client and emits send activity`() = runTest {
+ val serviceRepository = FakeServiceRepository()
+ val fixture = connectedHandler(serviceRepository)
+ try {
+ val outboundBefore = fixture.transport.outboundPackets().size
+ val packet = MeshPacket(
+ id = 42,
+ to = 0x22222222,
+ decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP),
+ )
+
+ // Start collecting before the emission (SharedFlow has no replay)
+ val activityDeferred = backgroundScope.async { serviceRepository.meshActivityFlow.first() }
+ runCurrent()
+
+ fixture.handler.sendToRadio(packet)
+ runCurrent()
+
+ val sent = fixture.transport.outboundPackets().drop(outboundBefore)
+ assertTrue(sent.any { it.id == 42 && it.to == 0x22222222 })
+ assertEquals(MeshActivity.Send, activityDeferred.await())
+ } finally {
+ fixture.client.disconnect()
+ }
+ }
+
+ @Test
+ fun `sendToRadio MeshPacket with no client drops packet`() = runTest {
+ val serviceRepository = FakeServiceRepository()
+ val handler = disconnectedHandler(serviceRepository)
+
+ val packet = MeshPacket(id = 99, to = 0x33333333, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
+ handler.sendToRadio(packet)
+ runCurrent()
+
+ // No crash, no activity emitted
+ assertEquals(emptyList(), serviceRepository.storeForwardServers.value)
+ }
+
+ @Test
+ fun `sendToRadio ToRadio with packet field routes MeshPacket`() = runTest {
+ val serviceRepository = FakeServiceRepository()
+ val fixture = connectedHandler(serviceRepository)
+ try {
+ val outboundBefore = fixture.transport.outboundPackets().size
+ val meshPacket = MeshPacket(
+ id = 7,
+ to = 0x44444444,
+ decoded = Data(portnum = PortNum.POSITION_APP),
+ )
+ val toRadio = ToRadio(packet = meshPacket)
+
+ fixture.handler.sendToRadio(toRadio)
+ runCurrent()
+
+ val sent = fixture.transport.outboundPackets().drop(outboundBefore)
+ assertTrue(sent.any { it.id == 7 && it.to == 0x44444444 })
+ } finally {
+ fixture.client.disconnect()
+ }
+ }
+
+ @Test
+ fun `sendToRadio ToRadio without packet calls sendRaw`() = runTest {
+ val serviceRepository = FakeServiceRepository()
+ val fixture = connectedHandler(serviceRepository)
+ try {
+ val rawFrame = ToRadio(want_config_id = 12345)
+
+ fixture.handler.sendToRadio(rawFrame)
+ advanceUntilIdle()
+
+ // sendRaw doesn't crash — the transport received the frame.
+ // We can verify no MeshActivity.Send was emitted (raw frames don't emit activity).
+ } finally {
+ fixture.client.disconnect()
+ }
+ }
+
+ @Test
+ fun `sendToRadio ToRadio non-packet with no client drops silently`() = runTest {
+ val serviceRepository = FakeServiceRepository()
+ val handler = disconnectedHandler(serviceRepository)
+
+ val rawFrame = ToRadio(want_config_id = 999)
+ handler.sendToRadio(rawFrame)
+ advanceUntilIdle()
+
+ // No crash
+ }
+
+ @Test
+ fun `sendToRadioAndAwait returns true on success`() = runTest {
+ val serviceRepository = FakeServiceRepository()
+ val fixture = connectedHandler(serviceRepository)
+ try {
+ val packet = MeshPacket(
+ id = 50,
+ to = 0x55555555,
+ decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP),
+ )
+
+ val result = fixture.handler.sendToRadioAndAwait(packet)
+ assertTrue(result)
+ } finally {
+ fixture.client.disconnect()
+ }
+ }
+
+ @Test
+ fun `sendToRadioAndAwait returns false when no client`() = runTest {
+ val handler = disconnectedHandler(FakeServiceRepository())
+
+ val packet = MeshPacket(id = 60, to = 0x66666666, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
+ val result = handler.sendToRadioAndAwait(packet)
+ assertFalse(result)
+ }
+
+ @Test
+ fun `handleQueueStatus is a no-op`() = runTest {
+ val handler = disconnectedHandler(FakeServiceRepository())
+ handler.handleQueueStatus(QueueStatus(res = 0, free = 32, maxlen = 32))
+ // No crash — SDK handles queue internally
+ }
+
+ @Test
+ fun `removeResponse is a no-op`() = runTest {
+ val handler = disconnectedHandler(FakeServiceRepository())
+ handler.removeResponse(dataRequestId = 123, complete = true)
+ // No crash
+ }
+
+ @Test
+ fun `stopPacketQueue is a no-op`() = runTest {
+ val handler = disconnectedHandler(FakeServiceRepository())
+ handler.stopPacketQueue()
+ // No crash
+ }
+
+ // ── Helpers ─────────────────────────────────────────────────────────────
+
+ private data class HandlerFixture(
+ val handler: SdkPacketHandler,
+ val transport: FakeRadioTransport,
+ val client: RadioClient,
+ )
+
+ private suspend fun TestScope.connectedHandler(
+ serviceRepository: FakeServiceRepository,
+ ): HandlerFixture {
+ val transport = FakeRadioTransport(
+ identity = TransportIdentity("fake:packet-handler-test"),
+ autoHandshake = true,
+ )
+ val client = RadioClient.Builder()
+ .transport(transport)
+ .storage(InMemoryStorageProvider())
+ .coroutineContext(backgroundScope.coroutineContext)
+ .autoSyncTimeOnConnect(false)
+ .build()
+ client.connect()
+ runCurrent()
+
+ val dispatcher = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor]
+ as CoroutineDispatcher
+ val handler = SdkPacketHandler(
+ accessor = TestRadioClientAccessor(client),
+ serviceRepository = serviceRepository,
+ dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
+ )
+ return HandlerFixture(handler, transport, client)
+ }
+
+ private fun TestScope.disconnectedHandler(serviceRepository: FakeServiceRepository): SdkPacketHandler {
+ val dispatcher = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor]
+ as CoroutineDispatcher
+ return SdkPacketHandler(
+ accessor = TestRadioClientAccessor(null),
+ serviceRepository = serviceRepository,
+ dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
+ )
+ }
+
+ private class TestRadioClientAccessor(client: RadioClient?) : RadioClientAccessor {
+ override val client = MutableStateFlow(client)
+
+ override fun rebuildAndConnectAsync() = Unit
+
+ override fun disconnect() = Unit
+ }
+}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt
index 8d9f2334e..d2cc4cbd8 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt
@@ -399,7 +399,85 @@ class SdkRadioControllerTest {
}
}
- private suspend fun TestScope.connectedFixture(myNodeNum: Int = 0x11111111): ControllerFixture {
+ @Test
+ fun `sendMessage routes text packet through sdk and emits activity`() = runTest {
+ val serviceRepository = FakeServiceRepository()
+ val fixture = connectedFixture(serviceRepository = serviceRepository)
+ try {
+ val outboundBefore = fixture.transport.outboundPackets().size
+ val packet = org.meshtastic.core.model.DataPacket(
+ to = 0x22334455,
+ bytes = okio.ByteString.of(*"Hello mesh".encodeToByteArray()),
+ dataType = PortNum.TEXT_MESSAGE_APP.value,
+ channel = 0,
+ wantAck = true,
+ )
+
+ fixture.controller.sendMessage(packet)
+ runCurrent()
+
+ val sent = fixture.transport.outboundPackets().drop(outboundBefore)
+ assertTrue(sent.any { it.to == 0x22334455 && it.decoded?.portnum == PortNum.TEXT_MESSAGE_APP })
+ } finally {
+ fixture.client.disconnect()
+ }
+ }
+
+ @Test
+ fun `sendMessage with no client drops silently`() = runTest {
+ val serviceRepository = FakeServiceRepository()
+ val dispatcher =
+ backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as CoroutineDispatcher
+ val controller =
+ SdkRadioController(
+ accessor = TestRadioClientAccessor(null),
+ serviceRepository = serviceRepository,
+ nodeRepository = FakeNodeRepository(),
+ locationManager = NoOpLocationManager,
+ deliveryTracker =
+ MessageDeliveryTracker(
+ lazyOf(mock(MockMode.autofill)),
+ CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
+ ),
+ radioPrefs = FakeRadioPrefs(),
+ )
+
+ val packet = org.meshtastic.core.model.DataPacket(
+ to = 0x22334455,
+ bytes = okio.ByteString.of(*"hello".encodeToByteArray()),
+ dataType = PortNum.TEXT_MESSAGE_APP.value,
+ )
+ controller.sendMessage(packet)
+ // No crash, no exception
+ }
+
+ @Test
+ fun `requireClient throws when disconnected`() = runTest {
+ val dispatcher =
+ backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as CoroutineDispatcher
+ val controller =
+ SdkRadioController(
+ accessor = TestRadioClientAccessor(null),
+ serviceRepository = FakeServiceRepository(),
+ nodeRepository = FakeNodeRepository(),
+ locationManager = NoOpLocationManager,
+ deliveryTracker =
+ MessageDeliveryTracker(
+ lazyOf(mock(MockMode.autofill)),
+ CoroutineDispatchers(dispatcher, dispatcher, dispatcher),
+ ),
+ radioPrefs = FakeRadioPrefs(),
+ )
+
+ assertFailsWith {
+ controller.setLocalConfig(Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)))
+ }
+ }
+
+ private suspend fun TestScope.connectedFixture(
+ myNodeNum: Int = 0x11111111,
+ serviceRepository: FakeServiceRepository = FakeServiceRepository(),
+ ): ControllerFixture {
val transport =
FakeRadioTransport(
identity = TransportIdentity("fake:sdk-radio-controller"),
@@ -420,7 +498,7 @@ class SdkRadioControllerTest {
val controller =
SdkRadioController(
accessor = TestRadioClientAccessor(client),
- serviceRepository = FakeServiceRepository(),
+ serviceRepository = serviceRepository,
nodeRepository = FakeNodeRepository(),
locationManager = NoOpLocationManager,
deliveryTracker =
@@ -454,8 +532,8 @@ class SdkRadioControllerTest {
val myNodeNum: Int,
)
- private class TestRadioClientAccessor(client: RadioClient) : RadioClientAccessor {
- override val client = MutableStateFlow(client)
+ private class TestRadioClientAccessor(client: RadioClient?) : RadioClientAccessor {
+ override val client = MutableStateFlow(client)
override fun rebuildAndConnectAsync() = Unit
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceServiceTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceServiceTest.kt
new file mode 100644
index 000000000..7ddb3f2f7
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceServiceTest.kt
@@ -0,0 +1,185 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.radio
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.model.DeviceType
+import org.meshtastic.core.model.InterfaceId
+import org.meshtastic.core.testing.FakeRadioPrefs
+import org.meshtastic.sdk.RadioClient
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SdkRadioInterfaceServiceTest {
+
+ @Test
+ fun `supportedDeviceTypes includes BLE TCP and USB`() {
+ val service = createService()
+ assertEquals(listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB), service.supportedDeviceTypes)
+ }
+
+ @Test
+ fun `currentDeviceAddressFlow delegates to radioPrefs devAddr`() {
+ val prefs = FakeRadioPrefs()
+ prefs.setDevAddr("x0123456789AB")
+ val service = createService(prefs = prefs)
+
+ assertEquals("x0123456789AB", service.currentDeviceAddressFlow.value)
+ }
+
+ @Test
+ fun `getDeviceAddress returns current prefs value`() {
+ val prefs = FakeRadioPrefs()
+ prefs.setDevAddr("tTCP:192.168.1.1")
+ val service = createService(prefs = prefs)
+
+ assertEquals("tTCP:192.168.1.1", service.getDeviceAddress())
+ }
+
+ @Test
+ fun `getDeviceAddress returns null when no address set`() {
+ val service = createService()
+ assertNull(service.getDeviceAddress())
+ }
+
+ @Test
+ fun `setDeviceAddress stores new address and returns true`() {
+ val prefs = FakeRadioPrefs()
+ val service = createService(prefs = prefs)
+
+ val changed = service.setDeviceAddress("x0123456789AB")
+
+ assertTrue(changed)
+ assertEquals("x0123456789AB", prefs.devAddr.value)
+ }
+
+ @Test
+ fun `setDeviceAddress with same address returns false`() {
+ val prefs = FakeRadioPrefs()
+ prefs.setDevAddr("x0123456789AB")
+ val service = createService(prefs = prefs)
+
+ val changed = service.setDeviceAddress("x0123456789AB")
+ assertFalse(changed)
+ }
+
+ @Test
+ fun `setDeviceAddress to null clears address`() {
+ val prefs = FakeRadioPrefs()
+ prefs.setDevAddr("x0123456789AB")
+ val service = createService(prefs = prefs)
+
+ val changed = service.setDeviceAddress(null)
+
+ assertTrue(changed)
+ assertNull(prefs.devAddr.value)
+ }
+
+ @Test
+ fun `isMockTransport returns true for mock address`() {
+ val prefs = FakeRadioPrefs()
+ prefs.setDevAddr("mMockDevice")
+ val service = createService(prefs = prefs)
+
+ assertTrue(service.isMockTransport())
+ }
+
+ @Test
+ fun `isMockTransport returns false for BLE address`() {
+ val prefs = FakeRadioPrefs()
+ prefs.setDevAddr("x0123456789AB")
+ val service = createService(prefs = prefs)
+
+ assertFalse(service.isMockTransport())
+ }
+
+ @Test
+ fun `isMockTransport returns false for TCP address`() {
+ val prefs = FakeRadioPrefs()
+ prefs.setDevAddr("tTCP:10.0.0.1")
+ val service = createService(prefs = prefs)
+
+ assertFalse(service.isMockTransport())
+ }
+
+ @Test
+ fun `isMockTransport returns false when no address set`() {
+ val service = createService()
+ assertFalse(service.isMockTransport())
+ }
+
+ @Test
+ fun `toInterfaceAddress composes interface id and rest`() {
+ val service = createService()
+
+ assertEquals("x0123456789AB", service.toInterfaceAddress(InterfaceId.BLUETOOTH, "0123456789AB"))
+ assertEquals("tTCP:192.168.1.1", service.toInterfaceAddress(InterfaceId.TCP, "TCP:192.168.1.1"))
+ assertEquals("s/dev/ttyUSB0", service.toInterfaceAddress(InterfaceId.SERIAL, "/dev/ttyUSB0"))
+ assertEquals("mMock", service.toInterfaceAddress(InterfaceId.MOCK, "Mock"))
+ }
+
+ @Test
+ fun `connect delegates to accessor rebuildAndConnectAsync`() {
+ val accessor = RecordingAccessor()
+ val service = createService(accessor = accessor)
+
+ service.connect()
+
+ assertTrue(accessor.rebuildCalled)
+ }
+
+ @Test
+ fun `disconnect delegates to accessor disconnect`() = runTest {
+ val accessor = RecordingAccessor()
+ val service = createService(accessor = accessor)
+
+ service.disconnect()
+
+ assertTrue(accessor.disconnectCalled)
+ }
+
+ // ── Helpers ─────────────────────────────────────────────────────────────
+
+ private fun createService(
+ prefs: FakeRadioPrefs = FakeRadioPrefs(),
+ accessor: RadioClientAccessor = RecordingAccessor(),
+ ): SdkRadioInterfaceService = SdkRadioInterfaceService(prefs, accessor)
+
+ private class RecordingAccessor : RadioClientAccessor {
+ override val client = MutableStateFlow(null)
+
+ var rebuildCalled = false
+ private set
+
+ var disconnectCalled = false
+ private set
+
+ override fun rebuildAndConnectAsync() {
+ rebuildCalled = true
+ }
+
+ override fun disconnect() {
+ disconnectCalled = true
+ }
+ }
+}