mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-16 17:59:07 -04:00
feat(settings): implement set_ham_mode HamParameters admin message (#5780)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2
.skills/compose-ui/strings-index.txt
generated
2
.skills/compose-ui/strings-index.txt
generated
@@ -122,6 +122,8 @@ broadcast_interval
|
||||
button_gpio
|
||||
buzzer_gpio
|
||||
calculating
|
||||
call_sign
|
||||
call_sign_summary
|
||||
cancel
|
||||
cancel_reply
|
||||
canned_message
|
||||
|
||||
@@ -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` 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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -108,6 +109,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) {}
|
||||
|
||||
@@ -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,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") {
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)) {
|
||||
@@ -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 =
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,91 @@ 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()) }
|
||||
}
|
||||
|
||||
@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"))
|
||||
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 {
|
||||
val node = Node(num = 123, user = User(id = "!123"))
|
||||
@@ -612,24 +699,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)
|
||||
|
||||
@@ -646,24 +716,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)
|
||||
|
||||
@@ -680,24 +733,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)
|
||||
|
||||
@@ -709,4 +745,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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user