From 140e062eee15e5b7070b3d1850f0b56c4eb6bd29 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 10:50:02 -0500 Subject: [PATCH] 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> --- .../core/data/radio/SdkRadioController.kt | 128 ++--- .../usecase/settings/AdminActionsUseCase.kt | 58 +-- .../settings/CleanNodeDatabaseUseCase.kt | 3 +- .../usecase/settings/InstallProfileUseCase.kt | 141 ++---- .../settings/ProcessRadioResponseUseCase.kt | 130 ----- .../usecase/settings/RadioConfigUseCase.kt | 187 +++----- .../settings/InstallProfileUseCaseTest.kt | 8 +- .../settings/RadioConfigUseCaseTest.kt | 4 +- .../meshtastic/core/model/AdminException.kt | 42 ++ .../org/meshtastic/core/model/DeviceAdmin.kt | 12 +- .../meshtastic/core/model/DeviceAdminEdit.kt | 36 ++ .../meshtastic/core/model/DeviceControl.kt | 18 +- .../org/meshtastic/core/model/RemoteAdmin.kt | 59 ++- .../core/testing/FakeRadioController.kt | 60 +-- .../firmware/ota/Esp32OtaUpdateHandler.kt | 2 +- .../node/detail/NodeManagementActions.kt | 5 +- .../node/detail/NodeManagementActionsTest.kt | 2 - .../settings/radio/RadioConfigViewModel.kt | 447 +++++------------- .../radio/RadioConfigViewModelTest.kt | 250 +++------- 19 files changed, 575 insertions(+), 1017 deletions(-) delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.kt diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index 7c05f9018..a022e8343 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -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 { 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 AdminResult.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) } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt index d6c48b14d..4b40d7f7d 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt @@ -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 } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt index 0ad5b4758..21d397b65 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt @@ -58,8 +58,7 @@ constructor( nodeRepository.deleteNodes(nodeNums) for (nodeNum in nodeNums) { - val packetId = radioController.getPacketId() - radioController.removeByNodenum(packetId, nodeNum) + radioController.removeByNodenum(nodeNum) } } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt index 2f6498133..5fb4ee7ab 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt @@ -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()) } - } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt deleted file mode 100644 index 755db4ca9..000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt +++ /dev/null @@ -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 . - */ -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): 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 - } - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt index 838617b2e..1e25ed135 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt @@ -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 = + 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) } diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt index 2c449344a..ad4138b22 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt @@ -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) } } diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt index 8d83f5aee..d252d1f36 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt @@ -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 diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt new file mode 100644 index 000000000..431989480 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt @@ -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 . + */ +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") +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt index c12979e21..c37d30d14 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt @@ -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) } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.kt new file mode 100644 index 000000000..88644fef6 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.kt @@ -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 . + */ +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) +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt index 8cd849205..7275e14f0 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt @@ -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) } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt index 0773e4da4..a743b9a94 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt @@ -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 + + /** 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 } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 515beeab2..d891284e3 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -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? = 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 = 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 diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index 6342aa5fc..94b6e39ba 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -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) } /** diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index 1c48540f1..5eef80e91 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -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) } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 3233747da..bcc451b07 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -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")) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index f9258deb1..3768eaba8 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -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 get() = _destNode - private val requestIds = MutableStateFlow(hashSetOf()) private val _radioConfigState = MutableStateFlow(RadioConfigState()) val radioConfigState: StateFlow = _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() - } - } - } } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index affa6edf3..213fda4f9 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -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() - - 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() - 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() - 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() - 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() - 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) } }