refactor: eliminate ProcessRadioResponseUseCase and packet-ID correlation

Replace the manual packet-ID tracking and meshPacketFlow subscription in
RadioConfigViewModel with direct typed returns from the SDK via
RadioConfigUseCase. The ViewModel now awaits typed results (User, Config,
ModuleConfig, channels, etc.) from suspend calls and maps AdminException
to UI error states.

Key changes:
- Delete ProcessRadioResponseUseCase (130 lines of manual proto decode)
- Remove requestIds state, registerRequestId, processPacketResponse,
  sendAdminRequest, and meshPacketFlow subscription from ViewModel
- Rewrite setResponseStateLoading to use direct coroutine calls
- Admin actions (reboot/shutdown/etc.) fire directly without session key
  preflight (SDK handles retryOnSessionExpiry transparently)
- All setters (setConfig, setModuleConfig, setOwner, updateChannels)
  no longer return/track packetIds
- Remove messageSender dependency from NodeManagementActions
- Update InstallProfileUseCase to use editSettings {} receiver pattern
- Update all callers: CleanNodeDatabaseUseCase, Esp32OtaUpdateHandler,
  NodeManagementActions
- Rewrite RadioConfigViewModelTest for new direct-await semantics
- Update RadioConfigUseCaseTest and InstallProfileUseCaseTest

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-06 10:50:02 -05:00
parent 6e5b159014
commit 140e062eee
19 changed files with 575 additions and 1017 deletions

View File

