mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-12 06:15:55 -04:00
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:
@@ -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 {
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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-009–FR-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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user