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) } }