Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` 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.
* @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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -52,6 +53,18 @@ 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 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)

/** Updates the general configuration on a remote node. */
suspend fun setConfig(destNum: Int, config: Config, packetId: Int)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@
<string name="button_gpio">Button GPIO</string>
<string name="buzzer_gpio">Buzzer GPIO</string>
<string name="calculating">Calculating…</string>
<string name="call_sign">Call sign</string>
<string name="call_sign_summary">Your amateur radio call sign, up to 8 characters</string>
<string name="cancel">Cancel</string>
<string name="cancel_reply">Cancel reply</string>
<string name="canned_message">Canned Message</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -65,6 +67,27 @@ internal class AdminControllerImpl(
commandSender.sendAdmin(destNum, packetId, wantResponse = true) { AdminMessage(get_owner_request = true) }
}

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()
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(
destNum,
currentUser.copy(
long_name = hamParameters.call_sign,
short_name = hamParameters.short_name,
is_licensed = true,
),
)
}

// ── Configuration ─────────────────────────────────────────────────────────

override suspend fun setLocalConfig(config: Config) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -356,6 +361,78 @@ class RadioControllerImplTest {
verify { nodeManager.handleReceivedUser(42, any(), any(), true) }
}

@Test
fun setHamModeSendsAdminWithEchoedLoraValuesAndUpdatesUser() = runTest {
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
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 setHamModeWithNoCachedLoraConfigSendsProtoDefaults() = runTest {
val controller = createController(myNodeNum = 123)
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 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -290,6 +291,37 @@ open class RadioConfigViewModel(
Logger.d { "RadioConfigViewModel cleared" }
}

/**
* 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
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) {
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)
}
}

/**
* 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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ 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.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
Expand All @@ -47,6 +49,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()
Expand All @@ -55,7 +60,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
Expand All @@ -67,7 +75,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)) {
Expand All @@ -78,9 +86,10 @@ 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
summary = if (hamMode) stringResource(Res.string.call_sign_summary) else null,
maxSize = longNameMax,
enabled = state.connected,
isError = !validLongName,
keyboardOptions =
Expand Down Expand Up @@ -123,7 +132,16 @@ 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
// 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,
long_name = if (clearForCallsign) "" else longName,
)
},
containerColor = CardDefaults.cardColors().containerColor,
)
}
Expand Down
Loading