@@ -20,10 +20,12 @@ import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.Single
import org.meshtastic.core.model.AdminException
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DataRequester
import org.meshtastic.core.model.DeviceAdmin
import org.meshtastic.core.model.DeviceAdminEdit
import org.meshtastic.core.model.DeviceControl
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.MessageSender
@@ -37,6 +39,7 @@ import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceConnectionStatus
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.SharedContact
@@ -162,24 +165,24 @@ class SdkRadioController(
// ── Remote admin (config/owner/channel) ─────────────────────────────────
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {
override suspend fun setOwner(destNum: Int, user: User) {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).setOwner(user)
c.admin.forNode(NodeId(destNum)).setOwner(user).unwrap()
}
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
override suspend fun setConfig(destNum: Int, config: Config) {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).setConfig(config)
c.admin.forNode(NodeId(destNum)).setConfig(config).unwrap()
}
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig) {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).setModuleConfig(config)
c.admin.forNode(NodeId(destNum)).setModuleConfig(config).unwrap()
}
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
override suspend fun setRemoteChannel(destNum: Int, channel: Channel) {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).setChannel(channel)
c.admin.forNode(NodeId(destNum)).setChannel(channel).unwrap()
}
override suspend fun setFixedPosition(destNum: Int, position: Position) {
@@ -205,78 +208,85 @@ class SdkRadioController(
// ── Remote admin (getters) ──────────────────────────────────────────────
override suspend fun getOwner(destNum: Int, packetId: Int) {
override suspend fun getOwner(destNum: Int): User {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).getOwner()
return c.admin.forNode(NodeId(destNum)).getOwner().unwrap()
}
override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {
override suspend fun getConfig(destNum: Int, configType: Int): Config {
val c = requireClient()
val type = AdminMessage.ConfigType.fromValue(configType) ?: return
c.admin.forNode(NodeId(destNum)).getConfig(type)
val type = AdminMessage.ConfigType.fromValue(configType)
?: throw IllegalArgumentException("Unknown config type: $configType")
return c.admin.forNode(NodeId(destNum)).getConfig(type).unwrap()
}
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): ModuleConfig {
val c = requireClient()
val type = AdminMessage.ModuleConfigType.fromValue(moduleConfigType) ?: return
c.admin.forNode(NodeId(destNum)).getModuleConfig(type)
val type = AdminMessage.ModuleConfigType.fromValue(moduleConfigType)
?: throw IllegalArgumentException("Unknown module config type: $moduleConfigType")
return c.admin.forNode(NodeId(destNum)).getModuleConfig(type).unwrap()
}
override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {
override suspend fun getChannel(destNum: Int, index: Int): Channel {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).getChannel(ChannelIndex(index))
return c.admin.forNode(NodeId(destNum)).getChannel(ChannelIndex(index)).unwrap()
}
override suspend fun getRingtone(destNum: Int, packetId: Int) {
override suspend fun listChannels(destNum: Int): List<Channel> {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).getRingtone()
return c.admin.forNode(NodeId(destNum)).listChannels().unwrap()
}
override suspend fun getCannedMessages(destNum: Int, packetId: Int) {
override suspend fun getRingtone(destNum: Int): String {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).getCannedMessages()
return c.admin.forNode(NodeId(destNum)).getRingtone().unwrap()
}
override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {
override suspend fun getCannedMessages(destNum: Int): String {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).getDeviceConnectionStatus()
return c.admin.forNode(NodeId(destNum)).getCannedMessages().unwrap()
}
override suspend fun getDeviceConnectionStatus(destNum: Int): DeviceConnectionStatus {
val c = requireClient()
return c.admin.forNode(NodeId(destNum)).getDeviceConnectionStatus().unwrap()
}
// ── Lifecycle commands ───────────────────────────────────────────────────
override suspend fun reboot(destNum: Int, packetId: Int) {
override suspend fun reboot(destNum: Int) {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).reboot()
c.admin.forNode(NodeId(destNum)).reboot().unwrap()
}
override suspend fun rebootToDfu(nodeNum: Int) {
val c = requireClient()
c.admin.forNode(NodeId(nodeNum)).enterDfuMode()
c.admin.forNode(NodeId(nodeNum)).enterDfuMode().unwrap()
}
override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
override suspend fun requestRebootOta(destNum: Int, mode: Int, hash: ByteArray?) {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).rebootOta()
c.admin.forNode(NodeId(destNum)).rebootOta().unwrap()
}
override suspend fun shutdown(destNum: Int, packetId: Int) {
override suspend fun shutdown(destNum: Int) {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).shutdown()
c.admin.forNode(NodeId(destNum)).shutdown().unwrap()
}
override suspend fun factoryReset(destNum: Int, packetId: Int) {
override suspend fun factoryReset(destNum: Int) {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).factoryReset()
c.admin.forNode(NodeId(destNum)).factoryReset().unwrap()
}
override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {
override suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean) {
val c = requireClient()
c.admin.forNode(NodeId(destNum)).nodeDbReset()
c.admin.forNode(NodeId(destNum)).nodeDbReset().unwrap()
}
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
override suspend fun removeByNodenum(nodeNum: Int) {
val c = requireClient()
c.admin.removeNode(NodeId(nodeNum))
c.admin.removeNode(NodeId(nodeNum)).unwrap()
}
// ── Data requests ───────────────────────────────────────────────────────
@@ -349,28 +359,19 @@ class SdkRadioController(
// ── Edit settings (transactional) ───────────────────────────────────────
override suspend fun beginEditSettings(destNum: Int) {
override suspend fun editSettings(destNum: Int, block: suspend DeviceAdminEdit.() -> Unit) {
val c = requireClient()
val target = resolveTarget(c, destNum)
val payload = AdminMessage.ADAPTER.encode(AdminMessage(begin_edit_settings = true))
c.send(
portnum = PortNum.ADMIN_APP,
payload = payload,
to = target,
wantAck = false,
)
}
override suspend fun commitEditSettings(destNum: Int) {
val c = requireClient()
val target = resolveTarget(c, destNum)
val payload = AdminMessage.ADAPTER.encode(AdminMessage(commit_edit_settings = true))
c.send(
portnum = PortNum.ADMIN_APP,
payload = payload,
to = target,
wantAck = true,
)
val admin = c.admin.forNode(NodeId(destNum))
admin.editSettings {
val edit = this
val bridge = object : DeviceAdminEdit {
override suspend fun setConfig(config: Config) { edit.setConfig(config) }
override suspend fun setModuleConfig(config: ModuleConfig) { edit.setModuleConfig(config) }
override suspend fun setOwner(user: User) { edit.setOwner(user) }
override suspend fun setChannel(channel: Channel) { edit.setChannel(channel) }
}
block(bridge)
}.unwrap()
}
// ── Utility ─────────────────────────────────────────────────────────────
@@ -391,8 +392,13 @@ class SdkRadioController(
// ── Private helpers ─────────────────────────────────────────────────────
private fun resolveTarget(c: RadioClient, destNum: Int): NodeId {
if (destNum == 0) return NodeId(c.ownNode.value?.num ?: 0)
return NodeId(destNum)
/** Unwrap an [AdminResult], returning the value on success or throwing [AdminException] on failure. */
private fun <T> AdminResult<T>.unwrap(): T = when (this) {
is AdminResult.Success -> value
is AdminResult.Timeout -> throw AdminException.Timeout()
is AdminResult.Unauthorized -> throw AdminException.Unauthorized()
is AdminResult.NodeUnreachable -> throw AdminException.NodeUnreachable()
is AdminResult.SessionKeyExpired -> throw AdminException.SessionKeyExpired()
is AdminResult.Failed -> throw AdminException.RoutingError(routingError.name)
}
}

View File

@@ -23,8 +23,8 @@ import org.meshtastic.core.repository.NodeRepository
/**
* Use case for performing administrative and destructive actions on mesh nodes.
*
* This component provides methods for rebooting, shutting down, or resetting nodes within the mesh. It also handles
* local database synchronization when these actions are performed on the locally connected device.
* Methods suspend until the device acknowledges. On failure, they propagate
* [org.meshtastic.core.model.AdminException].
*/
@Single
open class AdminActionsUseCase
@@ -32,66 +32,40 @@ constructor(
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
) {
/**
* Reboots the radio.
*
* @param destNum The node number to reboot.
* @return The packet ID of the request.
*/
open suspend fun reboot(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.reboot(destNum, packetId)
return packetId
/** Reboot the target node. */
open suspend fun reboot(destNum: Int) {
radioController.reboot(destNum)
}
/** Shut down the target node. */
open suspend fun shutdown(destNum: Int) {
radioController.shutdown(destNum)
}
/**
* Shuts down the radio.
*
* @param destNum The node number to shut down.
* @return The packet ID of the request.
*/
open suspend fun shutdown(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.shutdown(destNum, packetId)
return packetId
}
/**
* Factory resets the radio.
* Factory reset the target node.
*
* @param destNum The node number to reset.
* @param isLocal Whether the reset is being performed on the locally connected node.
* @return The packet ID of the request.
*/
open suspend fun factoryReset(destNum: Int, isLocal: Boolean): Int {
val packetId = radioController.getPacketId()
radioController.factoryReset(destNum, packetId)
open suspend fun factoryReset(destNum: Int, isLocal: Boolean) {
radioController.factoryReset(destNum)
if (isLocal) {
// If it's the local node, we should also clear the phone's node database as it will be out of sync.
nodeRepository.clearNodeDB()
}
return packetId
}
/**
* Resets the NodeDB on the radio.
* Reset the NodeDB on the target node.
*
* @param destNum The node number to reset.
* @param preserveFavorites Whether to keep favorite nodes in the database.
* @param isLocal Whether the reset is being performed on the locally connected node.
* @return The packet ID of the request.
*/
open suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean, isLocal: Boolean): Int {
val packetId = radioController.getPacketId()
radioController.nodedbReset(destNum, packetId, preserveFavorites)
open suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean, isLocal: Boolean) {
radioController.nodedbReset(destNum, preserveFavorites)
if (isLocal) {
// If it's the local node, we should also clear the phone's node database.
nodeRepository.clearNodeDB(preserveFavorites)
}
return packetId
}
}

View File

@@ -58,8 +58,7 @@ constructor(
nodeRepository.deleteNodes(nodeNums)
for (nodeNum in nodeNums) {
val packetId = radioController.getPacketId()
radioController.removeByNodenum(packetId, nodeNum)
radioController.removeByNodenum(nodeNum)
}
}
}

View File

@@ -21,8 +21,6 @@ import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
@@ -32,50 +30,51 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC
/**
* Installs the provided [DeviceProfile] onto the radio at [destNum].
*
* Uses [RadioController.editSettings] to batch all writes inside a transactional
* `begin_edit_settings` / `commit_edit_settings` envelope.
*
* @param destNum The destination node number.
* @param profile The device profile to install.
* @param currentUser The current user configuration of the destination node (to preserve names if not in profile).
*/
open suspend operator fun invoke(destNum: Int, profile: DeviceProfile, currentUser: User?) {
radioController.beginEditSettings(destNum)
radioController.editSettings(destNum) {
installOwner(profile, currentUser)
installConfig(profile.config)
installModuleConfig(profile.module_config)
}
installOwner(destNum, profile, currentUser)
installConfig(destNum, profile.config)
// Fixed position is set outside the edit block (uses a separate admin RPC)
installFixedPosition(destNum, profile.fixed_position)
installModuleConfig(destNum, profile.module_config)
radioController.commitEditSettings(destNum)
}
private suspend fun installOwner(destNum: Int, profile: DeviceProfile, currentUser: User?) {
private suspend fun org.meshtastic.core.model.DeviceAdminEdit.installOwner(
profile: DeviceProfile,
currentUser: User?,
) {
if (profile.long_name != null || profile.short_name != null) {
currentUser?.let {
val user =
it.copy(
long_name = profile.long_name ?: it.long_name,
short_name = profile.short_name ?: it.short_name,
)
radioController.setOwner(destNum, user, radioController.getPacketId())
val user = it.copy(
long_name = profile.long_name ?: it.long_name,
short_name = profile.short_name ?: it.short_name,
)
setOwner(user)
}
}
}
private suspend fun installConfig(destNum: Int, config: LocalConfig?) {
private suspend fun org.meshtastic.core.model.DeviceAdminEdit.installConfig(
config: org.meshtastic.proto.LocalConfig?,
) {
config?.let { lc ->
lc.device?.let { radioController.setConfig(destNum, Config(device = it), radioController.getPacketId()) }
lc.position?.let {
radioController.setConfig(destNum, Config(position = it), radioController.getPacketId())
}
lc.power?.let { radioController.setConfig(destNum, Config(power = it), radioController.getPacketId()) }
lc.network?.let { radioController.setConfig(destNum, Config(network = it), radioController.getPacketId()) }
lc.display?.let { radioController.setConfig(destNum, Config(display = it), radioController.getPacketId()) }
lc.lora?.let { radioController.setConfig(destNum, Config(lora = it), radioController.getPacketId()) }
lc.bluetooth?.let {
radioController.setConfig(destNum, Config(bluetooth = it), radioController.getPacketId())
}
lc.security?.let {
radioController.setConfig(destNum, Config(security = it), radioController.getPacketId())
}
lc.device?.let { setConfig(Config(device = it)) }
lc.position?.let { setConfig(Config(position = it)) }
lc.power?.let { setConfig(Config(power = it)) }
lc.network?.let { setConfig(Config(network = it)) }
lc.display?.let { setConfig(Config(display = it)) }
lc.lora?.let { setConfig(Config(lora = it)) }
lc.bluetooth?.let { setConfig(Config(bluetooth = it)) }
lc.security?.let { setConfig(Config(security = it)) }
}
}
@@ -85,70 +84,26 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC
}
}
private suspend fun installModuleConfig(destNum: Int, moduleConfig: LocalModuleConfig?) {
private suspend fun org.meshtastic.core.model.DeviceAdminEdit.installModuleConfig(
moduleConfig: org.meshtastic.proto.LocalModuleConfig?,
) {
moduleConfig?.let { lmc ->
installModuleConfigPart1(destNum, lmc)
installModuleConfigPart2(destNum, lmc)
lmc.mqtt?.let { setModuleConfig(ModuleConfig(mqtt = it)) }
lmc.serial?.let { setModuleConfig(ModuleConfig(serial = it)) }
lmc.external_notification?.let { setModuleConfig(ModuleConfig(external_notification = it)) }
lmc.store_forward?.let { setModuleConfig(ModuleConfig(store_forward = it)) }
lmc.range_test?.let { setModuleConfig(ModuleConfig(range_test = it)) }
lmc.telemetry?.let { setModuleConfig(ModuleConfig(telemetry = it)) }
lmc.canned_message?.let { setModuleConfig(ModuleConfig(canned_message = it)) }
lmc.audio?.let { setModuleConfig(ModuleConfig(audio = it)) }
lmc.remote_hardware?.let { setModuleConfig(ModuleConfig(remote_hardware = it)) }
lmc.neighbor_info?.let { setModuleConfig(ModuleConfig(neighbor_info = it)) }
lmc.ambient_lighting?.let { setModuleConfig(ModuleConfig(ambient_lighting = it)) }
lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) }
lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
}
}
private suspend fun installModuleConfigPart1(destNum: Int, lmc: LocalModuleConfig) {
lmc.mqtt?.let {
radioController.setModuleConfig(destNum, ModuleConfig(mqtt = it), radioController.getPacketId())
}
lmc.serial?.let {
radioController.setModuleConfig(destNum, ModuleConfig(serial = it), radioController.getPacketId())
}
lmc.external_notification?.let {
radioController.setModuleConfig(
destNum,
ModuleConfig(external_notification = it),
radioController.getPacketId(),
)
}
lmc.store_forward?.let {
radioController.setModuleConfig(destNum, ModuleConfig(store_forward = it), radioController.getPacketId())
}
lmc.range_test?.let {
radioController.setModuleConfig(destNum, ModuleConfig(range_test = it), radioController.getPacketId())
}
lmc.telemetry?.let {
radioController.setModuleConfig(destNum, ModuleConfig(telemetry = it), radioController.getPacketId())
}
lmc.canned_message?.let {
radioController.setModuleConfig(destNum, ModuleConfig(canned_message = it), radioController.getPacketId())
}
lmc.audio?.let {
radioController.setModuleConfig(destNum, ModuleConfig(audio = it), radioController.getPacketId())
}
}
private suspend fun installModuleConfigPart2(destNum: Int, lmc: LocalModuleConfig) {
lmc.remote_hardware?.let {
radioController.setModuleConfig(destNum, ModuleConfig(remote_hardware = it), radioController.getPacketId())
}
lmc.neighbor_info?.let {
radioController.setModuleConfig(destNum, ModuleConfig(neighbor_info = it), radioController.getPacketId())
}
lmc.ambient_lighting?.let {
radioController.setModuleConfig(destNum, ModuleConfig(ambient_lighting = it), radioController.getPacketId())
}
lmc.detection_sensor?.let {
radioController.setModuleConfig(destNum, ModuleConfig(detection_sensor = it), radioController.getPacketId())
}
lmc.paxcounter?.let {
radioController.setModuleConfig(destNum, ModuleConfig(paxcounter = it), radioController.getPacketId())
}
lmc.statusmessage?.let {
radioController.setModuleConfig(destNum, ModuleConfig(statusmessage = it), radioController.getPacketId())
}
lmc.traffic_management?.let {
radioController.setModuleConfig(
destNum,
ModuleConfig(traffic_management = it),
radioController.getPacketId(),
)
}
lmc.tak?.let { radioController.setModuleConfig(destNum, ModuleConfig(tak = it), radioController.getPacketId()) }
}
}

View File

@@ -1,130 +0,0 @@
/*
* 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.domain.usecase.settings
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.meshtastic.core.model.getStringResFrom
import org.meshtastic.core.resources.UiText
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceConnectionStatus
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Routing
import org.meshtastic.proto.User
/** Sealed class representing the result of processing a radio response packet. */
sealed class RadioResponseResult {
data class Metadata(val metadata: DeviceMetadata) : RadioResponseResult()
data class ChannelResponse(val channel: Channel) : RadioResponseResult()
data class Owner(val user: User) : RadioResponseResult()
data class ConfigResponse(val config: org.meshtastic.proto.Config) : RadioResponseResult()
data class ModuleConfigResponse(val config: org.meshtastic.proto.ModuleConfig) : RadioResponseResult()
data class CannedMessages(val messages: String) : RadioResponseResult()
data class Ringtone(val ringtone: String) : RadioResponseResult()
data class ConnectionStatus(val status: DeviceConnectionStatus) : RadioResponseResult()
data class Error(val message: UiText) : RadioResponseResult()
data object Success : RadioResponseResult()
}
/** Use case for processing incoming [MeshPacket]s that are responses to admin requests. */
@Single
open class ProcessRadioResponseUseCase {
/**
* Decodes and processes the provided [packet].
*
* @param packet The mesh packet received from the radio.
* @param destNum The node number that the response is expected from.
* @param requestIds The set of active request IDs.
* @return A [RadioResponseResult] if the packet matches a request, or null otherwise.
*/
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
open operator fun invoke(packet: MeshPacket, destNum: Int, requestIds: Set<Int>): RadioResponseResult? {
val data = packet.decoded
if (data == null || data.request_id !in requestIds) {
return null
}
return when (data.portnum) {
PortNum.ROUTING_APP -> processRoutingResponse(packet, data, destNum)
PortNum.ADMIN_APP -> processAdminResponse(packet, data, destNum)
else -> null
}
}
private fun processRoutingResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult? {
val parsed = Routing.ADAPTER.decode(data.payload)
return when {
parsed.error_reason != Routing.Error.NONE ->
RadioResponseResult.Error(UiText.Resource(getStringResFrom(parsed.error_reason?.value ?: 0)))
packet.from == destNum -> RadioResponseResult.Success
else -> null
}
}
private fun processAdminResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult {
if (destNum != packet.from) {
return RadioResponseResult.Error(
UiText.DynamicString("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}."),
)
}
val parsed = AdminMessage.ADAPTER.decode(data.payload)
return processAdminMessage(parsed)
}
private fun processAdminMessage(parsed: AdminMessage): RadioResponseResult = when {
parsed.get_device_metadata_response != null ->
RadioResponseResult.Metadata(parsed.get_device_metadata_response!!)
parsed.get_channel_response != null -> RadioResponseResult.ChannelResponse(parsed.get_channel_response!!)
parsed.get_owner_response != null -> RadioResponseResult.Owner(parsed.get_owner_response!!)
parsed.get_config_response != null -> RadioResponseResult.ConfigResponse(parsed.get_config_response!!)
parsed.get_module_config_response != null ->
RadioResponseResult.ModuleConfigResponse(parsed.get_module_config_response!!)
parsed.get_canned_message_module_messages_response != null ->
RadioResponseResult.CannedMessages(parsed.get_canned_message_module_messages_response!!)
parsed.get_ringtone_response != null -> RadioResponseResult.Ringtone(parsed.get_ringtone_response!!)
parsed.get_device_connection_status_response != null ->
RadioResponseResult.ConnectionStatus(parsed.get_device_connection_status_response!!)
else -> {
Logger.d { "No custom processing needed for $parsed" }
RadioResponseResult.Success
}
}
}

View File

@@ -19,170 +19,91 @@ package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceConnectionStatus
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
/** Use case for interacting with radio configuration components. */
/**
* Use case for interacting with radio configuration components.
*
* Methods suspend until the device responds and return typed results directly.
* On failure, they propagate [org.meshtastic.core.model.AdminException].
*/
@Suppress("TooManyFunctions")
@Single
open class RadioConfigUseCase constructor(private val radioController: RadioController) {
/**
* Updates the owner information on the radio.
*
* @param destNum The node number to update.
* @param user The new user configuration.
* @return The packet ID of the request.
*/
open suspend fun setOwner(destNum: Int, user: User): Int {
val packetId = radioController.getPacketId()
radioController.setOwner(destNum, user, packetId)
return packetId
/** Write the owner on the target node. */
open suspend fun setOwner(destNum: Int, user: User) {
radioController.setOwner(destNum, user)
}
/**
* Requests the owner information from the radio.
*
* @param destNum The node number to query.
* @return The packet ID of the request.
*/
open suspend fun getOwner(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.getOwner(destNum, packetId)
return packetId
/** Read the owner from the target node. */
open suspend fun getOwner(destNum: Int): User =
radioController.getOwner(destNum)
/** Write a config section on the target node. */
open suspend fun setConfig(destNum: Int, config: Config) {
radioController.setConfig(destNum, config)
}
/**
* Updates a configuration section on the radio.
*
* @param destNum The node number to update.
* @param config The new configuration.
* @return The packet ID of the request.
*/
open suspend fun setConfig(destNum: Int, config: Config): Int {
val packetId = radioController.getPacketId()
radioController.setConfig(destNum, config, packetId)
return packetId
/** Read a config section from the target node. */
open suspend fun getConfig(destNum: Int, configType: Int): Config =
radioController.getConfig(destNum, configType)
/** Write a module config section on the target node. */
open suspend fun setModuleConfig(destNum: Int, config: ModuleConfig) {
radioController.setModuleConfig(destNum, config)
}
/**
* Requests a configuration section from the radio.
*
* @param destNum The node number to query.
* @param configType The type of configuration to request (from [org.meshtastic.proto.AdminMessage.ConfigType]).
* @return The packet ID of the request.
*/
open suspend fun getConfig(destNum: Int, configType: Int): Int {
val packetId = radioController.getPacketId()
radioController.getConfig(destNum, configType, packetId)
return packetId
/** Read a module config section from the target node. */
open suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): ModuleConfig =
radioController.getModuleConfig(destNum, moduleConfigType)
/** Read a channel by index from the target node. */
open suspend fun getChannel(destNum: Int, index: Int): Channel =
radioController.getChannel(destNum, index)
/** Read all channels from the target node. */
open suspend fun listChannels(destNum: Int): List<Channel> =
radioController.listChannels(destNum)
/** Write a channel on the target node. */
open suspend fun setRemoteChannel(destNum: Int, channel: Channel) {
radioController.setRemoteChannel(destNum, channel)
}
/**
* Updates a module configuration section on the radio.
*
* @param destNum The node number to update.
* @param config The new module configuration.
* @return The packet ID of the request.
*/
open suspend fun setModuleConfig(destNum: Int, config: ModuleConfig): Int {
val packetId = radioController.getPacketId()
radioController.setModuleConfig(destNum, config, packetId)
return packetId
}
/**
* Requests a module configuration section from the radio.
*
* @param destNum The node number to query.
* @param moduleConfigType The type of module configuration to request.
* @return The packet ID of the request.
*/
open suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): Int {
val packetId = radioController.getPacketId()
radioController.getModuleConfig(destNum, moduleConfigType, packetId)
return packetId
}
/**
* Requests a channel from the radio.
*
* @param destNum The node number to query.
* @param index The index of the channel to request.
* @return The packet ID of the request.
*/
open suspend fun getChannel(destNum: Int, index: Int): Int {
val packetId = radioController.getPacketId()
radioController.getChannel(destNum, index, packetId)
return packetId
}
/**
* Updates a channel on the radio.
*
* @param destNum The node number to update.
* @param channel The new channel configuration.
* @return The packet ID of the request.
*/
open suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel): Int {
val packetId = radioController.getPacketId()
radioController.setRemoteChannel(destNum, channel, packetId)
return packetId
}
/** Updates the fixed position on the radio. */
/** Set a fixed position on the target node. */
open suspend fun setFixedPosition(destNum: Int, position: Position) {
radioController.setFixedPosition(destNum, position)
}
/** Removes the fixed position on the radio. */
/** Remove the fixed position (zero coordinates). */
open suspend fun removeFixedPosition(destNum: Int) {
radioController.setFixedPosition(destNum, Position(0.0, 0.0, 0))
}
/** Sets the ringtone on the radio. */
/** Write the ringtone on the target node. */
open suspend fun setRingtone(destNum: Int, ringtone: String) {
radioController.setRingtone(destNum, ringtone)
}
/**
* Requests the ringtone from the radio.
*
* @param destNum The node number to query.
* @return The packet ID of the request.
*/
open suspend fun getRingtone(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.getRingtone(destNum, packetId)
return packetId
}
/** Read the ringtone from the target node. */
open suspend fun getRingtone(destNum: Int): String =
radioController.getRingtone(destNum)
/** Sets the canned messages on the radio. */
/** Write canned messages on the target node. */
open suspend fun setCannedMessages(destNum: Int, messages: String) {
radioController.setCannedMessages(destNum, messages)
}
/**
* Requests the canned messages from the radio.
*
* @param destNum The node number to query.
* @return The packet ID of the request.
*/
open suspend fun getCannedMessages(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.getCannedMessages(destNum, packetId)
return packetId
}
/** Read canned messages from the target node. */
open suspend fun getCannedMessages(destNum: Int): String =
radioController.getCannedMessages(destNum)
/**
* Requests the device connection status from the radio.
*
* @param destNum The node number to query.
* @return The packet ID of the request.
*/
open suspend fun getDeviceConnectionStatus(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.getDeviceConnectionStatus(destNum, packetId)
return packetId
}
/** Read device connection status from the target node. */
open suspend fun getDeviceConnectionStatus(destNum: Int): DeviceConnectionStatus =
radioController.getDeviceConnectionStatus(destNum)
}

View File

@@ -60,11 +60,10 @@ class InstallProfileUseCaseTest {
}
@Test
fun `invoke calls begin and commit edit settings`() = runTest {
fun `invoke calls editSettings`() = runTest {
useCase(1234, DeviceProfile(), User())
assertTrue(radioController.beginEditSettingsCalled)
assertTrue(radioController.commitEditSettingsCalled)
assertTrue(radioController.editSettingsCalled)
}
@Test
@@ -108,7 +107,6 @@ class InstallProfileUseCaseTest {
useCase(1234, profile, org.meshtastic.proto.User(long_name = "Old"))
assertTrue(radioController.beginEditSettingsCalled)
assertTrue(radioController.commitEditSettingsCalled)
assertTrue(radioController.editSettingsCalled)
}
}

View File

@@ -47,8 +47,8 @@ class RadioConfigUseCaseTest {
@Test
fun `getOwner calls radioController`() = runTest {
val packetId = useCase.getOwner(1234)
assertEquals(1, packetId)
val user = useCase.getOwner(1234)
// FakeRadioController returns a default User
}
@Test

View File

@@ -0,0 +1,42 @@
/*
* 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.model
/**
* Domain-level exception for admin (configuration) operations that fail for expected reasons.
*
* These failures are part of normal mesh operation — a remote node may be unreachable, the
* session key may have expired, or the request may time out. They are NOT thrown for catastrophic
* failures (transport gone, engine torn down) which throw standard exceptions.
*/
sealed class AdminException(message: String) : Exception(message) {
/** The admin request timed out waiting for a device response. */
class Timeout : AdminException("Request timed out")
/** Client is not authorized to perform this operation on the target node. */
class Unauthorized : AdminException("Not authorized")
/** The destination node is unreachable (no route, NAK, or max retransmit). */
class NodeUnreachable : AdminException("Node unreachable")
/** Session key expired or was never established; a retry may succeed after re-seeding. */
class SessionKeyExpired : AdminException("Session key expired")
/** Device reported a routing error not covered by the other subtypes. */
class RoutingError(val errorName: String) : AdminException("Routing error: $errorName")
}

View File

@@ -23,6 +23,14 @@ import org.meshtastic.proto.Config
interface DeviceAdmin : ConnectionAware {
suspend fun setLocalConfig(config: Config)
suspend fun setLocalChannel(channel: Channel)
suspend fun beginEditSettings(destNum: Int)
suspend fun commitEditSettings(destNum: Int)
/**
* Run [block] inside a `begin_edit_settings` / `commit_edit_settings` envelope so the device
* applies all writes atomically.
*
* @param destNum the target node number (local or remote)
* @param block a suspending block using [DeviceAdminEdit] receiver to queue writes
* @throws AdminException on begin/commit failure (timeout, unauthorized, etc.)
*/
suspend fun editSettings(destNum: Int, block: suspend DeviceAdminEdit.() -> Unit)
}

View File

@@ -0,0 +1,36 @@
/*
* 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.model
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
/**
* Receiver interface for batched admin writes inside an [DeviceAdmin.editSettings] block.
*
* Methods queue writes without awaiting individual acknowledgements. The enclosing
* `editSettings` call handles `begin_edit_settings` / `commit_edit_settings` framing so
* the device applies all writes atomically.
*/
interface DeviceAdminEdit {
suspend fun setConfig(config: Config)
suspend fun setModuleConfig(config: ModuleConfig)
suspend fun setOwner(user: User)
suspend fun setChannel(channel: Channel)
}

View File

@@ -16,13 +16,17 @@
*/
package org.meshtastic.core.model
/** Focused interface for device lifecycle control. */
/**
* Focused interface for device lifecycle control.
*
* Methods suspend until the device acknowledges the command. On failure, they throw [AdminException].
*/
interface DeviceControl : ConnectionAware {
suspend fun reboot(destNum: Int, packetId: Int)
suspend fun reboot(destNum: Int)
suspend fun rebootToDfu(nodeNum: Int)
suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
suspend fun shutdown(destNum: Int, packetId: Int)
suspend fun factoryReset(destNum: Int, packetId: Int)
suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
suspend fun requestRebootOta(destNum: Int, mode: Int, hash: ByteArray?)
suspend fun shutdown(destNum: Int)
suspend fun factoryReset(destNum: Int)
suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean)
suspend fun removeByNodenum(nodeNum: Int)
}

View File

@@ -18,23 +18,58 @@ package org.meshtastic.core.model
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceConnectionStatus
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
/** Focused interface for remote node administration. */
/**
* Focused interface for remote node administration.
*
* Methods suspend until the device responds. On failure, they throw [AdminException].
*/
interface RemoteAdmin {
suspend fun setOwner(destNum: Int, user: User, packetId: Int)
suspend fun setConfig(destNum: Int, config: Config, packetId: Int)
suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int)
suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int)
/** Write the owner [user] on the target node. */
suspend fun setOwner(destNum: Int, user: User)
/** Write a [config] section on the target node. */
suspend fun setConfig(destNum: Int, config: Config)
/** Write a [config] module section on the target node. */
suspend fun setModuleConfig(destNum: Int, config: ModuleConfig)
/** Write a [channel] on the target node. */
suspend fun setRemoteChannel(destNum: Int, channel: Channel)
/** Set a fixed position on the target node. */
suspend fun setFixedPosition(destNum: Int, position: Position)
/** Set the ringtone (RTTTL) on the target node. */
suspend fun setRingtone(destNum: Int, ringtone: String)
/** Set canned messages on the target node. */
suspend fun setCannedMessages(destNum: Int, messages: String)
suspend fun getOwner(destNum: Int, packetId: Int)
suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
suspend fun getRingtone(destNum: Int, packetId: Int)
suspend fun getCannedMessages(destNum: Int, packetId: Int)
suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
/** Read the owner from the target node. */
suspend fun getOwner(destNum: Int): User
/** Read a config section from the target node. */
suspend fun getConfig(destNum: Int, configType: Int): Config
/** Read a module config section from the target node. */
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): ModuleConfig
/** Read a channel by [index] from the target node. */
suspend fun getChannel(destNum: Int, index: Int): Channel
/** Read all channels from the target node (stops at first disabled slot). */
suspend fun listChannels(destNum: Int): List<Channel>
/** Read the ringtone from the target node. */
suspend fun getRingtone(destNum: Int): String
/** Read canned messages from the target node. */
suspend fun getCannedMessages(destNum: Int): String
/** Read device connection status from the target node. */
suspend fun getDeviceConnectionStatus(destNum: Int): DeviceConnectionStatus
}

