diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index a3484428d..d40af8e52 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 af7fdfa29..528a196df 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 8d83f5aee..d29c6bb2f 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 b5466797d..493560503 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 5407e3953..8a93589ba 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 488c929e0..b4163a1e2 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 ae4045bd7..b085a9164 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 5ce42d269..49ce01b1f 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 1f781d285..e0f4a3e45 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 a1a503a67..e28433147 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 150f00d73..9eff2d36c 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"))