refactor(transport): complete transport architecture overhaul — extract callback, wire BleReconnectPolicy, fix safety issues (#5080)

This commit is contained in:
James Rich 2026-04-11 23:22:18 -05:00 committed by GitHub
parent 962c619c4c
commit e85300531e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1184 additions and 1018 deletions

View file

@ -39,18 +39,26 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.AirQualityMetrics
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HostMetrics
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.Telemetry
import kotlin.math.absoluteValue
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
import org.meshtastic.proto.Position as ProtoPosition
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
@Single
@ -68,10 +76,6 @@ class CommandSenderImpl(
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
// We'll need a way to track connection state in shared code,
// maybe via ServiceRepository or similar.
// For now I'll assume it's injected or available.
init {
radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope)
radioConfigRepository.channelSetFlow.onEach { channelSet.value = it }.launchIn(scope)
@ -141,14 +145,11 @@ class CommandSenderImpl(
if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) {
val actualSize = Data.ADAPTER.encodedSize(data)
p.status = MessageStatus.ERROR
// throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})")
// RemoteException is Android specific. For KMP we might want a custom exception.
error("Message too long: $actualSize bytes")
} else {
p.status = MessageStatus.QUEUED
}
// TODO: Check connection state
sendNow(p)
}
@ -191,7 +192,7 @@ class CommandSenderImpl(
return packetHandler.sendToRadioAndAwait(packet)
}
override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {
override fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) {
val myNum = nodeManager.myNodeNum.value ?: return
val idNum = destNum ?: myNum
Logger.d { "Sending our position/time to=$idNum $pos" }
@ -217,7 +218,7 @@ class CommandSenderImpl(
override fun requestPosition(destNum: Int, currentPosition: Position) {
val meshPosition =
org.meshtastic.proto.Position(
ProtoPosition(
latitude_i = Position.degI(currentPosition.latitude),
longitude_i = Position.degI(currentPosition.longitude),
altitude = currentPosition.altitude,
@ -240,7 +241,7 @@ class CommandSenderImpl(
override fun setFixedPosition(destNum: Int, pos: Position) {
val meshPos =
org.meshtastic.proto.Position(
ProtoPosition(
latitude_i = Position.degI(pos.latitude),
longitude_i = Position.degI(pos.longitude),
altitude = pos.altitude,
@ -293,21 +294,17 @@ class CommandSenderImpl(
if (type == TelemetryType.PAX) {
portNum = PortNum.PAXCOUNTER_APP
payloadBytes = org.meshtastic.proto.Paxcount().encode().toByteString()
payloadBytes = Paxcount().encode().toByteString()
} else {
portNum = PortNum.TELEMETRY_APP
payloadBytes =
Telemetry(
device_metrics =
if (type == TelemetryType.DEVICE) org.meshtastic.proto.DeviceMetrics() else null,
environment_metrics =
if (type == TelemetryType.ENVIRONMENT) org.meshtastic.proto.EnvironmentMetrics() else null,
air_quality_metrics =
if (type == TelemetryType.AIR_QUALITY) org.meshtastic.proto.AirQualityMetrics() else null,
power_metrics = if (type == TelemetryType.POWER) org.meshtastic.proto.PowerMetrics() else null,
local_stats =
if (type == TelemetryType.LOCAL_STATS) org.meshtastic.proto.LocalStats() else null,
host_metrics = if (type == TelemetryType.HOST) org.meshtastic.proto.HostMetrics() else null,
device_metrics = if (type == TelemetryType.DEVICE) DeviceMetrics() else null,
environment_metrics = if (type == TelemetryType.ENVIRONMENT) EnvironmentMetrics() else null,
air_quality_metrics = if (type == TelemetryType.AIR_QUALITY) AirQualityMetrics() else null,
power_metrics = if (type == TelemetryType.POWER) PowerMetrics() else null,
local_stats = if (type == TelemetryType.LOCAL_STATS) LocalStats() else null,
host_metrics = if (type == TelemetryType.HOST) HostMetrics() else null,
)
.encode()
.toByteString()

View file

@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
@ -199,7 +200,7 @@ class MeshActionHandlerImpl(
commandSender.sendData(p)
serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
dataHandler.value.rememberDataPacket(p, myNodeNum, false)
val bytes = p.bytes ?: okio.ByteString.EMPTY
val bytes = p.bytes ?: ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
}
@ -356,7 +357,7 @@ class MeshActionHandlerImpl(
override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA
val otaEvent =
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.ByteString.EMPTY)
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY)
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
}

View file

@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okio.ByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
@ -221,7 +222,7 @@ class MeshConnectionManagerImpl(
private fun tearDownConnection() {
packetHandler.stopPacketQueue()
commandSender.setSessionPasskey(okio.ByteString.EMPTY) // Prevent stale passkey on reconnect.
commandSender.setSessionPasskey(ByteString.EMPTY) // Prevent stale passkey on reconnect.
locationManager.stop()
mqttManager.stop()
}

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okio.ByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
@ -247,7 +248,7 @@ class MeshDataHandlerImpl(
val payload = packet.decoded?.payload ?: return
val u =
User.ADAPTER.decode(payload)
.let { if (it.is_licensed == true) it.copy(public_key = okio.ByteString.EMPTY) else it }
.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it }
.let {
if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) {
it.copy(long_name = "${it.long_name} (MQTT)")

View file

@ -69,7 +69,7 @@ class MeshMessageProcessorImpl(
@Volatile private var lastLocalNodeRefreshMs = 0L
private val earlyMutex = Mutex()
private val earlyReceivedPackets = kotlin.collections.ArrayDeque<MeshPacket>()
private val earlyReceivedPackets = ArrayDeque<MeshPacket>()
private val maxEarlyPacketBuffer = 10240
override fun clearEarlyPackets() {

View file

@ -65,7 +65,7 @@ class TracerouteHandlerImpl(
routeDiscovery.getTracerouteResponse(
getUser = { num ->
nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" }
?: "Unknown" // TODO: Use core:resources once available in core:data
?: "Unknown"
},
headerTowards = "Route towards destination:",
headerBack = "Route back to us:",

View file

@ -132,13 +132,10 @@ class MeshConnectionManagerImplTest {
scope,
)
@AfterTest fun tearDown() {}
@AfterTest fun tearDown() = Unit
@Test
fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
manager = createManager(backgroundScope)
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
@ -153,16 +150,6 @@ class MeshConnectionManagerImplTest {
@Test
fun `Disconnected state stops services`() = runTest(testDispatcher) {
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
manager = createManager(backgroundScope)
// Transition to Connected first so that Disconnected actually does something
@ -191,11 +178,6 @@ class MeshConnectionManagerImplTest {
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT),
)
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
manager = createManager(backgroundScope)
@ -216,11 +198,6 @@ class MeshConnectionManagerImplTest {
// Power saving enabled
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
manager = createManager(backgroundScope)
advanceUntilIdle()
@ -280,11 +257,6 @@ class MeshConnectionManagerImplTest {
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER),
)
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
manager = createManager(backgroundScope)
@ -317,11 +289,6 @@ class MeshConnectionManagerImplTest {
// Power saving enabled so DeviceSleep is preserved (not mapped to Disconnected)
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
// Record every state transition so we can verify ordering
@ -367,11 +334,6 @@ class MeshConnectionManagerImplTest {
// Power saving enabled with a short ls_secs so the sleep timeout fires quickly
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true, ls_secs = 1))
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
val observed = mutableListOf<ConnectionState>()

View file

@ -21,6 +21,7 @@ import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
@ -84,6 +85,8 @@ class PacketHandlerImplTest {
val toRadio = ToRadio(packet = MeshPacket(id = 123))
handler.sendToRadio(toRadio)
verify { radioInterfaceService.sendToRadio(any()) }
}
@Test
@ -93,6 +96,8 @@ class PacketHandlerImplTest {
handler.sendToRadio(packet)
testScheduler.runCurrent()
verify { radioInterfaceService.sendToRadio(any()) }
}
@Test