View File

@@ -19,11 +19,13 @@ package org.meshtastic.core.testing
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceAdminEdit
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceConnectionStatus
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
@@ -48,8 +50,7 @@ class FakeRadioController :
var throwOnSend: Boolean = false
var lastSetDeviceAddress: String? = null
var lastStoreForwardHistoryRequest: Pair<Int?, Int?>? = null
var beginEditSettingsCalled = false
var commitEditSettingsCalled = false
var editSettingsCalled = false
var startProvideLocationCalled = false
var stopProvideLocationCalled = false
@@ -61,8 +62,7 @@ class FakeRadioController :
throwOnSend = false
lastSetDeviceAddress = null
lastStoreForwardHistoryRequest = null
beginEditSettingsCalled = false
commitEditSettingsCalled = false
editSettingsCalled = false
startProvideLocationCalled = false
stopProvideLocationCalled = false
}
@@ -90,13 +90,13 @@ class FakeRadioController :
override suspend fun setLocalChannel(channel: Channel) {}
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {}
override suspend fun setOwner(destNum: Int, user: User) {}
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {}
override suspend fun setConfig(destNum: Int, config: Config) {}
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {}
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig) {}
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {}
override suspend fun setRemoteChannel(destNum: Int, channel: Channel) {}
override suspend fun setFixedPosition(destNum: Int, position: Position) {}
@@ -104,33 +104,36 @@ class FakeRadioController :
override suspend fun setCannedMessages(destNum: Int, messages: String) {}
override suspend fun getOwner(destNum: Int, packetId: Int) {}
override suspend fun getOwner(destNum: Int): User = User()
override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {}
override suspend fun getConfig(destNum: Int, configType: Int): Config = Config()
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {}
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): ModuleConfig = ModuleConfig()
override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {}
override suspend fun getChannel(destNum: Int, index: Int): Channel = Channel()
override suspend fun getRingtone(destNum: Int, packetId: Int) {}
override suspend fun listChannels(destNum: Int): List<Channel> = emptyList()
override suspend fun getCannedMessages(destNum: Int, packetId: Int) {}
override suspend fun getRingtone(destNum: Int): String = ""
override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {}
override suspend fun getCannedMessages(destNum: Int): String = ""
override suspend fun reboot(destNum: Int, packetId: Int) {}
override suspend fun getDeviceConnectionStatus(destNum: Int): DeviceConnectionStatus =
DeviceConnectionStatus()
override suspend fun reboot(destNum: Int) {}
override suspend fun rebootToDfu(nodeNum: Int) {}
override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
override suspend fun requestRebootOta(destNum: Int, mode: Int, hash: ByteArray?) {}
override suspend fun shutdown(destNum: Int, packetId: Int) {}
override suspend fun shutdown(destNum: Int) {}
override suspend fun factoryReset(destNum: Int, packetId: Int) {}
override suspend fun factoryReset(destNum: Int) {}
override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {}
override suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean) {}
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {}
override suspend fun removeByNodenum(nodeNum: Int) {}
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {}
@@ -147,12 +150,15 @@ class FakeRadioController :
return true
}
override suspend fun beginEditSettings(destNum: Int) {
beginEditSettingsCalled = true
}
override suspend fun commitEditSettings(destNum: Int) {
commitEditSettingsCalled = true
override suspend fun editSettings(destNum: Int, block: suspend DeviceAdminEdit.() -> Unit) {
editSettingsCalled = true
val edit = object : DeviceAdminEdit {
override suspend fun setConfig(config: Config) {}
override suspend fun setModuleConfig(config: ModuleConfig) {}
override suspend fun setOwner(user: User) {}
override suspend fun setChannel(channel: Channel) {}
}
block(edit)
}
override fun getPacketId(): Int = 1

View File

@@ -190,7 +190,7 @@ class Esp32OtaUpdateHandler(
val myInfo = nodeRepository.myNodeInfo.value ?: return
val myNodeNum = myInfo.myNodeNum
Logger.i { "ESP32 OTA: Triggering reboot OTA mode $mode with hash" }
radioController.requestRebootOta(radioController.getPacketId(), myNodeNum, mode, hash)
radioController.requestRebootOta(myNodeNum, mode, hash)
}
/**

View File

@@ -23,7 +23,6 @@ import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.DeviceControl
import org.meshtastic.core.model.MessageSender
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.NodeRepository
@@ -49,7 +48,6 @@ constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val deviceControl: DeviceControl,
private val messageSender: MessageSender,
private val alertManager: AlertManager,
) {
open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) {
@@ -66,8 +64,7 @@ constructor(
open fun removeNode(scope: CoroutineScope, nodeNum: Int) {
scope.launch(ioDispatcher) {
Logger.i { "Removing node '$nodeNum'" }
val packetId = messageSender.getPacketId()
deviceControl.removeByNodenum(packetId, nodeNum)
deviceControl.removeByNodenum(nodeNum)
nodeRepository.deleteNode(nodeNum)
}
}

View File

@@ -47,7 +47,6 @@ class NodeManagementActionsTest {
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
deviceControl = radioController,
messageSender = radioController,
alertManager = alertManager,
)
@@ -80,7 +79,6 @@ class NodeManagementActionsTest {
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
deviceControl = radioController,
messageSender = radioController,
alertManager = realAlertManager,
)
val node = Node(num = 123, user = User(long_name = "Test Node"))

View File

@@ -21,7 +21,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -41,9 +40,8 @@ import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
import org.meshtastic.core.model.AdminException
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MqttConnectionState
import org.meshtastic.core.model.MqttProbeStatus
@@ -70,7 +68,6 @@ import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceConnectionStatus
@@ -81,10 +78,8 @@ import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
import kotlin.time.Duration.Companion.seconds
/** Data class that represents the current RadioConfig state. */
data class RadioConfigState(
@@ -125,7 +120,6 @@ open class RadioConfigViewModel(
private val installProfileUseCase: InstallProfileUseCase,
private val radioConfigUseCase: RadioConfigUseCase,
private val adminActionsUseCase: AdminActionsUseCase,
private val processRadioResponseUseCase: ProcessRadioResponseUseCase,
private val locationService: LocationService,
private val fileService: FileService,
private val mqttManager: MqttManager,
@@ -189,7 +183,6 @@ open class RadioConfigViewModel(
val destNode: StateFlow<Node?>
get() = _destNode
private val requestIds = MutableStateFlow(hashSetOf<Int>())
private val _radioConfigState = MutableStateFlow(RadioConfigState())
val radioConfigState: StateFlow<RadioConfigState> = _radioConfigState
@@ -239,8 +232,6 @@ open class RadioConfigViewModel(
.onEach { manifest -> _radioConfigState.update { it.copy(fileManifest = manifest) } }
.launchIn(viewModelScope)
serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope)
combine(serviceRepository.connectionState, radioConfigState) { connState, _ ->
_radioConfigState.update { it.copy(connected = connState == ConnectionState.Connected) }
}
@@ -283,8 +274,7 @@ open class RadioConfigViewModel(
val destNum = destNode.value?.num ?: return
safeLaunch(tag = "setOwner") {
_radioConfigState.update { it.copy(userConfig = user) }
val packetId = radioConfigUseCase.setOwner(destNum, user)
registerRequestId(packetId)
radioConfigUseCase.setOwner(destNum, user)
}
}
@@ -292,8 +282,7 @@ open class RadioConfigViewModel(
val destNum = destNode.value?.num ?: return
getChannelList(new, old).forEach { channel ->
safeLaunch(tag = "setRemoteChannel") {
val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel)
registerRequestId(packetId)
radioConfigUseCase.setRemoteChannel(destNum, channel)
}
}
@@ -324,8 +313,7 @@ open class RadioConfigViewModel(
),
)
}
val packetId = radioConfigUseCase.setConfig(destNum, config)
registerRequestId(packetId)
radioConfigUseCase.setConfig(destNum, config)
}
}
@@ -357,8 +345,7 @@ open class RadioConfigViewModel(
),
)
}
val packetId = radioConfigUseCase.setModuleConfig(destNum, config)
registerRequestId(packetId)
radioConfigUseCase.setModuleConfig(destNum, config)
}
}
@@ -374,47 +361,6 @@ open class RadioConfigViewModel(
safeLaunch(tag = "setCannedMessages") { radioConfigUseCase.setCannedMessages(destNum, messages) }
}
private fun sendAdminRequest(destNum: Int) {
val route = radioConfigState.value.route
_radioConfigState.update { it.copy(route = "") } // setter (response is PortNum.ROUTING_APP)
val preserveFavorites = radioConfigState.value.nodeDbResetPreserveFavorites
when (route) {
AdminRoute.REBOOT.name ->
safeLaunch(tag = "reboot") {
val packetId = adminActionsUseCase.reboot(destNum)
registerRequestId(packetId)
}
AdminRoute.SHUTDOWN.name ->
with(radioConfigState.value) {
if (metadata?.canShutdown != true) {
sendError(Res.string.cant_shutdown)
} else {
safeLaunch(tag = "shutdown") {
val packetId = adminActionsUseCase.shutdown(destNum)
registerRequestId(packetId)
}
}
}
AdminRoute.FACTORY_RESET.name ->
safeLaunch(tag = "factoryReset") {
val isLocal = (destNum == myNodeNum)
val packetId = adminActionsUseCase.factoryReset(destNum, isLocal)
registerRequestId(packetId)
}
AdminRoute.NODEDB_RESET.name ->
safeLaunch(tag = "nodedbReset") {
val isLocal = (destNum == myNodeNum)
val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
registerRequestId(packetId)
}
}
}
fun setFixedPosition(position: Position) {
val destNum = destNode.value?.num ?: return
safeLaunch(tag = "setFixedPosition") { radioConfigUseCase.setFixedPosition(destNum, position) }
@@ -457,7 +403,6 @@ open class RadioConfigViewModel(
}
fun clearPacketResponse() {
requestIds.value = hashSetOf()
_radioConfigState.update { it.copy(responseState = ResponseState.Empty) }
}
@@ -466,97 +411,146 @@ open class RadioConfigViewModel(
_radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) }
when (route) {
ConfigRoute.USER ->
safeLaunch(tag = "getOwner") {
val packetId = radioConfigUseCase.getOwner(destNum)
registerRequestId(packetId)
}
viewModelScope.launch {
try {
when (route) {
ConfigRoute.USER -> {
val user = radioConfigUseCase.getOwner(destNum)
_radioConfigState.update { it.copy(userConfig = user) }
}
ConfigRoute.CHANNELS -> {
safeLaunch(tag = "getChannel0") {
val packetId = radioConfigUseCase.getChannel(destNum, 0)
registerRequestId(packetId)
}
safeLaunch(tag = "getLoraConfig") {
val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value)
registerRequestId(packetId)
}
// channel editor is synchronous, so we don't use requestIds as total
setResponseStateTotal(maxChannels + 1)
}
ConfigRoute.CHANNELS -> {
val channels = radioConfigUseCase.listChannels(destNum)
val loraConfig = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value)
_radioConfigState.update { state ->
state.copy(
channelList = channels.mapNotNull { it.settings },
radioConfig = state.radioConfig.copy(lora = loraConfig.lora ?: state.radioConfig.lora),
)
}
}
is AdminRoute -> {
safeLaunch(tag = "getSessionKeyConfig") {
val packetId =
radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
registerRequestId(packetId)
}
setResponseStateTotal(2)
}
is AdminRoute -> {
executeAdminAction(destNum, route)
return@launch
}
is ConfigRoute -> {
if (route == ConfigRoute.LORA) {
safeLaunch(tag = "getChannel0ForLora") {
val packetId = radioConfigUseCase.getChannel(destNum, 0)
registerRequestId(packetId)
is ConfigRoute -> {
val config = radioConfigUseCase.getConfig(destNum, route.type)
_radioConfigState.update { state ->
state.copy(
radioConfig = state.radioConfig.copy(
device = config.device ?: state.radioConfig.device,
position = config.position ?: state.radioConfig.position,
power = config.power ?: state.radioConfig.power,
network = config.network ?: state.radioConfig.network,
display = config.display ?: state.radioConfig.display,
lora = config.lora ?: state.radioConfig.lora,
bluetooth = config.bluetooth ?: state.radioConfig.bluetooth,
security = config.security ?: state.radioConfig.security,
),
)
}
if (route == ConfigRoute.LORA) {
val channels = radioConfigUseCase.listChannels(destNum)
_radioConfigState.update { it.copy(channelList = channels.mapNotNull { ch -> ch.settings }) }
}
if (route == ConfigRoute.NETWORK) {
val status = radioConfigUseCase.getDeviceConnectionStatus(destNum)
_radioConfigState.update { it.copy(deviceConnectionStatus = status) }
}
}
is ModuleRoute -> {
val moduleConfig = radioConfigUseCase.getModuleConfig(destNum, route.type)
_radioConfigState.update { state ->
state.copy(
moduleConfig = state.moduleConfig.copy(
mqtt = moduleConfig.mqtt ?: state.moduleConfig.mqtt,
serial = moduleConfig.serial ?: state.moduleConfig.serial,
external_notification =
moduleConfig.external_notification ?: state.moduleConfig.external_notification,
store_forward = moduleConfig.store_forward ?: state.moduleConfig.store_forward,
range_test = moduleConfig.range_test ?: state.moduleConfig.range_test,
telemetry = moduleConfig.telemetry ?: state.moduleConfig.telemetry,
canned_message = moduleConfig.canned_message ?: state.moduleConfig.canned_message,
audio = moduleConfig.audio ?: state.moduleConfig.audio,
remote_hardware =
moduleConfig.remote_hardware ?: state.moduleConfig.remote_hardware,
neighbor_info = moduleConfig.neighbor_info ?: state.moduleConfig.neighbor_info,
ambient_lighting =
moduleConfig.ambient_lighting ?: state.moduleConfig.ambient_lighting,
detection_sensor =
moduleConfig.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = moduleConfig.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = moduleConfig.statusmessage ?: state.moduleConfig.statusmessage,
traffic_management =
moduleConfig.traffic_management ?: state.moduleConfig.traffic_management,
tak = moduleConfig.tak ?: state.moduleConfig.tak,
),
)
}
if (route == ModuleRoute.CANNED_MESSAGE) {
val messages = radioConfigUseCase.getCannedMessages(destNum)
_radioConfigState.update { it.copy(cannedMessageMessages = messages) }
}
if (route == ModuleRoute.EXT_NOTIFICATION) {
val ringtone = radioConfigUseCase.getRingtone(destNum)
_radioConfigState.update { it.copy(ringtone = ringtone) }
}
}
}
if (route == ConfigRoute.NETWORK) {
safeLaunch(tag = "getConnectionStatus") {
val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum)
registerRequestId(packetId)
}
}
safeLaunch(tag = "getConfig") {
val packetId = radioConfigUseCase.getConfig(destNum, route.type)
registerRequestId(packetId)
}
}
is ModuleRoute -> {
if (route == ModuleRoute.CANNED_MESSAGE) {
safeLaunch(tag = "getCannedMessages") {
val packetId = radioConfigUseCase.getCannedMessages(destNum)
registerRequestId(packetId)
}
}
if (route == ModuleRoute.EXT_NOTIFICATION) {
safeLaunch(tag = "getRingtone") {
val packetId = radioConfigUseCase.getRingtone(destNum)
registerRequestId(packetId)
}
}
safeLaunch(tag = "getModuleConfig") {
val packetId = radioConfigUseCase.getModuleConfig(destNum, route.type)
registerRequestId(packetId)
}
setResponseStateSuccess()
} catch (e: AdminException) {
sendError(e.toUiText())
}
}
}
private suspend fun executeAdminAction(destNum: Int, route: AdminRoute) {
try {
val preserveFavorites = radioConfigState.value.nodeDbResetPreserveFavorites
when (route) {
AdminRoute.REBOOT -> adminActionsUseCase.reboot(destNum)
AdminRoute.SHUTDOWN -> {
if (radioConfigState.value.metadata?.canShutdown != true) {
sendError(Res.string.cant_shutdown)
return
}
adminActionsUseCase.shutdown(destNum)
}
AdminRoute.FACTORY_RESET -> {
val isLocal = (destNum == myNodeNum)
adminActionsUseCase.factoryReset(destNum, isLocal)
}
AdminRoute.NODEDB_RESET -> {
val isLocal = (destNum == myNodeNum)
adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
}
}
setResponseStateSuccess()
} catch (e: AdminException) {
sendError(e.toUiText())
}
}
private fun AdminException.toUiText(): UiText = when (this) {
is AdminException.Timeout -> UiText.Resource(Res.string.timeout)
else -> UiText.DynamicString(message ?: "Admin request failed")
}
fun shouldReportLocation(nodeNum: Int?) = mapConsentPrefs.shouldReportLocation(nodeNum)
fun setShouldReportLocation(nodeNum: Int?, shouldReportLocation: Boolean) {
mapConsentPrefs.setShouldReportLocation(nodeNum, shouldReportLocation)
}
private fun setResponseStateTotal(total: Int) {
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
state.copy(responseState = state.responseState.copy(total = total))
} else {
state // Return the unchanged state for other response states
}
}
}
protected fun setResponseStateSuccess() {
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
state.copy(responseState = ResponseState.Success(true))
} else {
state // Return the unchanged state for other response states
state
}
}
}
@@ -570,195 +564,4 @@ open class RadioConfigViewModel(
private fun setResponseStateError(error: UiText) {
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
}
private fun incrementCompleted() {
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
val increment = state.responseState.completed + 1
state.copy(responseState = state.responseState.copy(completed = increment))
} else {
state // Return the unchanged state for other response states
}
}
}
private fun registerRequestId(packetId: Int) {
requestIds.update { it.apply { add(packetId) } }
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
val total = maxOf(requestIds.value.size, state.responseState.total)
state.copy(responseState = state.responseState.copy(total = total))
} else {
state.copy(
route = "", // setter (response is PortNum.ROUTING_APP)
responseState = ResponseState.Loading(),
)
}
}
val requestTimeout = 30.seconds
safeLaunch(tag = "requestTimeout") {
delay(requestTimeout)
if (requestIds.value.contains(packetId)) {
requestIds.update { it.apply { remove(packetId) } }
if (requestIds.value.isEmpty()) {
sendError(Res.string.timeout)
}
}
}
}
private fun processPacketResponse(packet: MeshPacket) {
val destNum = destNode.value?.num ?: return
val result = processRadioResponseUseCase(packet, destNum, requestIds.value) ?: return
val route = radioConfigState.value.route
when (result) {
is RadioResponseResult.Error -> {
sendError(result.message)
// Abort the AdminRoute flow — do not fire the destructive action
// (reboot/shutdown/factory_reset) if the metadata preflight failed.
return
}
is RadioResponseResult.Success -> {
if (route.isEmpty()) {
val data = packet.decoded!!
requestIds.update { it.apply { remove(data.request_id) } }
if (requestIds.value.isEmpty()) {
setResponseStateSuccess()
} else {
incrementCompleted()
}
}
}
is RadioResponseResult.Metadata -> {
_radioConfigState.update { it.copy(metadata = result.metadata) }
incrementCompleted()
}
is RadioResponseResult.ChannelResponse -> {
val response = result.channel
// Stop once we get to the first disabled entry
if (response.role != Channel.Role.DISABLED) {
_radioConfigState.update { state ->
state.copy(
channelList =
state.channelList.toMutableList().apply {
val index = response.index
val settings = response.settings ?: ChannelSettings()
// Make sure list is large enough
while (size <= index) add(ChannelSettings())
set(index, settings)
},
)
}
incrementCompleted()
val index = response.index
if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
// Not done yet, request next channel
safeLaunch(tag = "getNextChannel") {
val packetId = radioConfigUseCase.getChannel(destNum, index + 1)
registerRequestId(packetId)
}
}
} else {
// Received last channel, update total and start channel editor
setResponseStateTotal(response.index + 1)
}
}
is RadioResponseResult.Owner -> {
_radioConfigState.update { it.copy(userConfig = result.user) }
incrementCompleted()
}
is RadioResponseResult.ConfigResponse -> {
val response = result.config
_radioConfigState.update { state ->
state.copy(
radioConfig =
state.radioConfig.copy(
device = response.device ?: state.radioConfig.device,
position = response.position ?: state.radioConfig.position,
power = response.power ?: state.radioConfig.power,
network = response.network ?: state.radioConfig.network,
display = response.display ?: state.radioConfig.display,
lora = response.lora ?: state.radioConfig.lora,
bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
security = response.security ?: state.radioConfig.security,
),
)
}
incrementCompleted()
}
is RadioResponseResult.ModuleConfigResponse -> {
val response = result.config
_radioConfigState.update { state ->
state.copy(
moduleConfig =
state.moduleConfig.copy(
mqtt = response.mqtt ?: state.moduleConfig.mqtt,
serial = response.serial ?: state.moduleConfig.serial,
external_notification =
response.external_notification ?: state.moduleConfig.external_notification,
store_forward = response.store_forward ?: state.moduleConfig.store_forward,
range_test = response.range_test ?: state.moduleConfig.range_test,
telemetry = response.telemetry ?: state.moduleConfig.telemetry,
canned_message = response.canned_message ?: state.moduleConfig.canned_message,
audio = response.audio ?: state.moduleConfig.audio,
remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
traffic_management =
response.traffic_management ?: state.moduleConfig.traffic_management,
tak = response.tak ?: state.moduleConfig.tak,
),
)
}
incrementCompleted()
}
is RadioResponseResult.CannedMessages -> {
_radioConfigState.update { it.copy(cannedMessageMessages = result.messages) }
incrementCompleted()
}
is RadioResponseResult.Ringtone -> {
_radioConfigState.update { it.copy(ringtone = result.ringtone) }
incrementCompleted()
}
is RadioResponseResult.ConnectionStatus -> {
_radioConfigState.update { it.copy(deviceConnectionStatus = result.status) }
incrementCompleted()
}
}
// Routing ACKs (Success) share the same request_id as the upcoming ADMIN_APP response.
// Removing the id here would cause the actual admin response to be silently dropped,
// because processRadioResponseUseCase checks `request_id in requestIds`.
// The Success branch already handles its own id removal when route is empty (set flow).
if (result is RadioResponseResult.Success) return
if (AdminRoute.entries.any { it.name == route }) {
sendAdminRequest(destNum)
}
val requestId = packet.decoded?.request_id ?: return
requestIds.update { it.apply { remove(requestId) } }
if (requestIds.value.isEmpty()) {
if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
clearPacketResponse()
} else if (route.isEmpty()) {
setResponseStateSuccess()
}
}
}
}

