mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: implement XModem file transfers and enhance BLE connection robustness (#4959)
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run
Some checks are pending
Dependency Submission / dependency-submission (push) Waiting to run
Main CI (Verify & Build) / validate-and-build (push) Waiting to run
Main Push Changelog / Generate main push changelog (push) Waiting to run
This commit is contained in:
parent
ae4465d7c8
commit
c75c9b34d6
43 changed files with 1100 additions and 120 deletions
|
|
@ -16,7 +16,12 @@
|
|||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.repository.FromRadioPacketHandler
|
||||
import org.meshtastic.core.repository.MeshRouter
|
||||
import org.meshtastic.core.repository.MqttManager
|
||||
|
|
@ -26,7 +31,13 @@ import org.meshtastic.core.repository.PacketHandler
|
|||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.client_notification
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.duplicated_public_key_title
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.key_verification_final_title
|
||||
import org.meshtastic.core.resources.key_verification_request_title
|
||||
import org.meshtastic.core.resources.key_verification_title
|
||||
import org.meshtastic.core.resources.low_entropy_key_title
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.FromRadio
|
||||
|
||||
/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
|
||||
|
|
@ -38,6 +49,11 @@ class FromRadioPacketHandlerImpl(
|
|||
private val packetHandler: PacketHandler,
|
||||
private val notificationManager: NotificationManager,
|
||||
) : FromRadioPacketHandler {
|
||||
|
||||
// Application-scoped coroutine context for suspend work (e.g. getStringSuspend).
|
||||
// This @Single lives for the entire app lifetime, so the SupervisorJob is never cancelled.
|
||||
private val scope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
override fun handleFromRadio(proto: FromRadio) {
|
||||
val myInfo = proto.my_info
|
||||
|
|
@ -50,9 +66,15 @@ class FromRadioPacketHandlerImpl(
|
|||
val moduleConfig = proto.moduleConfig
|
||||
val channel = proto.channel
|
||||
val clientNotification = proto.clientNotification
|
||||
val deviceUIConfig = proto.deviceuiConfig
|
||||
val fileInfo = proto.fileInfo
|
||||
val xmodemPacket = proto.xmodemPacket
|
||||
|
||||
when {
|
||||
myInfo != null -> router.value.configFlowManager.handleMyInfo(myInfo)
|
||||
// deviceuiConfig arrives immediately after my_info (STATE_SEND_UIDATA). It carries
|
||||
// the device's display, theme, node-filter, and other UI preferences.
|
||||
deviceUIConfig != null -> router.value.configHandler.handleDeviceUIConfig(deviceUIConfig)
|
||||
metadata != null -> router.value.configFlowManager.handleLocalMetadata(metadata)
|
||||
nodeInfo != null -> {
|
||||
router.value.configFlowManager.handleNodeInfo(nodeInfo)
|
||||
|
|
@ -64,17 +86,48 @@ class FromRadioPacketHandlerImpl(
|
|||
config != null -> router.value.configHandler.handleDeviceConfig(config)
|
||||
moduleConfig != null -> router.value.configHandler.handleModuleConfig(moduleConfig)
|
||||
channel != null -> router.value.configHandler.handleChannel(channel)
|
||||
clientNotification != null -> {
|
||||
serviceRepository.setClientNotification(clientNotification)
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getString(Res.string.client_notification),
|
||||
message = clientNotification.message,
|
||||
category = Notification.Category.Alert,
|
||||
),
|
||||
)
|
||||
packetHandler.removeResponse(0, complete = false)
|
||||
}
|
||||
fileInfo != null -> router.value.configFlowManager.handleFileInfo(fileInfo)
|
||||
xmodemPacket != null -> router.value.xmodemManager.handleIncomingXModem(xmodemPacket)
|
||||
clientNotification != null -> handleClientNotification(clientNotification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClientNotification(cn: ClientNotification) {
|
||||
serviceRepository.setClientNotification(cn)
|
||||
|
||||
scope.handledLaunch {
|
||||
val inform = cn.key_verification_number_inform
|
||||
val request = cn.key_verification_number_request
|
||||
val verificationFinal = cn.key_verification_final
|
||||
val (title, type) =
|
||||
when {
|
||||
inform != null -> {
|
||||
Logger.i { "Key verification inform from ${inform.remote_longname}" }
|
||||
Pair(getStringSuspend(Res.string.key_verification_title), Notification.Type.Info)
|
||||
}
|
||||
request != null -> {
|
||||
Logger.i { "Key verification request from ${request.remote_longname}" }
|
||||
Pair(getStringSuspend(Res.string.key_verification_request_title), Notification.Type.Info)
|
||||
}
|
||||
verificationFinal != null -> {
|
||||
Logger.i { "Key verification final from ${verificationFinal.remote_longname}" }
|
||||
Pair(getStringSuspend(Res.string.key_verification_final_title), Notification.Type.Info)
|
||||
}
|
||||
cn.duplicated_public_key != null -> {
|
||||
Logger.w { "Duplicated public key notification received" }
|
||||
Pair(getStringSuspend(Res.string.duplicated_public_key_title), Notification.Type.Warning)
|
||||
}
|
||||
cn.low_entropy_key != null -> {
|
||||
Logger.w { "Low entropy key notification received" }
|
||||
Pair(getStringSuspend(Res.string.low_entropy_key_title), Notification.Type.Warning)
|
||||
}
|
||||
else -> Pair(getStringSuspend(Res.string.client_notification), Notification.Type.Info)
|
||||
}
|
||||
|
||||
notificationManager.dispatch(
|
||||
Notification(title = title, type = type, message = cn.message, category = Notification.Category.Alert),
|
||||
)
|
||||
packetHandler.removeResponse(0, complete = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
|
|||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.FileInfo
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
import org.meshtastic.proto.NodeInfo
|
||||
|
|
@ -152,6 +153,8 @@ class MeshConfigFlowManagerImpl(
|
|||
radioConfigRepository.clearChannelSet()
|
||||
radioConfigRepository.clearLocalConfig()
|
||||
radioConfigRepository.clearLocalModuleConfig()
|
||||
radioConfigRepository.clearDeviceUIConfig()
|
||||
radioConfigRepository.clearFileManifest()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +168,11 @@ class MeshConfigFlowManagerImpl(
|
|||
newNodes.add(info)
|
||||
}
|
||||
|
||||
override fun handleFileInfo(info: FileInfo) {
|
||||
Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" }
|
||||
scope.handledLaunch { radioConfigRepository.addFileInfo(info) }
|
||||
}
|
||||
|
||||
override fun triggerWantConfig() {
|
||||
connectionManager.value.startConfigOnly()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
|
|||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.DeviceUIConfig
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
|
|
@ -82,4 +83,8 @@ class MeshConfigHandlerImpl(
|
|||
serviceRepository.setConnectionProgress("Channels (${index + 1})")
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleDeviceUIConfig(config: DeviceUIConfig) {
|
||||
scope.handledLaunch { radioConfigRepository.setDeviceUIConfig(config) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,12 +184,15 @@ class MeshConnectionManagerImpl(
|
|||
handshakeTimeout = scope.handledLaunch {
|
||||
delay(HANDSHAKE_TIMEOUT)
|
||||
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
|
||||
Logger.w { "Handshake stall detected! Retrying Stage $stage." }
|
||||
// Attempt one retry. Note: the firmware silently drops identical consecutive
|
||||
// writes (per-connection dedup). If the first want_config_id was received and
|
||||
// the stall is on our side, the retry will be dropped and the reconnect below
|
||||
// will trigger instead — which is the right recovery in that case.
|
||||
Logger.w { "Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled." }
|
||||
action()
|
||||
// Recursive timeout for one more try
|
||||
delay(HANDSHAKE_TIMEOUT)
|
||||
delay(HANDSHAKE_RETRY_TIMEOUT)
|
||||
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
|
||||
Logger.e { "Handshake still stalled after retry. Resetting connection." }
|
||||
Logger.e { "Handshake still stalled after retry. Forcing reconnect." }
|
||||
onConnectionChanged(ConnectionState.Disconnected)
|
||||
}
|
||||
}
|
||||
|
|
@ -267,16 +270,24 @@ class MeshConnectionManagerImpl(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
val myNodeNum = nodeManager.myNodeNum ?: 0
|
||||
// Set time
|
||||
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
|
||||
}
|
||||
|
||||
override fun onNodeDbReady() {
|
||||
handshakeTimeout?.cancel()
|
||||
handshakeTimeout = null
|
||||
|
||||
val myNodeNum = nodeManager.myNodeNum ?: 0
|
||||
|
||||
// Set device time now that the full node picture is ready. Sending this during Stage 1
|
||||
// (onRadioConfigLoaded) introduced GATT write contention with the Stage 2 node-info burst.
|
||||
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
|
||||
|
||||
// Proactively seed the session passkey. The firmware embeds session_passkey in every
|
||||
// admin *response* (wantResponse=true), but set_time_only has no response. A get_owner
|
||||
// request is the lightest way to trigger a response and populate the passkey cache so
|
||||
// that subsequent write operations don't fail with ADMIN_BAD_SESSION_KEY.
|
||||
commandSender.sendAdmin(myNodeNum, wantResponse = true) { AdminMessage(get_owner_request = true) }
|
||||
|
||||
// Start MQTT if enabled
|
||||
scope.handledLaunch {
|
||||
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
|
||||
|
|
@ -289,7 +300,6 @@ class MeshConnectionManagerImpl(
|
|||
|
||||
reportConnection()
|
||||
|
||||
val myNodeNum = nodeManager.myNodeNum ?: 0
|
||||
// Request history
|
||||
scope.handledLaunch {
|
||||
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
|
||||
|
|
@ -329,6 +339,11 @@ class MeshConnectionManagerImpl(
|
|||
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
|
||||
private val HANDSHAKE_TIMEOUT = 30.seconds
|
||||
|
||||
// Shorter window for the retry attempt: if the device genuinely didn't receive the
|
||||
// first want_config_id the retry completes within a few seconds. Waiting another 30s
|
||||
// before reconnecting just delays recovery unnecessarily.
|
||||
private val HANDSHAKE_RETRY_TIMEOUT = 15.seconds
|
||||
|
||||
private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
|
||||
private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"
|
||||
private const val EVENT_NUM_NODES = "num_nodes"
|
||||
|
|
|
|||
|
|
@ -258,7 +258,10 @@ class MeshDataHandlerImpl(
|
|||
private fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val u = AdminMessage.ADAPTER.decode(payload)
|
||||
u.session_passkey.let { commandSender.setSessionPasskey(it) }
|
||||
// Guard against clearing a valid passkey: firmware always embeds the key in every
|
||||
// admin response, but a missing (default-empty) field must not reset the stored value.
|
||||
val incomingPasskey = u.session_passkey
|
||||
if (incomingPasskey.size > 0) commandSender.setSessionPasskey(incomingPasskey)
|
||||
|
||||
val fromNum = packet.from
|
||||
u.get_module_config_response?.let {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import org.meshtastic.core.repository.MeshRouter
|
|||
import org.meshtastic.core.repository.MqttManager
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.core.repository.XModemManager
|
||||
|
||||
/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */
|
||||
@Suppress("LongParameterList")
|
||||
|
|
@ -38,6 +39,7 @@ class MeshRouterImpl(
|
|||
private val configFlowManagerLazy: Lazy<MeshConfigFlowManager>,
|
||||
private val mqttManagerLazy: Lazy<MqttManager>,
|
||||
private val actionHandlerLazy: Lazy<MeshActionHandler>,
|
||||
private val xmodemManagerLazy: Lazy<XModemManager>,
|
||||
) : MeshRouter {
|
||||
override val dataHandler: MeshDataHandler
|
||||
get() = dataHandlerLazy.value
|
||||
|
|
@ -60,6 +62,9 @@ class MeshRouterImpl(
|
|||
override val actionHandler: MeshActionHandler
|
||||
get() = actionHandlerLazy.value
|
||||
|
||||
override val xmodemManager: XModemManager
|
||||
get() = xmodemManagerLazy.value
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
dataHandler.start(scope)
|
||||
configHandler.start(scope)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.XModemFile
|
||||
import org.meshtastic.core.repository.XModemManager
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import org.meshtastic.proto.XModem
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* XModem-CRC receiver state machine.
|
||||
*
|
||||
* Protocol summary (device = sender, Android = receiver):
|
||||
* - SOH / STX → data block with seq, CRC-CCITT-16, payload; reply ACK or NAK
|
||||
* - EOT → end of transfer; reply ACK, emit assembled file
|
||||
* - CAN → sender cancelled; reset state
|
||||
*
|
||||
* CRC algorithm: CRC-CCITT (poly 0x1021, init 0x0000), same as the Meshtastic firmware.
|
||||
*/
|
||||
@Single
|
||||
class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManager {
|
||||
|
||||
private val _fileTransferFlow =
|
||||
MutableSharedFlow<XModemFile>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 4,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val fileTransferFlow = _fileTransferFlow.asSharedFlow()
|
||||
|
||||
// --- mutable state ---
|
||||
// Thread-safety contract: [handleIncomingXModem] is called sequentially from
|
||||
// [FromRadioPacketHandlerImpl.handleFromRadio] on a single IO coroutine. The
|
||||
// [setTransferName] and [cancel] calls originate from UI/ViewModel coroutines
|
||||
// and are guarded by @Volatile for visibility. Concurrent block processing is
|
||||
// not possible because the firmware sends one XModem packet at a time and waits
|
||||
// for ACK/NAK before sending the next.
|
||||
@Volatile private var transferName = ""
|
||||
|
||||
@Volatile private var expectedSeq = INITIAL_SEQ
|
||||
private val blocks = mutableListOf<ByteArray>()
|
||||
|
||||
override fun setTransferName(name: String) {
|
||||
transferName = name
|
||||
}
|
||||
|
||||
override fun handleIncomingXModem(packet: XModem) {
|
||||
when (packet.control) {
|
||||
XModem.Control.SOH,
|
||||
XModem.Control.STX,
|
||||
-> handleDataBlock(packet)
|
||||
XModem.Control.EOT -> handleEot()
|
||||
XModem.Control.CAN -> {
|
||||
Logger.w { "XModem: CAN received — transfer cancelled" }
|
||||
reset()
|
||||
}
|
||||
else -> Logger.w { "XModem: unexpected control byte ${packet.control}, ignoring" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDataBlock(packet: XModem) {
|
||||
val seq = packet.seq and 0xFF
|
||||
val data = packet.buffer.toByteArray()
|
||||
|
||||
if (!validateCrc(data, packet.crc16)) {
|
||||
Logger.w { "XModem: CRC error on block $seq (expected seq=$expectedSeq) — NAK" }
|
||||
sendControl(XModem.Control.NAK)
|
||||
return
|
||||
}
|
||||
|
||||
when (seq) {
|
||||
expectedSeq -> {
|
||||
blocks.add(data)
|
||||
expectedSeq = (expectedSeq % MAX_SEQ) + 1
|
||||
Logger.d { "XModem: block $seq OK, total=${blocks.size} blocks" }
|
||||
sendControl(XModem.Control.ACK)
|
||||
}
|
||||
// Duplicate: sender did not receive our previous ACK; re-ACK without buffering again.
|
||||
(expectedSeq - 1 + MAX_SEQ_PLUS_ONE) % MAX_SEQ_PLUS_ONE -> {
|
||||
Logger.d { "XModem: duplicate block $seq — re-ACK" }
|
||||
sendControl(XModem.Control.ACK)
|
||||
}
|
||||
else -> {
|
||||
Logger.w { "XModem: unexpected seq $seq (expected $expectedSeq) — NAK" }
|
||||
sendControl(XModem.Control.NAK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEot() {
|
||||
Logger.i { "XModem: EOT — transfer complete (${blocks.size} blocks, name='$transferName')" }
|
||||
sendControl(XModem.Control.ACK)
|
||||
|
||||
val raw = blocks.fold(ByteArray(0)) { acc, block -> acc + block }
|
||||
// Strip trailing CTRL-Z padding that XModem senders add to fill the last block.
|
||||
var end = raw.size
|
||||
while (end > 0 && raw[end - 1] == CTRLZ) end--
|
||||
val trimmed = if (end == raw.size) raw else raw.copyOf(end)
|
||||
_fileTransferFlow.tryEmit(XModemFile(name = transferName, data = trimmed))
|
||||
reset()
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
Logger.i { "XModem: cancelling transfer" }
|
||||
sendControl(XModem.Control.CAN)
|
||||
reset()
|
||||
}
|
||||
|
||||
private fun sendControl(control: XModem.Control) {
|
||||
packetHandler.sendToRadio(ToRadio(xmodemPacket = XModem(control = control)))
|
||||
}
|
||||
|
||||
private fun reset() {
|
||||
expectedSeq = INITIAL_SEQ
|
||||
blocks.clear()
|
||||
transferName = ""
|
||||
}
|
||||
|
||||
// CRC-CCITT: polynomial 0x1021, initial value 0x0000 (XModem variant)
|
||||
private fun validateCrc(data: ByteArray, expectedCrc: Int): Boolean =
|
||||
calculateCrc16(data) == (expectedCrc and 0xFFFF)
|
||||
|
||||
private fun calculateCrc16(data: ByteArray): Int {
|
||||
var crc = 0
|
||||
for (byte in data) {
|
||||
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
|
||||
repeat(BITS_PER_BYTE) { crc = if (crc and 0x8000 != 0) (crc shl 1) xor CRC_POLY else crc shl 1 }
|
||||
}
|
||||
return crc and 0xFFFF
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val INITIAL_SEQ = 1
|
||||
private const val MAX_SEQ = 255
|
||||
private const val MAX_SEQ_PLUS_ONE = 256
|
||||
private const val CTRLZ = 0x1A.toByte()
|
||||
private const val CRC_POLY = 0x1021
|
||||
private const val BITS_PER_BYTE = 8
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
package org.meshtastic.core.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.datastore.ChannelSetDataSource
|
||||
|
|
@ -30,6 +32,8 @@ import org.meshtastic.proto.ChannelSet
|
|||
import org.meshtastic.proto.ChannelSettings
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.DeviceProfile
|
||||
import org.meshtastic.proto.DeviceUIConfig
|
||||
import org.meshtastic.proto.FileInfo
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
|
|
@ -103,6 +107,30 @@ open class RadioConfigRepositoryImpl(
|
|||
moduleConfigDataSource.setLocalModuleConfig(config)
|
||||
}
|
||||
|
||||
// DeviceUIConfig is session-scoped data received fresh in every handshake — no persistence needed.
|
||||
private val _deviceUIConfigFlow = MutableStateFlow<DeviceUIConfig?>(null)
|
||||
override val deviceUIConfigFlow: Flow<DeviceUIConfig?> = _deviceUIConfigFlow.asStateFlow()
|
||||
|
||||
override suspend fun setDeviceUIConfig(config: DeviceUIConfig) {
|
||||
_deviceUIConfigFlow.value = config
|
||||
}
|
||||
|
||||
override suspend fun clearDeviceUIConfig() {
|
||||
_deviceUIConfigFlow.value = null
|
||||
}
|
||||
|
||||
// FileInfo manifest is session-scoped: accumulated during STATE_SEND_FILEMANIFEST, cleared on each new handshake.
|
||||
private val _fileManifestFlow = MutableStateFlow<List<FileInfo>>(emptyList())
|
||||
override val fileManifestFlow: Flow<List<FileInfo>> = _fileManifestFlow.asStateFlow()
|
||||
|
||||
override suspend fun addFileInfo(info: FileInfo) {
|
||||
_fileManifestFlow.value += info
|
||||
}
|
||||
|
||||
override suspend fun clearFileManifest() {
|
||||
_fileManifestFlow.value = emptyList()
|
||||
}
|
||||
|
||||
/** Flow representing the combined [DeviceProfile] protobuf. */
|
||||
override val deviceProfileFlow: Flow<DeviceProfile> =
|
||||
combine(nodeDB.ourNodeInfo, channelSetFlow, localConfigFlow, moduleConfigFlow) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import app.cash.turbine.test
|
||||
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.verify.VerifyMode.Companion.exactly
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import org.meshtastic.proto.XModem
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class XModemManagerImplTest {
|
||||
private lateinit var packetHandler: PacketHandler
|
||||
private lateinit var xmodemManager: XModemManagerImpl
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
packetHandler = mock<PacketHandler> { every { sendToRadio(any<ToRadio>()) } returns Unit }
|
||||
xmodemManager = XModemManagerImpl(packetHandler)
|
||||
}
|
||||
|
||||
private fun calculateExpectedCrc(data: ByteArray): Int {
|
||||
var crc = 0
|
||||
for (byte in data) {
|
||||
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
|
||||
repeat(8) { crc = if (crc and 0x8000 != 0) (crc shl 1) xor 0x1021 else crc shl 1 }
|
||||
}
|
||||
return crc and 0xFFFF
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful transfer emits file and ACKs blocks`() = runTest {
|
||||
val payload1 = "Hello, ".encodeToByteArray()
|
||||
val payload2 = "Meshtastic!".encodeToByteArray()
|
||||
|
||||
xmodemManager.setTransferName("test.txt")
|
||||
|
||||
xmodemManager.fileTransferFlow.test {
|
||||
// Send Block 1
|
||||
xmodemManager.handleIncomingXModem(
|
||||
XModem(
|
||||
control = XModem.Control.SOH,
|
||||
seq = 1,
|
||||
crc16 = calculateExpectedCrc(payload1),
|
||||
buffer = payload1.toByteString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Send Block 2
|
||||
xmodemManager.handleIncomingXModem(
|
||||
XModem(
|
||||
control = XModem.Control.SOH,
|
||||
seq = 2,
|
||||
crc16 = calculateExpectedCrc(payload2),
|
||||
buffer = payload2.toByteString(),
|
||||
),
|
||||
)
|
||||
|
||||
// EOT
|
||||
xmodemManager.handleIncomingXModem(XModem(control = XModem.Control.EOT))
|
||||
|
||||
val file = awaitItem()
|
||||
assertEquals("test.txt", file.name)
|
||||
assertEquals("Hello, Meshtastic!", file.data.decodeToString())
|
||||
|
||||
verify(exactly(3)) { packetHandler.sendToRadio(any<ToRadio>()) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores bad CRC and replies NAK`() = runTest {
|
||||
val payload1 = "Bad CRC payload".encodeToByteArray()
|
||||
|
||||
xmodemManager.handleIncomingXModem(
|
||||
XModem(
|
||||
control = XModem.Control.SOH,
|
||||
seq = 1,
|
||||
crc16 = 0xBAD, // intentionally bad
|
||||
buffer = payload1.toByteString(),
|
||||
),
|
||||
)
|
||||
|
||||
verify(exactly(1)) { packetHandler.sendToRadio(any<ToRadio>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles CAN and resets state`() = runTest {
|
||||
xmodemManager.setTransferName("bad.txt")
|
||||
|
||||
xmodemManager.handleIncomingXModem(XModem(control = XModem.Control.CAN))
|
||||
|
||||
// No control sent back for CAN by the device, just resets.
|
||||
// If we cancel locally, we send CAN. Wait, the test is for receiving CAN.
|
||||
// So nothing should be sent, but state should reset.
|
||||
// Let's verify no ACK/NAK sent when receiving CAN.
|
||||
verify(exactly(0)) { packetHandler.sendToRadio(any<ToRadio>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removes CTRLZ padding from end of file`() = runTest {
|
||||
val payload = byteArrayOf(0x48, 0x69, 0x1A, 0x1A) // "Hi" + CTRL-Z padding
|
||||
xmodemManager.setTransferName("padded.txt")
|
||||
|
||||
xmodemManager.fileTransferFlow.test {
|
||||
xmodemManager.handleIncomingXModem(
|
||||
XModem(
|
||||
control = XModem.Control.SOH,
|
||||
seq = 1,
|
||||
crc16 = calculateExpectedCrc(payload),
|
||||
buffer = payload.toByteString(),
|
||||
),
|
||||
)
|
||||
xmodemManager.handleIncomingXModem(XModem(control = XModem.Control.EOT))
|
||||
|
||||
val file = awaitItem()
|
||||
val expected = byteArrayOf(0x48, 0x69) // "Hi"
|
||||
assertTrue(expected.contentEquals(file.data))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue