feat(discovery): Mesh Beacon client with iOS 014-mesh-beacons parity (#6097)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-05 19:15:56 -05:00
committed by GitHub
parent 005ad1a9d4
commit bf929c20f2
27 changed files with 1289 additions and 76 deletions

View File

@@ -40,6 +40,7 @@ import org.meshtastic.core.resources.ic_speed
import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.ic_usb
import org.meshtastic.core.resources.ic_volume_up
import org.meshtastic.core.resources.mesh_beacon
import org.meshtastic.core.resources.mqtt
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.paxcounter
@@ -61,6 +62,9 @@ enum class ModuleRoute(
val type: Int = 0,
val isSupported: (Capabilities) -> Boolean = { true },
val isApplicable: (Config.DeviceConfig.Role?) -> Boolean = { true },
// False when the firmware has no ModuleConfigType to request this module per-request; the editor then relies on the
// connect-time config sync instead of a get (MeshBeacon: MeshBeaconConfig is in ModuleConfig but not in the enum).
val refreshable: Boolean = true,
) {
MQTT(Res.string.mqtt, SettingsRoute.MQTT, Res.drawable.ic_cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value),
SERIAL(
@@ -150,6 +154,16 @@ enum class ModuleRoute(
isSupported = { it.supportsTakConfig },
isApplicable = { it == Config.DeviceConfig.Role.TAK || it == Config.DeviceConfig.Role.TAK_TRACKER },
),
// MeshBeaconConfig has no AdminMessage.ModuleConfigType value upstream — the editor reads from the connect-time
// config sync, so refreshable=false (no per-module get is issued). Gated to firmware that ships the beacon module.
MESH_BEACON(
Res.string.mesh_beacon,
SettingsRoute.MeshBeacon,
Res.drawable.ic_perm_scan_wifi,
isSupported = { it.supportsMeshBeacon },
refreshable = false,
),
;
val bitfield: Int
@@ -184,7 +198,11 @@ enum class ModuleRoute(
STATUS_MESSAGE -> 0x0000
// Not excludable yet
TAK -> 0x0000 // Not excludable yet
TAK -> 0x0000
// Not excludable yet
MESH_BEACON -> 0x0000 // Not excludable yet
}
companion object {

View File

@@ -57,6 +57,7 @@ import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen
import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreenCommon
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
import org.meshtastic.feature.settings.radio.component.MQTTConfigScreen
import org.meshtastic.feature.settings.radio.component.MeshBeaconConfigScreen
import org.meshtastic.feature.settings.radio.component.NeighborInfoConfigScreen
import org.meshtastic.feature.settings.radio.component.NetworkConfigScreen
import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen
@@ -214,6 +215,9 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
ModuleRoute.TAK ->
TAKConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })
ModuleRoute.MESH_BEACON ->
MeshBeaconConfigScreen(viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })
}
}
}

View File