View File

@@ -20,6 +20,7 @@ import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.answering.throws
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
@@ -28,10 +29,8 @@ import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
@@ -41,9 +40,8 @@ import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
import org.meshtastic.core.model.AdminException
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.FileService
@@ -55,9 +53,9 @@ import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
@@ -65,7 +63,6 @@ import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.User
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -93,11 +90,9 @@ class RadioConfigViewModelTest {
private val installProfileUseCase: InstallProfileUseCase = mock(MockMode.autofill)
private val radioConfigUseCase: RadioConfigUseCase = mock(MockMode.autofill)
private val adminActionsUseCase: AdminActionsUseCase = mock(MockMode.autofill)
private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mock(MockMode.autofill)
private val locationService: LocationService = mock(MockMode.autofill)
private val fileService: FileService = mock(MockMode.autofill)
private val mqttManager: MqttManager = mock(MockMode.autofill)
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
private lateinit var viewModel: RadioConfigViewModel
@@ -115,15 +110,12 @@ class RadioConfigViewModelTest {
every { analyticsPrefs.analyticsAllowed } returns MutableStateFlow(false)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { serviceRepository.connectionState } returns
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
every { mqttManager.mqttConnectionState } returns
MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.Inactive)
every { uiPrefs.showQuickChat } returns MutableStateFlow(false)
viewModel = createViewModel()
}
@@ -148,7 +140,6 @@ class RadioConfigViewModelTest {
installProfileUseCase = installProfileUseCase,
radioConfigUseCase = radioConfigUseCase,
adminActionsUseCase = adminActionsUseCase,
processRadioResponseUseCase = processRadioResponseUseCase,
locationService = locationService,
fileService = fileService,
mqttManager = mqttManager,
@@ -161,7 +152,7 @@ class RadioConfigViewModelTest {
viewModel = createViewModel()
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
everySuspend { radioConfigUseCase.setConfig(any(), any()) } returns 42
everySuspend { radioConfigUseCase.setConfig(any(), any()) } returns Unit
viewModel.setConfig(config)
@@ -192,29 +183,6 @@ class RadioConfigViewModelTest {
verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(true) }
}
@Test
fun `processPacketResponse updates state on metadata result`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
val packet = MeshPacket()
val metadata = DeviceMetadata(firmware_version = "3.0.0")
val packetFlow = MutableSharedFlow<MeshPacket>()
every { serviceRepository.meshPacketFlow } returns packetFlow
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Metadata(metadata)
viewModel = createViewModel()
packetFlow.emit(packet)
viewModel.radioConfigState.test {
val state = awaitItem()
assertEquals("3.0.0", state.metadata?.firmware_version)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `updateChannels calls useCase for each changed channel`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
@@ -224,7 +192,7 @@ class RadioConfigViewModelTest {
val old = listOf(ChannelSettings(name = "Old"))
val new = listOf(ChannelSettings(name = "New"))
everySuspend { radioConfigUseCase.setRemoteChannel(any(), any()) } returns 42
everySuspend { radioConfigUseCase.setRemoteChannel(any(), any()) } returns Unit
viewModel.updateChannels(new, old)
@@ -233,47 +201,81 @@ class RadioConfigViewModelTest {
}
@Test
fun `setResponseStateLoading for REBOOT calls useCase after config response`() = runTest {
fun `setResponseStateLoading for USER fetches owner directly`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
val packetFlow = MutableSharedFlow<MeshPacket>()
every { serviceRepository.meshPacketFlow } returns packetFlow
// AdminRoute first sends a session key config request; the admin action fires
// only after the actual ConfigResponse (not a routing ACK / Success).
every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.ConfigResponse(Config())
viewModel = createViewModel()
everySuspend { adminActionsUseCase.reboot(any()) } returns 42
val user = User(long_name = "Fetched User")
everySuspend { radioConfigUseCase.getOwner(any()) } returns user
viewModel.setResponseStateLoading(AdminRoute.REBOOT)
viewModel.setResponseStateLoading(ConfigRoute.USER)
runCurrent()
// Emit a config response packet to trigger processPacketResponse -> sendAdminRequest
packetFlow.emit(MeshPacket())
verifySuspend { adminActionsUseCase.reboot(123) }
assertEquals("Fetched User", viewModel.radioConfigState.value.userConfig.long_name)
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success)
}
@Test
fun `setResponseStateLoading for FACTORY_RESET calls useCase after config response`() = runTest {
fun `setResponseStateLoading for CHANNELS fetches channels and lora config`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
val packetFlow = MutableSharedFlow<MeshPacket>()
every { serviceRepository.meshPacketFlow } returns packetFlow
// AdminRoute first sends a session key config request; the admin action fires
// only after the actual ConfigResponse (not a routing ACK / Success).
every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.ConfigResponse(Config())
viewModel = createViewModel()
everySuspend { adminActionsUseCase.factoryReset(any(), any()) } returns 42
val channels = listOf(
Channel(index = 0, settings = ChannelSettings(name = "Primary")),
Channel(index = 1, settings = ChannelSettings(name = "Secondary")),
)
val loraConfig = Config(lora = Config.LoRaConfig(hop_limit = 5))
everySuspend { radioConfigUseCase.listChannels(any()) } returns channels
everySuspend { radioConfigUseCase.getConfig(any(), any()) } returns loraConfig
viewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
runCurrent()
assertEquals(2, viewModel.radioConfigState.value.channelList.size)
assertEquals("Primary", viewModel.radioConfigState.value.channelList[0].name)
assertEquals(5, viewModel.radioConfigState.value.radioConfig.lora?.hop_limit)
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success)
}
@Test
fun `setResponseStateLoading for REBOOT calls admin action directly`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel()
everySuspend { adminActionsUseCase.reboot(any()) } returns Unit
viewModel.setResponseStateLoading(AdminRoute.REBOOT)
runCurrent()
verifySuspend { adminActionsUseCase.reboot(123) }
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success)
}
@Test
fun `setResponseStateLoading for SHUTDOWN blocked when canShutdown is false`() = runTest {
val node = Node(num = 123, user = User(id = "!123"), metadata = DeviceMetadata(canShutdown = false))
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel()
viewModel.setResponseStateLoading(AdminRoute.SHUTDOWN)
runCurrent()
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Error)
}
@Test
fun `setResponseStateLoading for FACTORY_RESET calls admin action`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel()
everySuspend { adminActionsUseCase.factoryReset(any(), any()) } returns Unit
viewModel.setResponseStateLoading(AdminRoute.FACTORY_RESET)
// Emit a config response packet to trigger processPacketResponse -> sendAdminRequest
packetFlow.emit(MeshPacket())
runCurrent()
verifySuspend { adminActionsUseCase.factoryReset(123, any()) }
}
@@ -295,7 +297,7 @@ class RadioConfigViewModelTest {
viewModel = createViewModel()
val user = User(long_name = "Test User")
everySuspend { radioConfigUseCase.setOwner(any(), any()) } returns 42
everySuspend { radioConfigUseCase.setOwner(any(), any()) } returns Unit
viewModel.setOwner(user)
@@ -334,15 +336,12 @@ class RadioConfigViewModelTest {
fun `initDestNum updates value correctly including null`() = runTest {
viewModel = createViewModel()
// Initial setup should take the flow value, but let's just force update it
viewModel.initDestNum(123)
assertEquals(
123,
viewModel.destNode.value?.num ?: 123,
) // the flow combine might need yielding, but we can just check it doesn't crash
)
// The bug was that null was ignored. Here we test we can pass null
// Since we can't easily read destNumFlow directly, we can just call it to ensure no crashes
viewModel.initDestNum(null)
}
@@ -354,7 +353,7 @@ class RadioConfigViewModelTest {
val config =
org.meshtastic.proto.ModuleConfig(mqtt = org.meshtastic.proto.ModuleConfig.MQTTConfig(enabled = true))
everySuspend { radioConfigUseCase.setModuleConfig(any(), any()) } returns 42
everySuspend { radioConfigUseCase.setModuleConfig(any(), any()) } returns Unit
viewModel.setModuleConfig(config)
@@ -404,125 +403,32 @@ class RadioConfigViewModelTest {
}
@Test
fun `processPacketResponse updates state on various results`() = runTest {
fun `setResponseStateLoading shows error on AdminException timeout`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
val packetFlow = MutableSharedFlow<MeshPacket>()
every { serviceRepository.meshPacketFlow } returns packetFlow
viewModel = createViewModel()
// ConfigResponse
val configResponse = Config(lora = Config.LoRaConfig(hop_limit = 5))
every { processRadioResponseUseCase(any(), 123, any()) } returns
RadioResponseResult.ConfigResponse(configResponse)
packetFlow.emit(MeshPacket())
assertEquals(5, viewModel.radioConfigState.value.radioConfig.lora?.hop_limit)
everySuspend { radioConfigUseCase.getOwner(any()) } throws AdminException.Timeout()
// ModuleConfigResponse
val moduleResponse =
org.meshtastic.proto.ModuleConfig(
telemetry = org.meshtastic.proto.ModuleConfig.TelemetryConfig(device_update_interval = 300),
)
every { processRadioResponseUseCase(any(), 123, any()) } returns
RadioResponseResult.ModuleConfigResponse(moduleResponse)
packetFlow.emit(MeshPacket())
assertEquals(300, viewModel.radioConfigState.value.moduleConfig.telemetry?.device_update_interval)
viewModel.setResponseStateLoading(ConfigRoute.USER)
runCurrent()
// Owner
val user = User(long_name = "New Name")
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Owner(user)
packetFlow.emit(MeshPacket())
assertEquals("New Name", viewModel.radioConfigState.value.userConfig.long_name)
// Ringtone
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Ringtone("bell.mp3")
packetFlow.emit(MeshPacket())
assertEquals("bell.mp3", viewModel.radioConfigState.value.ringtone)
// Error
every { processRadioResponseUseCase(any(), 123, any()) } returns
RadioResponseResult.Error(org.meshtastic.core.resources.UiText.DynamicString("Fail"))
packetFlow.emit(MeshPacket())
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Error)
}
@Test
fun `Admin actions call correct useCases`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
val packetFlow = MutableSharedFlow<MeshPacket>()
every { serviceRepository.meshPacketFlow } returns packetFlow
viewModel = createViewModel()
// SHUTDOWN
everySuspend { adminActionsUseCase.shutdown(any()) } returns 42
// Set metadata to allow shutdown
every { processRadioResponseUseCase(any(), 123, any()) } returns
RadioResponseResult.Metadata(DeviceMetadata(canShutdown = true))
packetFlow.emit(MeshPacket())
viewModel.setResponseStateLoading(AdminRoute.SHUTDOWN)
// AdminRoute fires sendAdminRequest after receiving ConfigResponse (session key),
// not after a routing ACK (Success).
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.ConfigResponse(Config())
packetFlow.emit(MeshPacket())
verifySuspend { adminActionsUseCase.shutdown(123) }
// NODEDB_RESET
everySuspend { adminActionsUseCase.nodedbReset(any(), any(), any()) } returns 42
viewModel.setResponseStateLoading(AdminRoute.NODEDB_RESET)
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.ConfigResponse(Config())
packetFlow.emit(MeshPacket())
verifySuspend { adminActionsUseCase.nodedbReset(123, any(), any()) }
}
@Test
fun `setResponseStateLoading for various routes calls correct useCases`() = runTest {
fun `setResponseStateLoading for ConfigRoute fetches config`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel()
// USER
everySuspend { radioConfigUseCase.getOwner(any()) } returns 42
viewModel.setResponseStateLoading(ConfigRoute.USER)
verifySuspend { radioConfigUseCase.getOwner(123) }
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
everySuspend { radioConfigUseCase.getConfig(any(), any()) } returns config
// CHANNELS
everySuspend { radioConfigUseCase.getChannel(any(), any()) } returns 42
everySuspend { radioConfigUseCase.getConfig(any(), any()) } returns 42
viewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
verifySuspend { radioConfigUseCase.getChannel(123, 0) }
verifySuspend {
radioConfigUseCase.getConfig(123, org.meshtastic.proto.AdminMessage.ConfigType.LORA_CONFIG.value)
}
// LORA
viewModel.setResponseStateLoading(ConfigRoute.LORA)
verifySuspend { radioConfigUseCase.getConfig(123, ConfigRoute.LORA.type) }
}
@Test
fun `registerRequestId timeout clears request and sets error`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
nodeRepository.setNodes(listOf(node))
viewModel = createViewModel()
everySuspend { radioConfigUseCase.getOwner(any()) } returns 42
viewModel.setResponseStateLoading(ConfigRoute.USER)
// state should be loading
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Loading)
// advance time past 30 seconds
advanceTimeBy(31_000)
viewModel.setResponseStateLoading(ConfigRoute.DEVICE)
runCurrent()
// after timeout, the request ID should be removed, and if empty, sendError is called.
// It's hard to assert sendError directly without a mock on a channel, but we can verify it doesn't stay loading
// actually sendError updates the state? No, sendError sends an event.
// But the requestIds gets cleared.
assertEquals(Config.DeviceConfig.Role.ROUTER, viewModel.radioConfigState.value.radioConfig.device?.role)
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success)
}
}