From ae16d11109c8c50f373eaa28a2c7f24e5a205ee8 Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 12 Jun 2026 07:20:45 -0500 Subject: [PATCH 1/3] feat(settings): implement set_ham_mode HamParameters admin message When the licensed amateur radio toggle is on for the locally connected node, the User config screen repurposes the long-name field as the callsign (max 8 chars, iOS parity) and saving sends AdminMessage(set_ham_mode) instead of set_owner. Current LoRa tx_power/override_frequency are echoed into the HamParameters so a re-send while already licensed never wipes the node's overrides (firmware applies them verbatim). The node entry is optimistically updated pending the device's authoritative NodeInfo. Closes #5759 Co-Authored-By: Claude Fable 5 --- .skills/compose-ui/strings-index.txt | 1 + .../usecase/settings/RadioConfigUseCase.kt | 15 ++++ .../settings/RadioConfigUseCaseTest.kt | 7 ++ .../core/repository/AdminController.kt | 12 ++++ .../composeResources/values/strings.xml | 1 + .../core/service/AdminControllerImpl.kt | 19 ++++++ .../core/service/RadioControllerImplTest.kt | 38 +++++++++++ .../core/testing/FakeRadioController.kt | 3 + .../settings/radio/RadioConfigViewModel.kt | 25 +++++++ .../radio/component/UserConfigItemList.kt | 30 ++++++-- .../radio/RadioConfigViewModelTest.kt | 68 +++++++++++++++++++ 11 files changed, 214 insertions(+), 5 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index a3484428d1..d40af8e529 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -122,6 +122,7 @@ broadcast_interval button_gpio buzzer_gpio calculating +call_sign cancel cancel_reply canned_message 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 af7fdfa295..528a196df1 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 @@ -20,6 +20,7 @@ import org.koin.core.annotation.Single import org.meshtastic.core.model.Position import org.meshtastic.core.repository.RadioController import org.meshtastic.proto.Config +import org.meshtastic.proto.HamParameters import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.User @@ -40,6 +41,20 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont return packetId } + /** + * Enables amateur-radio (ham) mode on the locally connected node via `set_ham_mode`. At protobufs 2.7.25 only + * `call_sign` and `short_name` are user-supplied; `long_name` joins when meshtastic/protobufs#941 ships. + * + * @param destNum The node number to update (must be the local node). + * @param hamParameters The ham onboarding parameters. + * @return The packet ID of the request. + */ + open suspend fun setHamMode(destNum: Int, hamParameters: HamParameters): Int { + val packetId = radioController.generatePacketId() + radioController.setHamMode(destNum, hamParameters, packetId) + return packetId + } + /** * Requests the owner information from the radio. * 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 8d83f5aee9..d29c6bb2fb 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 @@ -20,6 +20,7 @@ import kotlinx.coroutines.test.runTest import org.meshtastic.core.model.Position import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.proto.Config +import org.meshtastic.proto.HamParameters import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.User import kotlin.test.BeforeTest @@ -45,6 +46,12 @@ class RadioConfigUseCaseTest { // FakeRadioController already has getPacketId returning 1. } + @Test + fun `setHamMode calls radioController and returns packetId`() = runTest { + val packetId = useCase.setHamMode(1234, HamParameters(call_sign = "KK7ABC", short_name = "KK7A")) + assertEquals(1, packetId) + } + @Test fun `getOwner calls radioController`() = runTest { val packetId = useCase.getOwner(1234) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt index b5466797df..4935605032 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.repository import org.meshtastic.core.model.Position import org.meshtastic.proto.Channel import org.meshtastic.proto.Config +import org.meshtastic.proto.HamParameters import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.User @@ -52,6 +53,17 @@ interface AdminController { /** Updates the owner (user info) on a remote node. */ suspend fun setOwner(destNum: Int, user: User, packetId: Int) + /** + * Enables amateur-radio (ham) mode on a node via `AdminMessage.set_ham_mode`. + * + * Must only target the locally connected node — firmware ham onboarding is a local operation and the UI gates it + * accordingly. The firmware handler rewrites the owner (long_name = call_sign), flips `is_licensed`, disables + * encryption, applies [HamParameters.tx_power]/[HamParameters.frequency] to the LoRa config verbatim, and reboots, + * so callers must echo the node's current LoRa values rather than send defaults. Intentionally absent from + * [AdminEditScope]: ham enablement is not a batch-edit operation. + */ + suspend fun setHamMode(destNum: Int, hamParameters: HamParameters, packetId: Int) + /** Updates the general configuration on a remote node. */ suspend fun setConfig(destNum: Int, config: Config, packetId: Int) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 5407e39537..8a93589ba5 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -140,6 +140,7 @@ Button GPIO Buzzer GPIO Calculating… + Call sign Cancel Cancel reply Canned Message diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt index 488c929e0a..b4163a1e2a 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.handledLaunch @@ -30,6 +31,7 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config +import org.meshtastic.proto.HamParameters import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.OTAMode import org.meshtastic.proto.User @@ -65,6 +67,23 @@ internal class AdminControllerImpl( commandSender.sendAdmin(destNum, packetId, wantResponse = true) { AdminMessage(get_owner_request = true) } } + override suspend fun setHamMode(destNum: Int, hamParameters: HamParameters, packetId: Int) { + // Firmware applies tx_power/frequency to the LoRa config verbatim, so echo the node's current + // values to keep a re-send (e.g. a callsign edit while already licensed) from wiping overrides. + val lora = radioConfigRepository.localConfigFlow.firstOrNull()?.lora + val params = hamParameters.copy(tx_power = lora?.tx_power ?: 0, frequency = lora?.override_frequency ?: 0f) + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_ham_mode = params) } + val currentUser = nodeManager.nodeDBbyNodeNum[destNum]?.user ?: User() + nodeManager.handleReceivedUser( + destNum, + currentUser.copy( + long_name = hamParameters.call_sign, + short_name = hamParameters.short_name, + is_licensed = true, + ), + ) + } + // ── Configuration ───────────────────────────────────────────────────────── override suspend fun setLocalConfig(config: Config) { diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt index ae4045bd7f..b085a91640 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.service import dev.mokkery.MockMode +import dev.mokkery.answering.calls import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.everySuspend @@ -49,7 +50,11 @@ import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Config +import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import kotlin.test.Test @@ -356,6 +361,39 @@ class RadioControllerImplTest { verify { nodeManager.handleReceivedUser(42, any(), any(), true) } } + @Test + fun setHamModeSendsAdminWithEchoedLoraValuesAndUpdatesUser() = runTest { + val controller = createController() + val existingUser = User(id = "!0000007b", long_name = "Old Name", short_name = "OLD") + every { nodeManager.nodeDBbyNodeNum } returns mapOf(123 to Node(num = 123, user = existingUser)) + every { radioConfigRepository.localConfigFlow } returns + MutableStateFlow(LocalConfig(lora = Config.LoRaConfig(tx_power = 20, override_frequency = 915.5f))) + + var sentMessage: AdminMessage? = null + everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } calls + { + @Suppress("UNCHECKED_CAST") + sentMessage = (it.args[3] as () -> AdminMessage)() + } + + controller.setHamMode(123, HamParameters(call_sign = "KK7ABC", short_name = "KK7A"), 42) + + val ham = sentMessage?.set_ham_mode + assertEquals("KK7ABC", ham?.call_sign) + assertEquals("KK7A", ham?.short_name) + // Current LoRa values are echoed so a re-send never wipes the node's overrides. + assertEquals(20, ham?.tx_power) + assertEquals(915.5f, ham?.frequency) + verify { + nodeManager.handleReceivedUser( + 123, + existingUser.copy(long_name = "KK7ABC", short_name = "KK7A", is_licensed = true), + 0, + false, + ) + } + } + @Test fun importContactReturnsEarlyWhenDisconnected() = runTest { val controller = createController(myNodeNum = null) 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 5ce42d269e..49ce01b1f0 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 @@ -25,6 +25,7 @@ import org.meshtastic.core.repository.RadioController import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Config +import org.meshtastic.proto.HamParameters import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.User @@ -99,6 +100,8 @@ class FakeRadioController : override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {} + override suspend fun setHamMode(destNum: Int, hamParameters: HamParameters, packetId: Int) {} + override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {} override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {} 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 1f781d2854..e0f4a3e450 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 @@ -80,6 +80,7 @@ import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceProfile import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.FileInfo +import org.meshtastic.proto.HamParameters import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig @@ -290,6 +291,30 @@ open class RadioConfigViewModel( Logger.d { "RadioConfigViewModel cleared" } } + /** + * Routes the User config save: ham onboarding (`set_ham_mode`) when the licensed toggle is on and the target is the + * locally connected node, [setOwner] otherwise. The local-node guard is the backstop for the UI gate — + * `set_ham_mode` must never be sent to a remote node. + */ + fun saveUserConfig(user: User) { + val destNum = destNum ?: destNode.value?.num ?: return + if (user.is_licensed && destNum == myNodeNum) setHamMode(destNum, user) else setOwner(user) + } + + private fun setHamMode(destNum: Int, user: User) { + safeLaunch(tag = "setHamMode") { + _radioConfigState.update { it.copy(userConfig = user) } + // The form's long-name field carries the callsign while licensed (iOS parity). + // When meshtastic/protobufs#941 ships, add long_name here. + val packetId = + radioConfigUseCase.setHamMode( + destNum, + HamParameters(call_sign = user.long_name, short_name = user.short_name), + ) + registerRequestId(packetId) + } + } + fun setOwner(user: User) { val destNum = destNum ?: destNode.value?.num ?: return safeLaunch(tag = "setOwner") { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt index a1a503a674..e284331479 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt @@ -31,6 +31,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.isUnmessageableRole import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.call_sign import org.meshtastic.core.resources.hardware_model import org.meshtastic.core.resources.licensed_amateur_radio import org.meshtastic.core.resources.licensed_amateur_radio_text @@ -47,6 +48,9 @@ import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel +private const val LONG_NAME_MAX_LENGTH = 39 // long_name max_size:40 +private const val CALL_SIGN_MAX_LENGTH = 8 // iOS parity; firmware sets long_name from the callsign + @Composable fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() @@ -55,7 +59,10 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val firmwareVersion = state.metadata?.firmware_version val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) } - val validLongName = formState.value.long_name.isNotBlank() + // Ham onboarding repurposes the long-name field as the callsign, for the local node only (iOS parity). + val hamMode = formState.value.is_licensed && state.isLocal + val longNameMax = if (hamMode) CALL_SIGN_MAX_LENGTH else LONG_NAME_MAX_LENGTH + val validLongName = formState.value.long_name.isNotBlank() && formState.value.long_name.length <= longNameMax val validShortName = formState.value.short_name.isNotBlank() val validNames = validLongName && validShortName val focusManager = LocalFocusManager.current @@ -67,7 +74,7 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { enabled = state.connected && validNames, responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, - onSave = viewModel::setOwner, + onSave = viewModel::saveUserConfig, ) { item { TitledCard(title = stringResource(Res.string.user_config)) { @@ -78,9 +85,9 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { ) HorizontalDivider() EditTextPreference( - title = stringResource(Res.string.long_name), + title = stringResource(if (hamMode) Res.string.call_sign else Res.string.long_name), value = formState.value.long_name, - maxSize = 39, // long_name max_size:40 + maxSize = longNameMax, enabled = state.connected, isError = !validLongName, keyboardOptions = @@ -123,7 +130,20 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { summary = stringResource(Res.string.licensed_amateur_radio_text), checked = formState.value.is_licensed, enabled = state.connected, - onCheckedChange = { formState.value = formState.value.copy(is_licensed = it) }, + onCheckedChange = { licensed -> + val longName = formState.value.long_name + formState.value = + formState.value.copy( + is_licensed = licensed, + // The field becomes the callsign: clear an over-long name so the user enters one. + long_name = + if (licensed && state.isLocal && longName.length > CALL_SIGN_MAX_LENGTH) { + "" + } else { + longName + }, + ) + }, containerColor = CardDefaults.cardColors().containerColor, ) } 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 150f00d73f..9eff2d36c5 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 @@ -25,6 +25,7 @@ import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -66,6 +67,7 @@ import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.HamParameters import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig import org.meshtastic.proto.MeshPacket @@ -370,6 +372,72 @@ class RadioConfigViewModelTest { verifySuspend { radioConfigUseCase.setOwner(123, user) } } + @Test + fun `saveUserConfig sends setHamMode for licensed local node`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + nodeRepository.setNodes(listOf(node)) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 123)) + viewModel = createViewModel() + + val user = User(long_name = "KK7ABC", short_name = "KK7A", is_licensed = true) + everySuspend { radioConfigUseCase.setHamMode(any(), any()) } returns 42 + + viewModel.saveUserConfig(user) + + verifySuspend { radioConfigUseCase.setHamMode(123, HamParameters(call_sign = "KK7ABC", short_name = "KK7A")) } + verifySuspend(exactly(0)) { radioConfigUseCase.setOwner(any(), any()) } + } + + @Test + fun `saveUserConfig sends setOwner for unlicensed user`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + nodeRepository.setNodes(listOf(node)) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 123)) + viewModel = createViewModel() + + val user = User(long_name = "Test User", short_name = "TU") + everySuspend { radioConfigUseCase.setOwner(any(), any()) } returns 42 + + viewModel.saveUserConfig(user) + + verifySuspend { radioConfigUseCase.setOwner(123, user) } + verifySuspend(exactly(0)) { radioConfigUseCase.setHamMode(any(), any()) } + } + + @Test + fun `saveUserConfig never sends setHamMode to a remote node`() = runTest { + val localNode = Node(num = 100, user = User(id = "!100")) + val remoteNode = Node(num = 456, user = User(id = "!456")) + nodeRepository.setNodes(listOf(localNode, remoteNode)) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100)) + viewModel = createViewModel(destNum = 456) + + val user = User(long_name = "KK7ABC", short_name = "KK7A", is_licensed = true) + everySuspend { radioConfigUseCase.setOwner(any(), any()) } returns 42 + + viewModel.saveUserConfig(user) + + verifySuspend { radioConfigUseCase.setOwner(456, user) } + verifySuspend(exactly(0)) { radioConfigUseCase.setHamMode(any(), any()) } + } + + private fun myNodeInfo(myNodeNum: Int) = MyNodeInfo( + myNodeNum = myNodeNum, + hasGPS = false, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 8, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, + ) + @Test fun `setRingtone calls useCase`() = runTest { val node = Node(num = 123, user = User(id = "!123")) From 1cda00ff42d806d88f2f3279bc6e31591b6e600e Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 12 Jun 2026 08:49:08 -0500 Subject: [PATCH 2/3] fix: apply review auto-fixes (docs, idioms, test coverage, dedup) - Align AdminController.setHamMode KDoc with the implementation: the impl echoes the local LoRa values itself; caller-supplied tx_power/frequency are ignored (F-2, F-15) - Fall back to the default LoRaConfig message instead of hand-coded proto defaults (F-9) - Cover the lora-absent/unknown-node fallback branches and the null-myNodeInfo routing guard with tests (F-10, F-11) - Hoist the clear-on-toggle condition into a named boolean (F-13) - Deduplicate MyNodeInfo test fixtures into one helper (F-8) - Concrete wording for the protobufs#941 extension note (F-16) Co-Authored-By: Claude Fable 5 --- .../usecase/settings/RadioConfigUseCase.kt | 2 +- .../core/repository/AdminController.kt | 9 +- .../core/service/AdminControllerImpl.kt | 4 +- .../core/service/RadioControllerImplTest.kt | 29 +++++ .../radio/component/UserConfigItemList.kt | 10 +- .../radio/RadioConfigViewModelTest.kt | 104 ++++++------------ 6 files changed, 74 insertions(+), 84 deletions(-) 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 528a196df1..9e75f2f0eb 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 @@ -43,7 +43,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont /** * Enables amateur-radio (ham) mode on the locally connected node via `set_ham_mode`. At protobufs 2.7.25 only - * `call_sign` and `short_name` are user-supplied; `long_name` joins when meshtastic/protobufs#941 ships. + * `call_sign` and `short_name` are user-supplied; `long_name` becomes settable when meshtastic/protobufs#941 ships. * * @param destNum The node number to update (must be the local node). * @param hamParameters The ham onboarding parameters. diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt index 4935605032..b146ceaff3 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt @@ -56,11 +56,12 @@ interface AdminController { /** * Enables amateur-radio (ham) mode on a node via `AdminMessage.set_ham_mode`. * - * Must only target the locally connected node — firmware ham onboarding is a local operation and the UI gates it + * Must target only the locally connected node — firmware ham onboarding is a local operation and the UI gates it * accordingly. The firmware handler rewrites the owner (long_name = call_sign), flips `is_licensed`, disables - * encryption, applies [HamParameters.tx_power]/[HamParameters.frequency] to the LoRa config verbatim, and reboots, - * so callers must echo the node's current LoRa values rather than send defaults. Intentionally absent from - * [AdminEditScope]: ham enablement is not a batch-edit operation. + * encryption, applies [HamParameters.tx_power]/[HamParameters.frequency] to the LoRa config verbatim, and reboots. + * The implementation echoes the local node's current LoRa values into those two fields so a re-send never wipes the + * node's overrides; caller-supplied [HamParameters.tx_power]/[HamParameters.frequency] are ignored. Intentionally + * absent from [AdminEditScope]: ham enablement is not a batch-edit operation. */ suspend fun setHamMode(destNum: Int, hamParameters: HamParameters, packetId: Int) diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt index b4163a1e2a..81c8b69237 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt @@ -70,8 +70,8 @@ internal class AdminControllerImpl( override suspend fun setHamMode(destNum: Int, hamParameters: HamParameters, packetId: Int) { // Firmware applies tx_power/frequency to the LoRa config verbatim, so echo the node's current // values to keep a re-send (e.g. a callsign edit while already licensed) from wiping overrides. - val lora = radioConfigRepository.localConfigFlow.firstOrNull()?.lora - val params = hamParameters.copy(tx_power = lora?.tx_power ?: 0, frequency = lora?.override_frequency ?: 0f) + val lora = radioConfigRepository.localConfigFlow.firstOrNull()?.lora ?: Config.LoRaConfig() + val params = hamParameters.copy(tx_power = lora.tx_power, frequency = lora.override_frequency) commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_ham_mode = params) } val currentUser = nodeManager.nodeDBbyNodeNum[destNum]?.user ?: User() nodeManager.handleReceivedUser( diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt index b085a91640..16e2c6c521 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt @@ -394,6 +394,35 @@ class RadioControllerImplTest { } } + @Test + fun setHamModeWithNoCachedLoraConfigSendsProtoDefaults() = runTest { + val controller = createController() + every { nodeManager.nodeDBbyNodeNum } returns emptyMap() + every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig()) + + var sentMessage: AdminMessage? = null + everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } calls + { + @Suppress("UNCHECKED_CAST") + sentMessage = (it.args[3] as () -> AdminMessage)() + } + + controller.setHamMode(123, HamParameters(call_sign = "KK7ABC", short_name = "KK7A"), 42) + + val ham = sentMessage?.set_ham_mode + assertEquals(0, ham?.tx_power) + assertEquals(0f, ham?.frequency) + // Unknown node: the optimistic update is built on a default User. + verify { + nodeManager.handleReceivedUser( + 123, + User(long_name = "KK7ABC", short_name = "KK7A", is_licensed = true), + 0, + false, + ) + } + } + @Test fun importContactReturnsEarlyWhenDisconnected() = runTest { val controller = createController(myNodeNum = null) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt index e284331479..904355c6bd 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt @@ -132,16 +132,12 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { enabled = state.connected, onCheckedChange = { licensed -> val longName = formState.value.long_name + // The field becomes the callsign: clear an over-long name so the user enters one. + val clearForCallsign = licensed && state.isLocal && longName.length > CALL_SIGN_MAX_LENGTH formState.value = formState.value.copy( is_licensed = licensed, - // The field becomes the callsign: clear an over-long name so the user enters one. - long_name = - if (licensed && state.isLocal && longName.length > CALL_SIGN_MAX_LENGTH) { - "" - } else { - longName - }, + long_name = if (clearForCallsign) "" else longName, ) }, containerColor = CardDefaults.cardColors().containerColor, 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 9eff2d36c5..4a8f3288a9 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 @@ -421,22 +421,20 @@ class RadioConfigViewModelTest { verifySuspend(exactly(0)) { radioConfigUseCase.setHamMode(any(), any()) } } - private fun myNodeInfo(myNodeNum: Int) = MyNodeInfo( - myNodeNum = myNodeNum, - hasGPS = false, - model = null, - firmwareVersion = null, - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 0, - messageTimeoutMsec = 0, - minAppVersion = 0, - maxChannels = 8, - hasWifi = false, - channelUtilization = 0f, - airUtilTx = 0f, - deviceId = null, - ) + @Test + fun `saveUserConfig routes licensed save to setOwner when myNodeInfo is absent`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + nodeRepository.setNodes(listOf(node)) + viewModel = createViewModel() + + val user = User(long_name = "KK7ABC", short_name = "KK7A", is_licensed = true) + everySuspend { radioConfigUseCase.setOwner(any(), any()) } returns 42 + + viewModel.saveUserConfig(user) + + verifySuspend { radioConfigUseCase.setOwner(123, user) } + verifySuspend(exactly(0)) { radioConfigUseCase.setHamMode(any(), any()) } + } @Test fun `setRingtone calls useCase`() = runTest { @@ -680,24 +678,7 @@ class RadioConfigViewModelTest { val localNode = Node(num = 100, user = User(id = "!100")) val remoteNode = Node(num = 456, user = User(id = "!456")) nodeRepository.setNodes(listOf(localNode, remoteNode)) - nodeRepository.setMyNodeInfo( - MyNodeInfo( - myNodeNum = 100, - hasGPS = false, - model = null, - firmwareVersion = null, - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 0, - messageTimeoutMsec = 0, - minAppVersion = 0, - maxChannels = 8, - hasWifi = false, - channelUtilization = 0f, - airUtilTx = 0f, - deviceId = null, - ), - ) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100)) val remoteVm = createViewModel(destNum = 456) @@ -714,24 +695,7 @@ class RadioConfigViewModelTest { fun `ensureLoadingForRemote is no-op for local nodes`() = runTest { val localNode = Node(num = 100, user = User(id = "!100")) nodeRepository.setNodes(listOf(localNode)) - nodeRepository.setMyNodeInfo( - MyNodeInfo( - myNodeNum = 100, - hasGPS = false, - model = null, - firmwareVersion = null, - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 0, - messageTimeoutMsec = 0, - minAppVersion = 0, - maxChannels = 8, - hasWifi = false, - channelUtilization = 0f, - airUtilTx = 0f, - deviceId = null, - ), - ) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100)) val localVm = createViewModel(destNum = 100) @@ -748,24 +712,7 @@ class RadioConfigViewModelTest { val localNode = Node(num = 100, user = User(id = "!100")) val remoteNode = Node(num = 456, user = User(id = "!456")) nodeRepository.setNodes(listOf(localNode, remoteNode)) - nodeRepository.setMyNodeInfo( - MyNodeInfo( - myNodeNum = 100, - hasGPS = false, - model = null, - firmwareVersion = null, - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 0, - messageTimeoutMsec = 0, - minAppVersion = 0, - maxChannels = 8, - hasWifi = false, - channelUtilization = 0f, - airUtilTx = 0f, - deviceId = null, - ), - ) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100)) val remoteVm = createViewModel(destNum = 456) @@ -777,4 +724,21 @@ class RadioConfigViewModelTest { remoteVm.ensureLoadingForRemote() assertTrue(remoteVm.radioConfigState.value.responseState is ResponseState.Loading) } + + private fun myNodeInfo(myNodeNum: Int) = MyNodeInfo( + myNodeNum = myNodeNum, + hasGPS = false, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 8, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, + ) } From 5a189a288ddb036a5baebb8636d5bdcff71ddcb5 Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 12 Jun 2026 09:19:59 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20review=20triage=20?= =?UTF-8?q?=E2=80=94=20transition-based=20ham=20routing,=20local-only=20gu?= =?UTF-8?q?ard,=20callsign=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route to set_ham_mode only when the licensed toggle transitions OFF→ON; subsequent saves of an already-licensed node use set_owner so other owner edits still propagate and the node doesn't reboot on every save (F-1) - Enforce the local-node-only contract in AdminControllerImpl, not just the ViewModel: setHamMode ignores remote destinations (F-3) - Show 'Your amateur radio call sign, up to 8 characters' as the field's supporting text in ham mode, giving sighted and screen-reader users the constraint (F-4, F-6) - Document that saveUserConfig is the preferred entry point over setOwner (F-7) Co-Authored-By: Claude Fable 5 --- .skills/compose-ui/strings-index.txt | 1 + .../core/repository/AdminController.kt | 12 +++++------ .../composeResources/values/strings.xml | 1 + .../core/service/AdminControllerImpl.kt | 4 ++++ .../core/service/RadioControllerImplTest.kt | 14 +++++++++++-- .../settings/radio/RadioConfigViewModel.kt | 15 +++++++++---- .../radio/component/UserConfigItemList.kt | 2 ++ .../radio/RadioConfigViewModelTest.kt | 21 +++++++++++++++++++ 8 files changed, 58 insertions(+), 12 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index d40af8e529..73cc216ed7 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -123,6 +123,7 @@ button_gpio buzzer_gpio calculating call_sign +call_sign_summary cancel cancel_reply canned_message diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt index b146ceaff3..72d332634a 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt @@ -56,12 +56,12 @@ interface AdminController { /** * Enables amateur-radio (ham) mode on a node via `AdminMessage.set_ham_mode`. * - * Must target only the locally connected node — firmware ham onboarding is a local operation and the UI gates it - * accordingly. The firmware handler rewrites the owner (long_name = call_sign), flips `is_licensed`, disables - * encryption, applies [HamParameters.tx_power]/[HamParameters.frequency] to the LoRa config verbatim, and reboots. - * The implementation echoes the local node's current LoRa values into those two fields so a re-send never wipes the - * node's overrides; caller-supplied [HamParameters.tx_power]/[HamParameters.frequency] are ignored. Intentionally - * absent from [AdminEditScope]: ham enablement is not a batch-edit operation. + * Must target only the locally connected node — firmware ham onboarding is a local operation; the implementation + * ignores requests for any other node. The firmware handler rewrites the owner (long_name = call_sign), flips + * `is_licensed`, disables encryption, applies [HamParameters.tx_power]/[HamParameters.frequency] to the LoRa config + * verbatim, and reboots. The implementation echoes the local node's current LoRa values into those two fields so a + * re-send never wipes the node's overrides; caller-supplied [HamParameters.tx_power]/[HamParameters.frequency] are + * ignored. Intentionally absent from [AdminEditScope]: ham enablement is not a batch-edit operation. */ suspend fun setHamMode(destNum: Int, hamParameters: HamParameters, packetId: Int) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 8a93589ba5..5bd0d50655 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -141,6 +141,7 @@ Buzzer GPIO Calculating… Call sign + Your amateur radio call sign, up to 8 characters Cancel Cancel reply Canned Message diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt index 81c8b69237..9f04737335 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt @@ -68,6 +68,10 @@ internal class AdminControllerImpl( } override suspend fun setHamMode(destNum: Int, hamParameters: HamParameters, packetId: Int) { + if (destNum != nodeManager.myNodeNum.value) { + Logger.w { "Ignoring setHamMode for node $destNum — ham onboarding targets the local node only" } + return + } // Firmware applies tx_power/frequency to the LoRa config verbatim, so echo the node's current // values to keep a re-send (e.g. a callsign edit while already licensed) from wiping overrides. val lora = radioConfigRepository.localConfigFlow.firstOrNull()?.lora ?: Config.LoRaConfig() diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt index 16e2c6c521..bd7a868e13 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt @@ -363,7 +363,7 @@ class RadioControllerImplTest { @Test fun setHamModeSendsAdminWithEchoedLoraValuesAndUpdatesUser() = runTest { - val controller = createController() + val controller = createController(myNodeNum = 123) val existingUser = User(id = "!0000007b", long_name = "Old Name", short_name = "OLD") every { nodeManager.nodeDBbyNodeNum } returns mapOf(123 to Node(num = 123, user = existingUser)) every { radioConfigRepository.localConfigFlow } returns @@ -396,7 +396,7 @@ class RadioControllerImplTest { @Test fun setHamModeWithNoCachedLoraConfigSendsProtoDefaults() = runTest { - val controller = createController() + val controller = createController(myNodeNum = 123) every { nodeManager.nodeDBbyNodeNum } returns emptyMap() every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig()) @@ -423,6 +423,16 @@ class RadioControllerImplTest { } } + @Test + fun setHamModeIgnoresRemoteDestinations() = runTest { + val controller = createController(myNodeNum = 123) + + controller.setHamMode(456, HamParameters(call_sign = "KK7ABC", short_name = "KK7A"), 42) + + verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } + verify(exactly(0)) { nodeManager.handleReceivedUser(any(), any(), any(), any()) } + } + @Test fun importContactReturnsEarlyWhenDisconnected() = runTest { val controller = createController(myNodeNum = null) 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 e0f4a3e450..bc5c6d1901 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 @@ -292,13 +292,16 @@ open class RadioConfigViewModel( } /** - * Routes the User config save: ham onboarding (`set_ham_mode`) when the licensed toggle is on and the target is the - * locally connected node, [setOwner] otherwise. The local-node guard is the backstop for the UI gate — - * `set_ham_mode` must never be sent to a remote node. + * Routes the User config save: ham onboarding (`set_ham_mode`) when the licensed toggle transitions OFF→ON on the + * locally connected node, [setOwner] otherwise. Routing on the transition — not the toggle state — keeps subsequent + * saves of an already-licensed node on the `set_owner` path, so edits to other owner fields still reach the device + * and the node doesn't reboot on every save (firmware reboots on `set_ham_mode`). The local-node guard is the + * backstop for the UI gate — `set_ham_mode` must never be sent to a remote node. */ fun saveUserConfig(user: User) { val destNum = destNum ?: destNode.value?.num ?: return - if (user.is_licensed && destNum == myNodeNum) setHamMode(destNum, user) else setOwner(user) + val enablingHam = user.is_licensed && !radioConfigState.value.userConfig.is_licensed + if (enablingHam && destNum == myNodeNum) setHamMode(destNum, user) else setOwner(user) } private fun setHamMode(destNum: Int, user: User) { @@ -315,6 +318,10 @@ open class RadioConfigViewModel( } } + /** + * Sends a plain `set_owner` with [user]. Prefer [saveUserConfig] for User config screen saves — it routes ham + * onboarding to `set_ham_mode` when the licensed toggle is first enabled; calling this directly bypasses that. + */ fun setOwner(user: User) { val destNum = destNum ?: destNode.value?.num ?: return safeLaunch(tag = "setOwner") { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt index 904355c6bd..d9a3843815 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt @@ -32,6 +32,7 @@ import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.isUnmessageableRole import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.call_sign +import org.meshtastic.core.resources.call_sign_summary import org.meshtastic.core.resources.hardware_model import org.meshtastic.core.resources.licensed_amateur_radio import org.meshtastic.core.resources.licensed_amateur_radio_text @@ -87,6 +88,7 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { EditTextPreference( title = stringResource(if (hamMode) Res.string.call_sign else Res.string.long_name), value = formState.value.long_name, + summary = if (hamMode) stringResource(Res.string.call_sign_summary) else null, maxSize = longNameMax, enabled = state.connected, isError = !validLongName, 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 4a8f3288a9..76b3a2fbe1 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 @@ -421,6 +421,27 @@ class RadioConfigViewModelTest { verifySuspend(exactly(0)) { radioConfigUseCase.setHamMode(any(), any()) } } + @Test + fun `saveUserConfig routes subsequent licensed saves to setOwner`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + nodeRepository.setNodes(listOf(node)) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 123)) + viewModel = createViewModel() + + val user = User(long_name = "KK7ABC", short_name = "KK7A", is_licensed = true) + everySuspend { radioConfigUseCase.setHamMode(any(), any()) } returns 42 + everySuspend { radioConfigUseCase.setOwner(any(), any()) } returns 43 + + // First save transitions OFF→ON and onboards via set_ham_mode. + viewModel.saveUserConfig(user) + // A later save while already licensed must use set_owner so other owner fields propagate. + val edited = user.copy(short_name = "KK7B") + viewModel.saveUserConfig(edited) + + verifySuspend(exactly(1)) { radioConfigUseCase.setHamMode(any(), any()) } + verifySuspend { radioConfigUseCase.setOwner(123, edited) } + } + @Test fun `saveUserConfig routes licensed save to setOwner when myNodeInfo is absent`() = runTest { val node = Node(num = 123, user = User(id = "!123"))