@@ -585,6 +585,13 @@ open class RadioConfigViewModel(
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNum ?: destNode.value?.num ?: return
// A module without a per-module get (no ModuleConfigType, e.g. MeshBeacon) reads from the connect-time config
// sync — just select the route and render, skipping the loading/request round-trip that would never complete.
if (route is ModuleRoute && !route.refreshable) {
_radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Empty) }
return
}
_radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) }
when (route) {

View File

@@ -0,0 +1,292 @@
/*
* 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.component
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import okio.ByteString.Companion.decodeBase64
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.mesh_beacon
import org.meshtastic.core.resources.mesh_beacon_broadcast
import org.meshtastic.core.resources.mesh_beacon_broadcast_summary
import org.meshtastic.core.resources.mesh_beacon_interval
import org.meshtastic.core.resources.mesh_beacon_interval_error
import org.meshtastic.core.resources.mesh_beacon_listen
import org.meshtastic.core.resources.mesh_beacon_listen_summary
import org.meshtastic.core.resources.mesh_beacon_message
import org.meshtastic.core.resources.mesh_beacon_offer_channel_key
import org.meshtastic.core.resources.mesh_beacon_offer_channel_name
import org.meshtastic.core.resources.mesh_beacon_offer_preset_setting
import org.meshtastic.core.resources.mesh_beacon_offer_region_setting
import org.meshtastic.core.resources.mesh_beacon_on_preset
import org.meshtastic.core.resources.mesh_beacon_on_region
import org.meshtastic.core.resources.mesh_beacon_send_as_node
import org.meshtastic.core.resources.mesh_beacon_target_add
import org.meshtastic.core.resources.mesh_beacon_target_channel_index
import org.meshtastic.core.resources.mesh_beacon_target_remove
import org.meshtastic.core.resources.mesh_beacon_targets
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SignedIntegerEditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.ModuleConfig.MeshBeaconConfig
private const val MESSAGE_MAX_BYTES = 100
private const val CHANNEL_NAME_MAX_BYTES = 11 // ChannelSettings.name max_size:12 (buffer incl. null terminator)
private const val MIN_INTERVAL_SECS = 3600
private fun Int.withFlag(flag: Int, on: Boolean): Int = if (on) this or flag else this and flag.inv()
private fun Int.hasFlag(flag: Int): Boolean = (this and flag) != 0
/**
* Editor for `ModuleConfig.MeshBeaconConfig` (Apple 014-mesh-beacons US2 / FR-009FR-014). Reads from the connect-time
* config sync (there is no `ModuleConfigType` beacon value to request per-module) and writes via
* `AdminMessage.setModuleConfig`. Flag edits are read-modify-write so `FLAG_LEGACY_SPLIT` and any unknown bits survive.
*
* The repeated `broadcast_targets` list is editable ([BroadcastTargetsCard]); the single-target `broadcast_on_*`
* scalars are shown only as the fallback when no targets are set (Apple parity). `broadcast_on_channel` is preserved
* verbatim through the Wire `copy()` round-trip (no scalar editor — the targets list supersedes it).
*/
@Suppress("LongMethod")
@Composable
fun MeshBeaconConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, modifier: Modifier = Modifier) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val meshBeaconConfig = state.moduleConfig.mesh_beacon ?: MeshBeaconConfig()
val formState = rememberConfigState(initialValue = meshBeaconConfig)
val listenFlag = MeshBeaconConfig.Flags.FLAG_LISTEN_ENABLED.value
val broadcastFlag = MeshBeaconConfig.Flags.FLAG_BROADCAST_ENABLED.value
// Only require a valid interval when broadcasting is actually on — otherwise a default (interval=0) config could
// never be saved, blocking even a listen-only toggle (FR-013 applies to the broadcast, not the whole form).
val broadcastEnabled = formState.value.flags.hasFlag(broadcastFlag)
val intervalValid = !broadcastEnabled || formState.value.broadcast_interval_secs >= MIN_INTERVAL_SECS
RadioConfigScreenList(
modifier = modifier,
title = stringResource(Res.string.mesh_beacon),
onBack = onBack,
configState = formState,
// Block saving an out-of-range interval (FR-013); message length is capped inline by the field itself.
enabled = state.connected && intervalValid,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
// Drop the offered channel when no name is set (a text-only beacon offers no channel).
val offered = it.broadcast_offer_channel?.takeIf { c -> c.name.isNotBlank() }
viewModel.setModuleConfig(ModuleConfig(mesh_beacon = it.copy(broadcast_offer_channel = offered)))
},
) {
item {
TitledCard(title = stringResource(Res.string.mesh_beacon)) {
SwitchPreference(
title = stringResource(Res.string.mesh_beacon_listen),
summary = stringResource(Res.string.mesh_beacon_listen_summary),
checked = formState.value.flags.hasFlag(listenFlag),
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.copy(flags = formState.value.flags.withFlag(listenFlag, it))
},
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.mesh_beacon_broadcast),
summary = stringResource(Res.string.mesh_beacon_broadcast_summary),
checked = formState.value.flags.hasFlag(broadcastFlag),
enabled = state.connected,
onCheckedChange = {
formState.value =
formState.value.copy(flags = formState.value.flags.withFlag(broadcastFlag, it))
},
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.mesh_beacon_message),
value = formState.value.broadcast_message,
maxSize = MESSAGE_MAX_BYTES,
enabled = state.connected,
isError = false,
keyboardOptions = KeyboardOptions.Default,
keyboardActions = KeyboardActions.Default,
onValueChanged = { formState.value = formState.value.copy(broadcast_message = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.mesh_beacon_interval),
value = formState.value.broadcast_interval_secs,
enabled = state.connected,
isError = !intervalValid,
keyboardActions = KeyboardActions.Default,
summary =
if (intervalValid) {
null
} else {
stringResource(Res.string.mesh_beacon_interval_error, MIN_INTERVAL_SECS)
},
onValueChanged = { formState.value = formState.value.copy(broadcast_interval_secs = it) },
)
}
}
item {
TitledCard(title = stringResource(Res.string.mesh_beacon_offer_channel_name)) {
EditTextPreference(
title = stringResource(Res.string.mesh_beacon_offer_channel_name),
value = formState.value.broadcast_offer_channel?.name.orEmpty(),
maxSize = CHANNEL_NAME_MAX_BYTES,
enabled = state.connected,
isError = false,
keyboardOptions = KeyboardOptions.Default,
keyboardActions = KeyboardActions.Default,
onValueChanged = {
val current = formState.value.broadcast_offer_channel ?: ChannelSettings()
formState.value = formState.value.copy(broadcast_offer_channel = current.copy(name = it))
},
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.mesh_beacon_offer_channel_key),
value = formState.value.broadcast_offer_channel?.psk?.base64().orEmpty(),
enabled = state.connected,
isError = false,
keyboardOptions = KeyboardOptions.Default,
keyboardActions = KeyboardActions.Default,
onValueChanged = { encoded ->
val psk = encoded.decodeBase64() ?: return@EditTextPreference
val current = formState.value.broadcast_offer_channel ?: ChannelSettings()
formState.value = formState.value.copy(broadcast_offer_channel = current.copy(psk = psk))
},
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.mesh_beacon_offer_region_setting),
selectedItem = formState.value.broadcast_offer_region,
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(broadcast_offer_region = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.mesh_beacon_offer_preset_setting),
selectedItem = formState.value.broadcast_offer_preset ?: ModemPreset.LONG_FAST,
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(broadcast_offer_preset = it) },
)
}
}
item {
BroadcastTargetsCard(
targets = formState.value.broadcast_targets,
enabled = state.connected,
onChange = { formState.value = formState.value.copy(broadcast_targets = it) },
)
}
item {
TitledCard(title = stringResource(Res.string.mesh_beacon_on_region)) {
// Single-target transmit scalars are the fallback used only when no multi-target list is set (Apple
// shows these two only when broadcast_targets is empty). broadcast_send_as_node applies either way.
if (formState.value.broadcast_targets.isEmpty()) {
DropDownPreference(
title = stringResource(Res.string.mesh_beacon_on_region),
selectedItem = formState.value.broadcast_on_region,
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(broadcast_on_region = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.mesh_beacon_on_preset),
selectedItem = formState.value.broadcast_on_preset ?: ModemPreset.LONG_FAST,
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(broadcast_on_preset = it) },
)
HorizontalDivider()
}
SignedIntegerEditTextPreference(
title = stringResource(Res.string.mesh_beacon_send_as_node),
value = formState.value.broadcast_send_as_node,
enabled = state.connected,
keyboardActions = KeyboardActions.Default,
onValueChanged = { formState.value = formState.value.copy(broadcast_send_as_node = it) },
)
}
}
}
}
/**
* Editor for the repeated `broadcast_targets` list (Apple FR-014 multi-target). Each target carries its own
* region/preset and an optional `channel_index`; rows can be added and removed. When the list is non-empty it
* supersedes the single-target `broadcast_on_*` scalars.
*/
@Composable
private fun BroadcastTargetsCard(
targets: List<MeshBeaconConfig.BroadcastTarget>,
enabled: Boolean,
onChange: (List<MeshBeaconConfig.BroadcastTarget>) -> Unit,
) {
TitledCard(title = stringResource(Res.string.mesh_beacon_targets)) {
targets.forEachIndexed { index, target ->
if (index > 0) HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.mesh_beacon_on_region),
selectedItem = target.region,
enabled = enabled,
onItemSelected = { sel ->
onChange(targets.mapIndexed { i, t -> if (i == index) t.copy(region = sel) else t })
},
)
DropDownPreference(
title = stringResource(Res.string.mesh_beacon_on_preset),
selectedItem = target.preset ?: ModemPreset.LONG_FAST,
enabled = enabled,
onItemSelected = { sel ->
onChange(targets.mapIndexed { i, t -> if (i == index) t.copy(preset = sel) else t })
},
)
SignedIntegerEditTextPreference(
title = stringResource(Res.string.mesh_beacon_target_channel_index),
value = target.channel_index ?: 0,
enabled = enabled,
keyboardActions = KeyboardActions.Default,
onValueChanged = { v ->
onChange(targets.mapIndexed { i, t -> if (i == index) t.copy(channel_index = v) else t })
},
)
TextButton(onClick = { onChange(targets.filterIndexed { i, _ -> i != index }) }, enabled = enabled) {
Text(stringResource(Res.string.mesh_beacon_target_remove))
}
}
HorizontalDivider()
TextButton(onClick = { onChange(targets + MeshBeaconConfig.BroadcastTarget()) }, enabled = enabled) {
Text(stringResource(Res.string.mesh_beacon_target_add))
}
}
}