mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-02 16:44:33 -04:00
fix(settings): stop profile import dropping Mesh Beacon settings (#7416)
This commit is contained in:
1 parent
7694d2103e
commit
c7ff9b4ef0
14 files changed
+439
-65
No files matched your search
+6
-2
@@ -172,7 +172,7 @@ class MeshConfigHandlerImpl(
|
||||
}
|
||||
|
||||
/** Returns a short summary of which Config variant is set. */
|
||||
private fun Config.summarize(): String = when {
|
||||
internal fun Config.summarize(): String = when {
|
||||
device != null -> "device"
|
||||
position != null -> "position"
|
||||
power != null -> "power"
|
||||
@@ -181,12 +181,14 @@ private fun Config.summarize(): String = when {
|
||||
lora != null -> "lora"
|
||||
bluetooth != null -> "bluetooth"
|
||||
security != null -> "security"
|
||||
sessionkey != null -> "sessionkey"
|
||||
device_ui != null -> "device_ui"
|
||||
else -> "unknown"
|
||||
}
|
||||
|
||||
/** Returns a short summary of which ModuleConfig variant is set. */
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun ModuleConfig.summarize(): String = when {
|
||||
internal fun ModuleConfig.summarize(): String = when {
|
||||
mqtt != null -> "mqtt"
|
||||
serial != null -> "serial"
|
||||
external_notification != null -> "external_notification"
|
||||
@@ -201,6 +203,8 @@ private fun ModuleConfig.summarize(): String = when {
|
||||
detection_sensor != null -> "detection_sensor"
|
||||
paxcounter != null -> "paxcounter"
|
||||
statusmessage != null -> "statusmessage"
|
||||
traffic_management != null -> "traffic_management"
|
||||
tak != null -> "tak"
|
||||
mesh_beacon != null -> "mesh_beacon"
|
||||
else -> "unknown"
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import com.squareup.wire.ProtoAdapter
|
||||
import com.squareup.wire.WireField
|
||||
import org.meshtastic.proto.Config
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Walks Wire's generated oneof, so a variant the proto grows fails here by name. JVM-only: reads `@WireField`. */
|
||||
class ConfigSummaryCoverageTest {
|
||||
|
||||
@Test
|
||||
fun `every Config variant is summarized by its field name`() {
|
||||
val variants =
|
||||
Config::class.java.declaredFields.filter {
|
||||
it.getAnnotation(WireField::class.java)?.oneofName == "payload_variant"
|
||||
}
|
||||
assertTrue(variants.isNotEmpty(), "found no @WireField fields in the Config payload_variant oneof")
|
||||
assertTrue(variants.any { it.name == "lora" }, "lora is not among the oneof fields found")
|
||||
|
||||
val mislabelled =
|
||||
variants
|
||||
.associate { field ->
|
||||
val value = (field.type.getField("ADAPTER").get(null) as ProtoAdapter<*>).decode(ByteArray(0))
|
||||
val config = Config.Builder().also { it.javaClass.getField(field.name).set(it, value) }.build()
|
||||
field.name to config.summarize()
|
||||
}
|
||||
.filter { (name, summary) -> name != summary }
|
||||
|
||||
assertEquals(emptyMap(), mislabelled, "these variants are logged under the wrong name")
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import com.squareup.wire.ProtoAdapter
|
||||
import com.squareup.wire.WireField
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Walks Wire's generated oneof, so a variant the proto grows fails here by name. JVM-only: reads `@WireField`. */
|
||||
class ModuleConfigSummaryCoverageTest {
|
||||
|
||||
@Test
|
||||
fun `every ModuleConfig variant is summarized by its field name`() {
|
||||
val variants =
|
||||
ModuleConfig::class.java.declaredFields.filter {
|
||||
it.getAnnotation(WireField::class.java)?.oneofName == "payload_variant"
|
||||
}
|
||||
assertTrue(variants.isNotEmpty(), "found no @WireField fields in the ModuleConfig payload_variant oneof")
|
||||
assertTrue(variants.any { it.name == "mesh_beacon" }, "mesh_beacon is not among the oneof fields found")
|
||||
|
||||
val mislabelled =
|
||||
variants
|
||||
.associate { field ->
|
||||
val value = (field.type.getField("ADAPTER").get(null) as ProtoAdapter<*>).decode(ByteArray(0))
|
||||
val config =
|
||||
ModuleConfig.Builder().also { it.javaClass.getField(field.name).set(it, value) }.build()
|
||||
field.name to config.summarize()
|
||||
}
|
||||
.filter { (name, summary) -> name != summary }
|
||||
|
||||
assertEquals(emptyMap(), mislabelled, "these variants are logged under the wrong name")
|
||||
}
|
||||
}
|
||||
+3
@@ -85,6 +85,9 @@ class ModuleConfigDataSource(private val moduleConfigStore: CoreModuleConfigData
|
||||
config.statusmessage != null ->
|
||||
current.newBuilder().also { wb -> wb.statusmessage = config.statusmessage }.build()
|
||||
|
||||
config.traffic_management != null ->
|
||||
current.newBuilder().also { wb -> wb.traffic_management = config.traffic_management }.build()
|
||||
|
||||
config.tak != null -> current.newBuilder().also { wb -> wb.tak = config.tak }.build()
|
||||
|
||||
config.mesh_beacon != null ->
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.datastore
|
||||
|
||||
import com.squareup.wire.ProtoAdapter
|
||||
import com.squareup.wire.WireField
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.datastore.di.CoreModuleConfigDataStore
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Walks Wire's generated oneof, so a variant the proto grows fails here by name. JVM-only: reads `@WireField`. */
|
||||
class ModuleConfigDataSourceCoverageTest {
|
||||
|
||||
private class InMemoryModuleConfigStore : CoreModuleConfigDataStore {
|
||||
override val data = MutableStateFlow(LocalModuleConfig.Builder().build())
|
||||
|
||||
override suspend fun updateData(transform: suspend (t: LocalModuleConfig) -> LocalModuleConfig) =
|
||||
transform(data.value).also { data.value = it }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every ModuleConfig variant is persisted into its LocalModuleConfig section`() = runTest {
|
||||
val store = InMemoryModuleConfigStore()
|
||||
val dataSource = ModuleConfigDataSource(store)
|
||||
val variants =
|
||||
ModuleConfig::class.java.declaredFields.filter {
|
||||
it.getAnnotation(WireField::class.java)?.oneofName == "payload_variant"
|
||||
}
|
||||
assertTrue(variants.isNotEmpty(), "found no @WireField fields in the ModuleConfig payload_variant oneof")
|
||||
assertTrue(variants.any { it.name == "mesh_beacon" }, "mesh_beacon is not among the oneof fields found")
|
||||
|
||||
variants.forEach { field ->
|
||||
val value = (field.type.getField("ADAPTER").get(null) as ProtoAdapter<*>).decode(ByteArray(0))
|
||||
dataSource.setLocalModuleConfig(
|
||||
ModuleConfig.Builder().also { it.javaClass.getField(field.name).set(it, value) }.build(),
|
||||
)
|
||||
}
|
||||
|
||||
val stored = store.data.first()
|
||||
val dropped = variants.map { it.name }.filter { LocalModuleConfig::class.java.getField(it).get(stored) == null }
|
||||
assertEquals(emptyList(), dropped, "these variants are never persisted, or a later one cleared them")
|
||||
}
|
||||
}
|
||||
+3
@@ -177,7 +177,10 @@ constructor(
|
||||
}
|
||||
lmc.paxcounter?.let { setModuleConfig(ModuleConfig.Builder().also { wb -> wb.paxcounter = it }.build()) }
|
||||
lmc.statusmessage?.let { setModuleConfig(ModuleConfig.Builder().also { wb -> wb.statusmessage = it }.build()) }
|
||||
// traffic_management is not installed: it has no settings screen, and a node built without the module exports
|
||||
// position_min_interval_secs = 0, which would silently turn off this node's default-on position dedup.
|
||||
lmc.tak?.let { setModuleConfig(ModuleConfig.Builder().also { wb -> wb.tak = it }.build()) }
|
||||
lmc.mesh_beacon?.let { setModuleConfig(ModuleConfig.Builder().also { wb -> wb.mesh_beacon = it }.build()) }
|
||||
}
|
||||
|
||||
private suspend fun AdminEditScope.installChannelsAndLora(
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ class InstallProfileUseCaseTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke installs all sections of a full profile`() = runTest {
|
||||
fun `invoke opens an edit session for a populated profile`() = runTest {
|
||||
val profile =
|
||||
DeviceProfile.Builder()
|
||||
.also { wb ->
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import com.squareup.wire.Message
|
||||
import com.squareup.wire.ProtoAdapter
|
||||
import com.squareup.wire.WireField
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.testing.FakeRadioConfigRepository
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.proto.DeviceProfile
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import java.lang.reflect.Field
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Walks Wire's generated fields, so a module config section the proto grows fails here by name until profile install
|
||||
* writes it or [notInstalled] names it. JVM-only because it reads `@WireField`.
|
||||
*/
|
||||
class InstallProfileModuleConfigCoverageTest {
|
||||
|
||||
private val notInstalled =
|
||||
mapOf(
|
||||
"traffic_management" to
|
||||
"no settings screen, and a node built without the module exports its position dedup switched off",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every section named as not installed is still a LocalModuleConfig section`() {
|
||||
val stale = notInstalled.keys - localModuleConfigSections().map { it.name }.toSet()
|
||||
|
||||
assertEquals(emptySet(), stale, "these sections left LocalModuleConfig, so drop them from notInstalled")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every LocalModuleConfig section has a ModuleConfig variant of the same name and type`() {
|
||||
val variantTypes = moduleConfigVariants().associate { it.name to it.type }
|
||||
|
||||
val unmatched = localModuleConfigSections().filter { variantTypes[it.name] != it.type }.map { it.name }
|
||||
|
||||
assertEquals(emptyList(), unmatched, "these LocalModuleConfig sections have no matching ModuleConfig variant")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile install writes every section except those named as not installed`() = runTest {
|
||||
val radioController = FakeRadioController()
|
||||
val sections = localModuleConfigSections()
|
||||
val moduleConfig =
|
||||
LocalModuleConfig.Builder()
|
||||
.also { builder ->
|
||||
sections.forEach { builder.javaClass.getField(it.name).set(builder, defaultOf(it)) }
|
||||
}
|
||||
.build()
|
||||
|
||||
InstallProfileUseCase(radioController, FakeRadioConfigRepository())(
|
||||
destNum = 1234,
|
||||
profile = DeviceProfile.Builder().also { it.module_config = moduleConfig }.build(),
|
||||
currentUser = null,
|
||||
currentLoraConfig = null,
|
||||
isLocal = false,
|
||||
)
|
||||
|
||||
val written = radioController.allModuleConfigs.associate { it.onlyVariant() }
|
||||
val installed = sections.filter { it.name !in notInstalled }
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
installed.map { it.name } - written.keys,
|
||||
"profile install never writes these module config sections",
|
||||
)
|
||||
assertEquals(emptySet(), notInstalled.keys intersect written.keys, "profile install writes these after all")
|
||||
assertEquals(radioController.allModuleConfigs.size, written.size, "a section was written more than once")
|
||||
installed.forEach { assertEquals(it.get(moduleConfig), written[it.name], "${it.name} was written changed") }
|
||||
}
|
||||
|
||||
private fun ModuleConfig.onlyVariant(): Pair<String, Any> =
|
||||
moduleConfigVariants().mapNotNull { field -> field.get(this)?.let { field.name to it } }.single()
|
||||
|
||||
private fun localModuleConfigSections(): List<Field> = LocalModuleConfig::class
|
||||
.java
|
||||
.declaredFields
|
||||
.filter { it.isAnnotationPresent(WireField::class.java) && Message::class.java.isAssignableFrom(it.type) }
|
||||
.also { sections ->
|
||||
assertTrue(sections.isNotEmpty(), "found no @WireField message fields on LocalModuleConfig")
|
||||
assertTrue(sections.any { it.name == "mesh_beacon" }, "mesh_beacon is not among the sections found")
|
||||
}
|
||||
|
||||
private fun moduleConfigVariants(): List<Field> = ModuleConfig::class
|
||||
.java
|
||||
.declaredFields
|
||||
.filter { it.getAnnotation(WireField::class.java)?.oneofName == "payload_variant" }
|
||||
.also { variants ->
|
||||
assertTrue(
|
||||
variants.isNotEmpty(),
|
||||
"found no @WireField fields in the ModuleConfig payload_variant oneof",
|
||||
)
|
||||
assertTrue(variants.any { it.name == "mesh_beacon" }, "mesh_beacon is not among the oneof fields found")
|
||||
}
|
||||
|
||||
private fun defaultOf(field: Field): Any? =
|
||||
(field.type.getField("ADAPTER").get(null) as ProtoAdapter<*>).decode(ByteArray(0))
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
title: Settings — Modules & Admin
|
||||
parent: User Guide
|
||||
nav_order: 8
|
||||
last_updated: 2026-09-19
|
||||
last_updated: 2026-09-28
|
||||
description: Configure optional feature modules (MQTT, telemetry, canned messages, TAK, and more) and perform device administration.
|
||||
aliases:
|
||||
- modules
|
||||
@@ -30,7 +30,7 @@ Module settings use a card-based layout with toggle switches, dropdowns, text fi
|
||||
|
||||
Every module lives under **Settings → Module configuration**.
|
||||
|
||||
> ⚠️ **Important:** Saving a module screen restarts the node — the button reads **Save & restart**, and the node is unreachable for a few seconds afterwards. External Notification and Mesh Beacon are the exceptions: their button reads **Save**, and the node may still restart for some changes.
|
||||
> ⚠️ **Important:** Saving a module screen restarts the node: the button reads **Save & restart**, and the node is unreachable for a few seconds afterwards. External Notification and Mesh Beacon are the exceptions: their button reads **Save**. External Notification may still restart the node for some changes, while a Mesh Beacon change applies without a restart.
|
||||
|
||||
### MQTT module
|
||||
|
||||
@@ -292,8 +292,9 @@ Remotely configure nodes that share your admin key:
|
||||
### Backup & Restore
|
||||
|
||||
**Settings → Backup & Restore** writes the connected node's whole configuration to a file with
|
||||
**Export configuration**, and reads a saved file back in with **Import configuration**. Export
|
||||
before a factory reset, or to copy one node's setup onto another. The section is shown for your
|
||||
**Export configuration**, and reads a saved file back in with **Import configuration**.
|
||||
Traffic Management settings are exported but not applied on import. Export before a factory
|
||||
reset, or to copy one node's setup onto another. The section is shown for your
|
||||
own node only, not over remote admin.
|
||||
|
||||
### Advanced
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.settings.radio
|
||||
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
|
||||
/** Replaces the section [config] carries and keeps every other section. */
|
||||
internal fun LocalModuleConfig.mergedWith(config: ModuleConfig): LocalModuleConfig = newBuilder()
|
||||
.also { wb ->
|
||||
wb.mqtt = config.mqtt ?: mqtt
|
||||
wb.serial = config.serial ?: serial
|
||||
wb.external_notification = config.external_notification ?: external_notification
|
||||
wb.store_forward = config.store_forward ?: store_forward
|
||||
wb.range_test = config.range_test ?: range_test
|
||||
wb.telemetry = config.telemetry ?: telemetry
|
||||
wb.canned_message = config.canned_message ?: canned_message
|
||||
wb.audio = config.audio ?: audio
|
||||
wb.remote_hardware = config.remote_hardware ?: remote_hardware
|
||||
wb.neighbor_info = config.neighbor_info ?: neighbor_info
|
||||
wb.ambient_lighting = config.ambient_lighting ?: ambient_lighting
|
||||
wb.detection_sensor = config.detection_sensor ?: detection_sensor
|
||||
wb.paxcounter = config.paxcounter ?: paxcounter
|
||||
wb.statusmessage = config.statusmessage ?: statusmessage
|
||||
wb.traffic_management = config.traffic_management ?: traffic_management
|
||||
wb.tak = config.tak ?: tak
|
||||
wb.mesh_beacon = config.mesh_beacon ?: mesh_beacon
|
||||
}
|
||||
.build()
|
||||
+6
-58
@@ -599,37 +599,10 @@ open class RadioConfigViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
fun setModuleConfig(config: ModuleConfig) {
|
||||
val destNum = destNum ?: destNode.value?.num ?: return
|
||||
safeLaunch(tag = "setModuleConfig") {
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
moduleConfig =
|
||||
state.moduleConfig
|
||||
.newBuilder()
|
||||
.also { wb ->
|
||||
wb.mqtt = config.mqtt ?: state.moduleConfig.mqtt
|
||||
wb.serial = config.serial ?: state.moduleConfig.serial
|
||||
wb.external_notification =
|
||||
config.external_notification ?: state.moduleConfig.external_notification
|
||||
wb.store_forward = config.store_forward ?: state.moduleConfig.store_forward
|
||||
wb.range_test = config.range_test ?: state.moduleConfig.range_test
|
||||
wb.telemetry = config.telemetry ?: state.moduleConfig.telemetry
|
||||
wb.canned_message = config.canned_message ?: state.moduleConfig.canned_message
|
||||
wb.audio = config.audio ?: state.moduleConfig.audio
|
||||
wb.remote_hardware = config.remote_hardware ?: state.moduleConfig.remote_hardware
|
||||
wb.neighbor_info = config.neighbor_info ?: state.moduleConfig.neighbor_info
|
||||
wb.ambient_lighting = config.ambient_lighting ?: state.moduleConfig.ambient_lighting
|
||||
wb.detection_sensor = config.detection_sensor ?: state.moduleConfig.detection_sensor
|
||||
wb.paxcounter = config.paxcounter ?: state.moduleConfig.paxcounter
|
||||
wb.statusmessage = config.statusmessage ?: state.moduleConfig.statusmessage
|
||||
wb.tak = config.tak ?: state.moduleConfig.tak
|
||||
wb.mesh_beacon = config.mesh_beacon ?: state.moduleConfig.mesh_beacon
|
||||
}
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
_radioConfigState.update { state -> state.copy(moduleConfig = state.moduleConfig.mergedWith(config)) }
|
||||
expectRestartIfLocal(config.saveRebootBehavior())
|
||||
radioConfigUseCase.setModuleConfig(destNum, config, onRequestId = ::registerWriteRequestId)
|
||||
}
|
||||
@@ -1342,35 +1315,8 @@ open class RadioConfigViewModel(
|
||||
}
|
||||
|
||||
is RadioResponseResult.ModuleConfigResponse -> {
|
||||
val response = result.config
|
||||
_radioConfigState.update { state ->
|
||||
state.copy(
|
||||
moduleConfig =
|
||||
state.moduleConfig
|
||||
.newBuilder()
|
||||
.also { wb ->
|
||||
wb.mqtt = response.mqtt ?: state.moduleConfig.mqtt
|
||||
wb.serial = response.serial ?: state.moduleConfig.serial
|
||||
wb.external_notification =
|
||||
response.external_notification ?: state.moduleConfig.external_notification
|
||||
wb.store_forward = response.store_forward ?: state.moduleConfig.store_forward
|
||||
wb.range_test = response.range_test ?: state.moduleConfig.range_test
|
||||
wb.telemetry = response.telemetry ?: state.moduleConfig.telemetry
|
||||
wb.canned_message = response.canned_message ?: state.moduleConfig.canned_message
|
||||
wb.audio = response.audio ?: state.moduleConfig.audio
|
||||
wb.remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware
|
||||
wb.neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info
|
||||
wb.ambient_lighting =
|
||||
response.ambient_lighting ?: state.moduleConfig.ambient_lighting
|
||||
wb.detection_sensor =
|
||||
response.detection_sensor ?: state.moduleConfig.detection_sensor
|
||||
wb.paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter
|
||||
wb.statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage
|
||||
wb.tak = response.tak ?: state.moduleConfig.tak
|
||||
wb.mesh_beacon = response.mesh_beacon ?: state.moduleConfig.mesh_beacon
|
||||
}
|
||||
.build(),
|
||||
)
|
||||
state.copy(moduleConfig = state.moduleConfig.mergedWith(result.config))
|
||||
}
|
||||
if (!isLateRemoteRead) incrementCompleted()
|
||||
}
|
||||
@@ -1536,6 +1482,8 @@ internal fun Config.saveRebootBehavior(): RebootBehavior = when {
|
||||
else -> RebootBehavior.MAY_RESTART
|
||||
}
|
||||
|
||||
/** Firmware `AdminModule::handleSetModuleConfig` reboots for every module section except status message. */
|
||||
/**
|
||||
* Firmware `AdminModule::handleSetModuleConfig` reboots for every module section except status message and Mesh Beacon.
|
||||
*/
|
||||
internal fun ModuleConfig.saveRebootBehavior(): RebootBehavior =
|
||||
if (statusmessage != null) RebootBehavior.NEVER else RebootBehavior.ALWAYS
|
||||
if (statusmessage != null || mesh_beacon != null) RebootBehavior.NEVER else RebootBehavior.ALWAYS
|
||||
+2
@@ -68,6 +68,7 @@ import org.meshtastic.core.ui.component.RegularPreference
|
||||
import org.meshtastic.core.ui.component.SwitchPreference
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.radio.RebootBehavior
|
||||
import org.meshtastic.feature.settings.util.FixedUpdateIntervals
|
||||
import org.meshtastic.feature.settings.util.IntervalConfiguration
|
||||
import org.meshtastic.feature.settings.util.toDisplayString
|
||||
@@ -181,6 +182,7 @@ fun MeshBeaconConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit,
|
||||
// broadcast fields verbatim.
|
||||
saveEnabled =
|
||||
meshBeaconSaveEnabled(state.connected, radioLora.use_preset, intervalValid, state.channelList.isNotEmpty()),
|
||||
rebootBehavior = RebootBehavior.NEVER,
|
||||
responseState = state.responseState,
|
||||
onDismissPacketResponse = viewModel::clearPacketResponse,
|
||||
onSave = {
|
||||
|
||||
+42
@@ -2804,6 +2804,48 @@ class RadioConfigViewModelTest {
|
||||
|
||||
assertFalse(nodeRestartTracker.restartExpected.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local module save that reboots opens the restart window`() = runTest {
|
||||
val node = Node(num = 123, user = User.Builder().also { wb -> wb.id = "!123" }.build())
|
||||
nodeRepository.setNodes(listOf(node))
|
||||
nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 123))
|
||||
viewModel = createViewModel()
|
||||
runCurrent()
|
||||
everySuspend { radioConfigUseCase.setModuleConfig(any(), any(), any()) } returns 42
|
||||
|
||||
nodeRestartTracker.onConnected()
|
||||
viewModel.setModuleConfig(
|
||||
ModuleConfig.Builder()
|
||||
.also { wb -> wb.mqtt = ModuleConfig.MQTTConfig.Builder().also { wb -> wb.enabled = true }.build() }
|
||||
.build(),
|
||||
)
|
||||
runCurrent()
|
||||
|
||||
assertTrue(nodeRestartTracker.restartExpected.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local Mesh Beacon save does not open the restart window`() = runTest {
|
||||
val node = Node(num = 123, user = User.Builder().also { wb -> wb.id = "!123" }.build())
|
||||
nodeRepository.setNodes(listOf(node))
|
||||
nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 123))
|
||||
viewModel = createViewModel()
|
||||
runCurrent()
|
||||
everySuspend { radioConfigUseCase.setModuleConfig(any(), any(), any()) } returns 42
|
||||
|
||||
nodeRestartTracker.onConnected()
|
||||
viewModel.setModuleConfig(
|
||||
ModuleConfig.Builder()
|
||||
.also { wb ->
|
||||
wb.mesh_beacon = MeshBeaconConfig.Builder().also { wb -> wb.broadcast_message = "hi" }.build()
|
||||
}
|
||||
.build(),
|
||||
)
|
||||
runCurrent()
|
||||
|
||||
assertFalse(nodeRestartTracker.restartExpected.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts the trailing `onRequestId` callback from a mocked request method's args. */
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.settings.radio
|
||||
|
||||
import com.squareup.wire.ProtoAdapter
|
||||
import com.squareup.wire.WireField
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Walks Wire's generated oneof, so a variant the proto grows fails here by name. JVM-only: reads `@WireField`. */
|
||||
class ModuleConfigMergeCoverageTest {
|
||||
|
||||
@Test
|
||||
fun `merging every ModuleConfig variant in turn fills and keeps every LocalModuleConfig section`() {
|
||||
val variants =
|
||||
ModuleConfig::class.java.declaredFields.filter {
|
||||
it.getAnnotation(WireField::class.java)?.oneofName == "payload_variant"
|
||||
}
|
||||
assertTrue(variants.isNotEmpty(), "found no @WireField fields in the ModuleConfig payload_variant oneof")
|
||||
assertTrue(variants.any { it.name == "mesh_beacon" }, "mesh_beacon is not among the oneof fields found")
|
||||
|
||||
val merged =
|
||||
variants.fold(LocalModuleConfig.Builder().build()) { acc, field ->
|
||||
val value = (field.type.getField("ADAPTER").get(null) as ProtoAdapter<*>).decode(ByteArray(0))
|
||||
acc.mergedWith(ModuleConfig.Builder().also { it.javaClass.getField(field.name).set(it, value) }.build())
|
||||
}
|
||||
|
||||
val dropped = variants.map { it.name }.filter { LocalModuleConfig::class.java.getField(it).get(merged) == null }
|
||||
assertEquals(emptyList(), dropped, "these variants are never merged, or a later merge cleared them")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user