mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-11 13:56:03 -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:
@@ -52,6 +52,8 @@ import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
@@ -101,6 +103,12 @@ class DiscoveryScanEngine(
|
||||
private var scanScope: CoroutineScope? = null
|
||||
private var dwellJob: Job? = null
|
||||
private var originalLoRaConfig: Config.LoRaConfig? = null
|
||||
|
||||
/** The radio's primary channel at scan start; restored after a custom-channel target overwrites it (FR-005). */
|
||||
private var originalPrimaryChannel: ChannelSettings? = null
|
||||
|
||||
/** True once a custom-channel target has retuned the primary channel, so restore only writes when needed. */
|
||||
private var tunedPrimaryChannel: Boolean = false
|
||||
private var sessionId: Long = 0
|
||||
|
||||
/** Nodes collected for the current preset dwell. Keyed by nodeNum. */
|
||||
@@ -138,14 +146,19 @@ class DiscoveryScanEngine(
|
||||
|
||||
// region Public API
|
||||
|
||||
/** Convenience overload: scan a list of public presets (each becomes a preset [ScanTarget] labelled by name). */
|
||||
suspend fun startScan(presets: List<ChannelOption>, dwellDurationSeconds: Long) =
|
||||
startScanTargets(presets.map { ScanTarget(preset = it, label = it.name) }, dwellDurationSeconds)
|
||||
|
||||
/**
|
||||
* Starts a discovery scan across the given [presets].
|
||||
* Starts a discovery scan across the given [targets] — public presets and/or beacon-advertised custom channels.
|
||||
* (Distinct name from the [ChannelOption] overload: both would erase to the same JVM signature otherwise.)
|
||||
*
|
||||
* @param presets The LoRa presets to cycle through.
|
||||
* @param dwellDurationSeconds How long to listen on each preset.
|
||||
* @param targets The scan queue ([ScanTarget]).
|
||||
* @param dwellDurationSeconds How long to listen on each target.
|
||||
*/
|
||||
suspend fun startScan(presets: List<ChannelOption>, dwellDurationSeconds: Long) {
|
||||
require(presets.isNotEmpty()) { "At least one preset is required" }
|
||||
suspend fun startScanTargets(targets: List<ScanTarget>, dwellDurationSeconds: Long) {
|
||||
require(targets.isNotEmpty()) { "At least one scan target is required" }
|
||||
require(dwellDurationSeconds > 0) { "Dwell duration must be positive" }
|
||||
|
||||
mutex.withLock {
|
||||
@@ -156,9 +169,19 @@ class DiscoveryScanEngine(
|
||||
|
||||
_scanState.value = DiscoveryScanState.Preparing
|
||||
|
||||
// Capture the entire original LoRa config to restore it accurately later
|
||||
// Capture the entire original LoRa config and primary channel to restore them accurately later.
|
||||
val initialLoraConfig = radioConfigRepository.localConfigFlow.first().lora
|
||||
originalLoRaConfig = initialLoraConfig
|
||||
originalPrimaryChannel = radioConfigRepository.channelSetFlow.first().settings.firstOrNull()
|
||||
tunedPrimaryChannel = false
|
||||
|
||||
// A custom-channel target overwrites the primary channel; without a captured original we could not restore
|
||||
// it and would strand the radio on the beacon's channel. Abort rather than proceed silently.
|
||||
if (originalPrimaryChannel == null && targets.any { it.channel != null }) {
|
||||
Logger.w { "DiscoveryScanEngine: primary channel not captured; aborting custom-channel scan" }
|
||||
_scanState.value = DiscoveryScanState.Idle
|
||||
return
|
||||
}
|
||||
|
||||
val homePresetStr =
|
||||
if (initialLoraConfig?.use_preset == true) {
|
||||
@@ -176,7 +199,7 @@ class DiscoveryScanEngine(
|
||||
val session =
|
||||
DiscoverySessionEntity(
|
||||
timestamp = nowMillis,
|
||||
presetsScanned = presets.joinToString(",") { it.name },
|
||||
presetsScanned = targets.joinToString(",") { it.label },
|
||||
homePreset = homePresetStr,
|
||||
completionStatus = "in_progress",
|
||||
userLatitude = latDouble,
|
||||
@@ -189,14 +212,14 @@ class DiscoveryScanEngine(
|
||||
collectorRegistry.collector = this
|
||||
|
||||
// Set initial state so the scan loop's isActive guard succeeds
|
||||
_scanState.value = DiscoveryScanState.Shifting(presets.first().name)
|
||||
currentPresetName = presets.first().name
|
||||
_scanState.value = DiscoveryScanState.Shifting(targets.first().label)
|
||||
currentPresetName = targets.first().label
|
||||
totalDwellSeconds = dwellDurationSeconds
|
||||
|
||||
// Launch scan coroutine
|
||||
val scope = CoroutineScope(dispatchers.io + SupervisorJob())
|
||||
scanScope = scope
|
||||
scope.launch { runScanLoop(presets, dwellDurationSeconds) }
|
||||
scope.launch { runScanLoop(targets, dwellDurationSeconds) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,11 +295,11 @@ class DiscoveryScanEngine(
|
||||
// region Scan loop
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun runScanLoop(presets: List<ChannelOption>, dwellDurationSeconds: Long) {
|
||||
for (preset in presets) {
|
||||
private suspend fun runScanLoop(targets: List<ScanTarget>, dwellDurationSeconds: Long) {
|
||||
for (target in targets) {
|
||||
if (!isActive) return
|
||||
|
||||
currentPresetName = preset.name
|
||||
currentPresetName = target.label
|
||||
mutex.withLock {
|
||||
collectedNodes.clear()
|
||||
deviceMetricsLog.clear()
|
||||
@@ -284,12 +307,12 @@ class DiscoveryScanEngine(
|
||||
}
|
||||
totalDwellSeconds = dwellDurationSeconds
|
||||
|
||||
// Shift to the new preset
|
||||
_scanState.value = DiscoveryScanState.Shifting(preset.name)
|
||||
shiftPreset(preset)
|
||||
// Shift to the new target (preset, plus a custom primary channel for beacon-channel targets)
|
||||
_scanState.value = DiscoveryScanState.Shifting(target.label)
|
||||
shiftTarget(target)
|
||||
|
||||
// Wait for reconnection
|
||||
_scanState.value = DiscoveryScanState.Reconnecting(preset.name)
|
||||
_scanState.value = DiscoveryScanState.Reconnecting(target.label)
|
||||
if (!waitForConnection()) {
|
||||
pauseAndAbort()
|
||||
return
|
||||
@@ -299,13 +322,13 @@ class DiscoveryScanEngine(
|
||||
requestNeighborInfoAtDwellBoundary()
|
||||
|
||||
// Dwell
|
||||
if (!runDwell(preset.name, dwellDurationSeconds)) {
|
||||
if (!runDwell(target.label, dwellDurationSeconds)) {
|
||||
pauseAndAbort()
|
||||
return
|
||||
}
|
||||
if (!isActive) return
|
||||
|
||||
// Persist this preset's results
|
||||
// Persist this target's results
|
||||
persistCurrentDwellResults()
|
||||
}
|
||||
|
||||
@@ -333,11 +356,36 @@ class DiscoveryScanEngine(
|
||||
cancelScanInternal()
|
||||
}
|
||||
|
||||
private suspend fun shiftPreset(preset: ChannelOption) {
|
||||
val loraConfig = Config.LoRaConfig(use_preset = true, modem_preset = preset.modemPreset)
|
||||
val config = Config(lora = loraConfig)
|
||||
radioController.setLocalConfig(config)
|
||||
Logger.i { "DiscoveryScanEngine: shifted to ${preset.name} (use_preset=true)" }
|
||||
private suspend fun shiftTarget(target: ScanTarget) {
|
||||
// Start from the captured original config so unrelated fields (hop_limit, tx_power, tx_enabled, …) are carried
|
||||
// over instead of zeroed by a fresh LoRaConfig — a from-scratch config can e.g. break the dwell-boundary
|
||||
// NeighborInfo request. Only the preset (and, for custom channels, region + channel_num) is overridden.
|
||||
val base = originalLoRaConfig ?: Config.LoRaConfig()
|
||||
if (target.channel == null) {
|
||||
// Public-preset target — dwell on the preset using the radio's existing primary channel (unchanged).
|
||||
radioController.setLocalConfig(
|
||||
Config(lora = base.copy(use_preset = true, modem_preset = target.preset.modemPreset)),
|
||||
)
|
||||
Logger.i { "DiscoveryScanEngine: shifted to ${target.label} (use_preset=true)" }
|
||||
} else {
|
||||
// Beacon custom-channel target: apply the offered preset+region, reset channel_num so firmware derives the
|
||||
// frequency from the new name, then tune the primary channel to the offered name+PSK so nodes on that mesh
|
||||
// are heard. The original primary channel is restored after the scan.
|
||||
radioController.setLocalConfig(
|
||||
Config(
|
||||
lora =
|
||||
base.copy(
|
||||
use_preset = true,
|
||||
modem_preset = target.preset.modemPreset,
|
||||
region = target.region ?: base.region,
|
||||
channel_num = 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
radioController.setLocalChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = target.channel))
|
||||
tunedPrimaryChannel = true
|
||||
Logger.i { "DiscoveryScanEngine: shifted to ${target.label} (custom channel)" }
|
||||
}
|
||||
// The firmware often restarts the radio or reboots after a LoRa config change.
|
||||
// Wait a short moment to ensure we don't consider it 'connected' right before it drops.
|
||||
delay(3000)
|
||||
@@ -642,8 +690,14 @@ class DiscoveryScanEngine(
|
||||
|
||||
private suspend fun restoreHomePreset() {
|
||||
val config = originalLoRaConfig ?: return
|
||||
val fullConfig = Config(lora = config)
|
||||
radioController.setLocalConfig(fullConfig)
|
||||
// Restore the primary channel first (only when a custom-channel target overwrote it), then the LoRa config —
|
||||
// both are no-ops on a public-only scan, so that path stays unchanged (FR-005 no-regression).
|
||||
if (tunedPrimaryChannel) {
|
||||
originalPrimaryChannel?.let {
|
||||
radioController.setLocalChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = it))
|
||||
}
|
||||
}
|
||||
radioController.setLocalConfig(Config(lora = config))
|
||||
Logger.i { "DiscoveryScanEngine: restored original LoRa config" }
|
||||
// The firmware often restarts the radio or reboots after a LoRa config change.
|
||||
delay(3000)
|
||||
|
||||
@@ -36,6 +36,8 @@ import org.meshtastic.core.ui.viewmodel.safeLaunch
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.discovery.scan.Check24GhzCapability
|
||||
import org.meshtastic.feature.discovery.scan.HardwareCapabilityResult
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
import org.meshtastic.proto.Config.LoRaConfig
|
||||
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
|
||||
|
||||
@KoinViewModel
|
||||
@@ -56,6 +58,45 @@ class DiscoveryViewModel(
|
||||
/** Mesh Beacon invitations received from other meshes, newest first. */
|
||||
val beaconOffers: StateFlow<List<MeshBeaconOffer>> = meshBeaconRepository.offers
|
||||
|
||||
/** Modem presets advertised by any received beacon — flagged in the scan-setup picker (FR-004). */
|
||||
val beaconPresets: StateFlow<Set<ChannelOption>> =
|
||||
meshBeaconRepository.offers
|
||||
.map { offers -> offers.mapNotNull { ChannelOption.from(it.beacon.offer_preset) }.toSet() }
|
||||
.stateInWhileSubscribed(initialValue = emptySet())
|
||||
|
||||
/** Distinct custom channels advertised by beacons — offered as selectable scan targets (FR-007). */
|
||||
val beaconChannels: StateFlow<List<BeaconChannel>> =
|
||||
meshBeaconRepository.offers
|
||||
.map { offers ->
|
||||
offers
|
||||
.mapNotNull { offer ->
|
||||
val ch = offer.beacon.offer_channel ?: return@mapNotNull null
|
||||
val name =
|
||||
ch.name.ifBlank {
|
||||
return@mapNotNull null
|
||||
}
|
||||
BeaconChannel(
|
||||
name = name,
|
||||
psk = ch.psk,
|
||||
preset = ChannelOption.from(offer.beacon.offer_preset),
|
||||
region = offer.beacon.offer_region,
|
||||
)
|
||||
}
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
private val _selectedBeaconChannels = MutableStateFlow<Set<String>>(emptySet())
|
||||
val selectedBeaconChannels: StateFlow<Set<String>> = _selectedBeaconChannels.asStateFlow()
|
||||
|
||||
/** The radio's current LoRa config — drives the add-vs-switch decision for a beacon join. */
|
||||
val currentLora: StateFlow<LoRaConfig?> =
|
||||
radioConfigRepository.localConfigFlow.map { it.lora }.stateInWhileSubscribed(initialValue = null)
|
||||
|
||||
/** The radio's current channel settings (index 0 = primary) — drives the add-vs-switch decision. */
|
||||
val currentChannels: StateFlow<List<ChannelSettings>> =
|
||||
radioConfigRepository.channelSetFlow.map { it.settings }.stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
val homePreset: StateFlow<ChannelOption> =
|
||||
radioConfigRepository.localConfigFlow
|
||||
.map { localConfig -> ChannelOption.from(localConfig.lora?.modem_preset) ?: ChannelOption.DEFAULT }
|
||||
@@ -94,6 +135,12 @@ class DiscoveryViewModel(
|
||||
val sessions: StateFlow<List<DiscoverySessionEntity>> =
|
||||
discoveryDao.getAllSessions().stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
/** Beacon presets we've already auto-selected once, so a user's later deselection is never undone (FR-004). */
|
||||
private val autoSelectedBeaconPresets = mutableSetOf<ChannelOption>()
|
||||
|
||||
/** Beacon channels we've already auto-selected once (union-only, FR-007). */
|
||||
private val autoSelectedBeaconChannels = mutableSetOf<String>()
|
||||
|
||||
init {
|
||||
safeLaunch(tag = "markInterruptedSessions") { discoveryDao.markInterruptedSessions() }
|
||||
safeLaunch(tag = "check24GhzCapability") {
|
||||
@@ -101,26 +148,57 @@ class DiscoveryViewModel(
|
||||
_is24GhzBlocked.value =
|
||||
result is HardwareCapabilityResult.Unsupported || result is HardwareCapabilityResult.Unknown
|
||||
}
|
||||
// Pre-select each beacon-advertised preset once (union-only) as beacons arrive — never clearing user choices.
|
||||
safeLaunch(tag = "preselectBeaconPresets") {
|
||||
beaconPresets.collect { presets ->
|
||||
val fresh = presets - autoSelectedBeaconPresets
|
||||
if (fresh.isNotEmpty()) {
|
||||
autoSelectedBeaconPresets += fresh
|
||||
updateSelectedPresets { it + fresh }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pre-select each beacon custom channel once (union-only), never clearing user choices (FR-007).
|
||||
safeLaunch(tag = "preselectBeaconChannels") {
|
||||
beaconChannels.collect { channels ->
|
||||
val fresh = channels.map { it.id }.toSet() - autoSelectedBeaconChannels
|
||||
if (fresh.isNotEmpty()) {
|
||||
autoSelectedBeaconChannels += fresh
|
||||
_selectedBeaconChannels.update { it + fresh }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePreset(preset: ChannelOption) {
|
||||
fun toggleBeaconChannel(id: String) {
|
||||
_selectedBeaconChannels.update { if (id in it) it - id else it + id }
|
||||
}
|
||||
|
||||
fun togglePreset(preset: ChannelOption) = updateSelectedPresets { current ->
|
||||
if (preset in current) current - preset else current + preset
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically mutates the selected-preset set and mirrors it to prefs, so the shown selection and saved prefs never
|
||||
* diverge. Shared by [togglePreset], [discoverOffer], and the beacon-preset pre-select.
|
||||
*/
|
||||
private fun updateSelectedPresets(transform: (Set<ChannelOption>) -> Set<ChannelOption>) {
|
||||
_selectedPresets.update { current ->
|
||||
val updated = if (preset in current) current - preset else current + preset
|
||||
val updated = transform(current)
|
||||
discoveryPrefs.setSelectedPresets(updated.map { it.name }.toSet())
|
||||
updated
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the scan with just the preset an invitation advertised, so the user can survey the advertised mesh before
|
||||
* joining. Persists like [togglePreset] (not a bare [_selectedPresets] write) so the shown selection and saved
|
||||
* prefs never diverge — otherwise the next [togglePreset] would silently persist prefs derived from this seed.
|
||||
* No-op if the offer carries no preset, or a preset with no matching [ChannelOption].
|
||||
* Adds the preset an invitation advertised to the scan selection (union, never clearing the user's existing choices
|
||||
* — matches Apple 014-mesh-beacons FR-003), so the user can survey the advertised mesh before joining. Persists
|
||||
* like [togglePreset] so the shown selection and saved prefs never diverge. No-op if the offer carries no preset,
|
||||
* or a preset with no matching [ChannelOption].
|
||||
*/
|
||||
fun discoverOffer(offer: MeshBeaconOffer) {
|
||||
val preset = ChannelOption.from(offer.beacon.offer_preset) ?: return
|
||||
_selectedPresets.value = setOf(preset)
|
||||
discoveryPrefs.setSelectedPresets(setOf(preset.name))
|
||||
updateSelectedPresets { it + preset }
|
||||
}
|
||||
|
||||
fun dismissOffer(offer: MeshBeaconOffer) {
|
||||
@@ -134,8 +212,24 @@ class DiscoveryViewModel(
|
||||
|
||||
fun startScan() {
|
||||
safeLaunch(tag = "startScan") {
|
||||
scanEngine.startScan(
|
||||
presets = selectedPresets.value.toList(),
|
||||
val presetTargets = selectedPresets.value.map { ScanTarget(preset = it, label = it.name) }
|
||||
val selectedIds = selectedBeaconChannels.value
|
||||
val channelTargets =
|
||||
beaconChannels.value
|
||||
.filter { it.id in selectedIds }
|
||||
.map { bc ->
|
||||
val preset = bc.preset ?: homePreset.value
|
||||
ScanTarget(
|
||||
preset = preset,
|
||||
label = "${preset.name} · ${bc.name}",
|
||||
channel = ChannelSettings(name = bc.name, psk = bc.psk),
|
||||
region = bc.region.takeIf { it != RegionCode.UNSET },
|
||||
)
|
||||
}
|
||||
val targets = presetTargets + channelTargets
|
||||
if (targets.isEmpty()) return@safeLaunch
|
||||
scanEngine.startScanTargets(
|
||||
targets = targets,
|
||||
dwellDurationSeconds = dwellDurationMinutes.value.toLong() * SECONDS_PER_MINUTE,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.feature.discovery
|
||||
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.model.ChannelOption
|
||||
import org.meshtastic.proto.ChannelSettings
|
||||
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
|
||||
|
||||
/**
|
||||
* One entry in the discovery scan queue. A [channel] of `null` is a public-preset target (the original scan behavior —
|
||||
* dwell on [preset] using the radio's existing primary channel). A non-null [channel] is a beacon-advertised custom
|
||||
* channel: during its dwell the engine tunes the radio's primary channel to that name+PSK (and [region]) so nodes on
|
||||
* that mesh are heard, then restores the original primary channel afterwards (Apple 014-mesh-beacons FR-005).
|
||||
*
|
||||
* @param label Result label persisted with the dwell (e.g. `"LONG_FAST"` or `"LONG_FAST · PartyNet"`), so a custom
|
||||
* channel's results never collide with the same preset's public results.
|
||||
*/
|
||||
data class ScanTarget(
|
||||
val preset: ChannelOption,
|
||||
val label: String,
|
||||
val channel: ChannelSettings? = null,
|
||||
val region: RegionCode? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A distinct custom channel a beacon advertised, presented as a selectable row in scan setup (FR-007). Deduped by [id]
|
||||
* (name + preset + PSK), since the same channel on the same preset is one row regardless of how many nodes beaconed it
|
||||
* — but a different PSK is a different network, so it must not collapse into another row (that would send the user to
|
||||
* the wrong mesh).
|
||||
*/
|
||||
data class BeaconChannel(val name: String, val psk: ByteString, val preset: ChannelOption?, val region: RegionCode) {
|
||||
val id: String
|
||||
get() = "$name|${preset?.name.orEmpty()}|${psk.hex()}"
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.meshtastic.core.model.util.toChannelSet
|
||||
import org.meshtastic.core.navigation.DiscoveryRoute
|
||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
@@ -81,9 +80,11 @@ fun EntryProviderScope<NavKey>.discoveryGraph(backStack: NavBackStack<NavKey>) {
|
||||
private fun DiscoveryScanScreenEntry(backStack: NavBackStack<NavKey>) {
|
||||
val viewModel = koinViewModel<DiscoveryViewModel>()
|
||||
// Join reuses the QR channel-import flow (same ADD/REPLACE + "this retunes your radio" confirmation a scanned
|
||||
// channel URL shows). UIViewModel is ViewModel-store-scoped per nav entry (MeshtasticNavDisplay uses
|
||||
// rememberViewModelStoreNavEntryDecorator), so the app-shell SharedDialogs observes a *different* instance —
|
||||
// we must render the dialog here, against this entry's instance, or the offer never surfaces.
|
||||
// channel URL shows). The screen decides add-vs-switch and hands us the ready ChannelSet (add = no lora_config →
|
||||
// no reboot; switch = lora_config present → replace + reboot). UIViewModel is ViewModel-store-scoped per nav entry
|
||||
// (MeshtasticNavDisplay uses rememberViewModelStoreNavEntryDecorator), so the app-shell SharedDialogs observes a
|
||||
// *different* instance — we must render the dialog here, against this entry's instance, or the offer never
|
||||
// surfaces.
|
||||
val uiViewModel = koinViewModel<UIViewModel>()
|
||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
DiscoveryScanScreen(
|
||||
@@ -91,7 +92,7 @@ private fun DiscoveryScanScreenEntry(backStack: NavBackStack<NavKey>) {
|
||||
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
|
||||
onNavigateToSummary = { sessionId -> backStack.add(DiscoveryRoute.DiscoverySummary(sessionId)) },
|
||||
onNavigateToHistory = dropUnlessResumed { backStack.add(DiscoveryRoute.DiscoveryHistory) },
|
||||
onJoinOffer = { beacon -> beacon.toChannelSet()?.let(uiViewModel::setRequestChannelSet) },
|
||||
onJoinOffer = uiViewModel::setRequestChannelSet,
|
||||
)
|
||||
requestChannelSet?.let { ScannedQrCodeDialog(incoming = it, onDismiss = { uiViewModel.clearRequestChannelUrl() }) }
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.util.beaconJoinOption
|
||||
import org.meshtastic.core.model.util.toJoinChannelSet
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.back
|
||||
import org.meshtastic.core.resources.discovery_analysing_results
|
||||
@@ -99,10 +101,11 @@ import org.meshtastic.core.ui.icon.Warning
|
||||
import org.meshtastic.core.ui.util.KeepScreenOn
|
||||
import org.meshtastic.feature.discovery.DiscoveryScanState
|
||||
import org.meshtastic.feature.discovery.DiscoveryViewModel
|
||||
import org.meshtastic.feature.discovery.ui.component.BeaconChannelsCard
|
||||
import org.meshtastic.feature.discovery.ui.component.DwellProgressIndicator
|
||||
import org.meshtastic.feature.discovery.ui.component.MeshBeaconInvitationCard
|
||||
import org.meshtastic.feature.discovery.ui.component.PresetPickerCard
|
||||
import org.meshtastic.proto.MeshBeacon
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
|
||||
private val CONTENT_PADDING = 16.dp
|
||||
private val SECTION_SPACING = 16.dp
|
||||
@@ -119,11 +122,16 @@ fun DiscoveryScanScreen(
|
||||
onNavigateToSummary: (sessionId: Long) -> Unit,
|
||||
onNavigateToHistory: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onJoinOffer: (MeshBeacon) -> Unit = {},
|
||||
onJoinOffer: (ChannelSet) -> Unit = {},
|
||||
) {
|
||||
val scanState by viewModel.scanState.collectAsStateWithLifecycle()
|
||||
val selectedPresets by viewModel.selectedPresets.collectAsStateWithLifecycle()
|
||||
val beaconOffers by viewModel.beaconOffers.collectAsStateWithLifecycle()
|
||||
val beaconPresets by viewModel.beaconPresets.collectAsStateWithLifecycle()
|
||||
val beaconChannels by viewModel.beaconChannels.collectAsStateWithLifecycle()
|
||||
val selectedBeaconChannels by viewModel.selectedBeaconChannels.collectAsStateWithLifecycle()
|
||||
val currentLora by viewModel.currentLora.collectAsStateWithLifecycle()
|
||||
val currentChannels by viewModel.currentChannels.collectAsStateWithLifecycle()
|
||||
val dwellMinutes by viewModel.dwellDurationMinutes.collectAsStateWithLifecycle()
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
|
||||
val usesDefaultKey by viewModel.usesDefaultKey.collectAsStateWithLifecycle()
|
||||
@@ -184,7 +192,7 @@ fun DiscoveryScanScreen(
|
||||
ScanButton(
|
||||
scanState = scanState,
|
||||
isConnected = isConnected,
|
||||
hasPresetsSelected = selectedPresets.isNotEmpty(),
|
||||
hasPresetsSelected = selectedPresets.isNotEmpty() || selectedBeaconChannels.isNotEmpty(),
|
||||
usesDefaultKey = usesDefaultKey,
|
||||
is24GhzUnsupported = isLora24Region && is24GhzBlocked,
|
||||
onStart = viewModel::startScan,
|
||||
@@ -215,9 +223,14 @@ fun DiscoveryScanScreen(
|
||||
)
|
||||
}
|
||||
items(beaconOffers, key = { "invitation_${it.key}" }) { offer ->
|
||||
val joinOption =
|
||||
remember(offer, currentLora, currentChannels) {
|
||||
offer.beacon.beaconJoinOption(currentLora, currentChannels)
|
||||
}
|
||||
MeshBeaconInvitationCard(
|
||||
offer = offer,
|
||||
onJoin = { onJoinOffer(offer.beacon) },
|
||||
joinOption = joinOption,
|
||||
onJoin = { offer.beacon.toJoinChannelSet(joinOption, currentLora)?.let(onJoinOffer) },
|
||||
onDiscover = { viewModel.discoverOffer(offer) },
|
||||
onDismiss = { viewModel.dismissOffer(offer) },
|
||||
)
|
||||
@@ -231,9 +244,22 @@ fun DiscoveryScanScreen(
|
||||
homePreset = homePreset,
|
||||
onTogglePreset = viewModel::togglePreset,
|
||||
enabled = true,
|
||||
beaconPresets = beaconPresets,
|
||||
)
|
||||
}
|
||||
|
||||
// Beacon channels — custom channels advertised by beacons (hidden when none recorded)
|
||||
if (beaconChannels.isNotEmpty()) {
|
||||
item(key = "beacon_channels") {
|
||||
BeaconChannelsCard(
|
||||
channels = beaconChannels,
|
||||
selectedIds = selectedBeaconChannels,
|
||||
onToggle = viewModel::toggleBeaconChannel,
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Dwell time picker
|
||||
item(key = "dwell_picker") {
|
||||
DwellTimePicker(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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.discovery.ui.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.mesh_beacon_channels_title
|
||||
import org.meshtastic.core.resources.mesh_beacon_preset_indicator
|
||||
import org.meshtastic.core.ui.icon.CellTower
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.feature.discovery.BeaconChannel
|
||||
|
||||
private val CARD_PADDING = 16.dp
|
||||
private val ROW_SPACING = 8.dp
|
||||
|
||||
/**
|
||||
* Scan-setup section listing distinct custom channels that beacons advertised (Apple 014-mesh-beacons FR-007).
|
||||
* Selecting a row adds a custom-channel scan target that tunes the radio's primary channel to that name during its
|
||||
* dwell.
|
||||
*/
|
||||
@Composable
|
||||
fun BeaconChannelsCard(
|
||||
channels: List<BeaconChannel>,
|
||||
selectedIds: Set<String>,
|
||||
onToggle: (String) -> Unit,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(CARD_PADDING), verticalArrangement = Arrangement.spacedBy(ROW_SPACING)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.mesh_beacon_channels_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
channels.forEach { channel ->
|
||||
val selected = channel.id in selectedIds
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable(enabled = enabled) { onToggle(channel.id) },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(ROW_SPACING),
|
||||
) {
|
||||
Checkbox(checked = selected, onCheckedChange = { onToggle(channel.id) }, enabled = enabled)
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.CellTower,
|
||||
contentDescription = stringResource(Res.string.mesh_beacon_preset_indicator),
|
||||
)
|
||||
Column {
|
||||
Text(text = channel.name, style = MaterialTheme.typography.bodyMedium)
|
||||
channel.preset?.let {
|
||||
Text(
|
||||
text = it.displayName(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,12 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.common.util.MetricFormatter
|
||||
import org.meshtastic.core.model.ChannelOption
|
||||
import org.meshtastic.core.model.MeshBeaconOffer
|
||||
import org.meshtastic.core.model.util.BeaconJoinOption
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.mesh_beacon_offer_add
|
||||
import org.meshtastic.core.resources.mesh_beacon_offer_channel
|
||||
import org.meshtastic.core.resources.mesh_beacon_offer_discover
|
||||
import org.meshtastic.core.resources.mesh_beacon_offer_dismiss
|
||||
@@ -43,6 +46,7 @@ import org.meshtastic.core.resources.mesh_beacon_offer_from_unknown
|
||||
import org.meshtastic.core.resources.mesh_beacon_offer_join
|
||||
import org.meshtastic.core.resources.mesh_beacon_offer_preset
|
||||
import org.meshtastic.core.resources.mesh_beacon_offer_region
|
||||
import org.meshtastic.core.resources.mesh_beacon_offer_signal
|
||||
import org.meshtastic.core.resources.mesh_beacon_offer_title
|
||||
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
|
||||
|
||||
@@ -50,9 +54,11 @@ import org.meshtastic.proto.Config.LoRaConfig.RegionCode
|
||||
* A single received Mesh Beacon invitation. Presents the advertised channel/region/preset and lets the user survey the
|
||||
* mesh first ([onDiscover], shown only when a preset is offered), join it ([onJoin]), or dismiss the invitation.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun MeshBeaconInvitationCard(
|
||||
offer: MeshBeaconOffer,
|
||||
joinOption: BeaconJoinOption,
|
||||
onJoin: () -> Unit,
|
||||
onDiscover: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -93,6 +99,18 @@ internal fun MeshBeaconInvitationCard(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (offer.rssi != 0 || offer.snr != 0f) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
Res.string.mesh_beacon_offer_signal,
|
||||
MetricFormatter.snr(offer.snr),
|
||||
MetricFormatter.rssi(offer.rssi),
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
@@ -104,7 +122,17 @@ internal fun MeshBeaconInvitationCard(
|
||||
if (presetOption != null) {
|
||||
OutlinedButton(onClick = onDiscover) { Text(stringResource(Res.string.mesh_beacon_offer_discover)) }
|
||||
}
|
||||
Button(onClick = onJoin) { Text(stringResource(Res.string.mesh_beacon_offer_join)) }
|
||||
// "Add channel" joins with no reboot (same frequency slot); otherwise "Join" retunes + reboots.
|
||||
val joinLabel =
|
||||
if (joinOption == BeaconJoinOption.ADD) {
|
||||
Res.string.mesh_beacon_offer_add
|
||||
} else {
|
||||
Res.string.mesh_beacon_offer_join
|
||||
}
|
||||
// NONE (no offered channel) can't be joined — toJoinChannelSet returns null — so disable the action.
|
||||
Button(onClick = onJoin, enabled = joinOption != BeaconJoinOption.NONE) {
|
||||
Text(stringResource(joinLabel))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ import org.meshtastic.core.resources.discovery_lora_presets_description
|
||||
import org.meshtastic.core.resources.discovery_preset_home_label
|
||||
import org.meshtastic.core.resources.discovery_stat_selected
|
||||
import org.meshtastic.core.resources.discovery_stat_unselected
|
||||
import org.meshtastic.core.resources.mesh_beacon_preset_indicator
|
||||
import org.meshtastic.core.ui.icon.CellTower
|
||||
import org.meshtastic.core.ui.icon.Check
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
|
||||
@@ -58,6 +60,7 @@ internal fun ChannelOption.displayName(): String =
|
||||
private val DEPRECATED_PRESETS = setOf(ChannelOption.VERY_LONG_SLOW, ChannelOption.LONG_SLOW)
|
||||
|
||||
/** A card containing a [FlowRow] of [FilterChip] items for preset selection. */
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun PresetPickerCard(
|
||||
@@ -66,6 +69,7 @@ fun PresetPickerCard(
|
||||
onTogglePreset: (ChannelOption) -> Unit,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
beaconPresets: Set<ChannelOption> = emptySet(),
|
||||
) {
|
||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(CARD_PADDING)) {
|
||||
@@ -98,6 +102,8 @@ fun PresetPickerCard(
|
||||
}
|
||||
val selectedDesc = stringResource(Res.string.discovery_stat_selected)
|
||||
val unselectedDesc = stringResource(Res.string.discovery_stat_unselected)
|
||||
val fromBeacon = preset in beaconPresets
|
||||
val beaconDesc = stringResource(Res.string.mesh_beacon_preset_indicator)
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onTogglePreset(preset) },
|
||||
@@ -119,6 +125,19 @@ fun PresetPickerCard(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// A tower badge marks presets a nearby beacon advertised (Apple 014-mesh-beacons FR-004).
|
||||
trailingIcon =
|
||||
if (fromBeacon) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.CellTower,
|
||||
contentDescription = beaconDesc,
|
||||
modifier = Modifier.size(FilterChipDefaults.IconSize),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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