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

@@ -578,6 +578,7 @@ firmware
firmware_edition
firmware_old
firmware_recovery_banner
firmware_recovery_ble_failed
firmware_recovery_button
firmware_recovery_dismiss
firmware_recovery_explanation
@@ -892,17 +893,43 @@ match_all
match_any
max
### MESH ###
mesh_beacon
mesh_beacon_broadcast
mesh_beacon_broadcast_summary
mesh_beacon_channels_title
mesh_beacon_interval
mesh_beacon_interval_error
mesh_beacon_invitations_title
mesh_beacon_listen
mesh_beacon_listen_summary
mesh_beacon_message
mesh_beacon_message_error
mesh_beacon_notification_body
mesh_beacon_notification_title
mesh_beacon_offer_add
mesh_beacon_offer_channel
mesh_beacon_offer_channel_key
mesh_beacon_offer_channel_name
mesh_beacon_offer_discover
mesh_beacon_offer_dismiss
mesh_beacon_offer_from_unknown
mesh_beacon_offer_join
mesh_beacon_offer_preset
mesh_beacon_offer_preset_setting
mesh_beacon_offer_region
mesh_beacon_offer_region_setting
mesh_beacon_offer_signal
mesh_beacon_offer_title
mesh_beacon_on_preset
mesh_beacon_on_region
mesh_beacon_preset_indicator
mesh_beacon_preset_none
mesh_beacon_send_as_node
mesh_beacon_target
mesh_beacon_target_add
mesh_beacon_target_channel_index
mesh_beacon_target_remove
mesh_beacon_targets
mesh_map_location
mesh_map_location_description
### MESHTASTIC ###

View File

@@ -206,7 +206,7 @@ class MeshDataHandlerImpl(
}
PortNum.MESH_BEACON_APP -> {
handleMeshBeacon(packet)
handleMeshBeacon(packet, myNodeNum)
}
else -> {}
@@ -224,12 +224,16 @@ class MeshDataHandlerImpl(
* low-priority notification when the invitation is first seen (not on every periodic re-broadcast). Only beacons
* carrying a join offer (a channel) are actionable; message-only beacons are ignored.
*/
private fun handleMeshBeacon(packet: MeshPacket) {
@Suppress("ReturnCount")
private fun handleMeshBeacon(packet: MeshPacket, myNodeNum: Int) {
// Ignore our own beacons (spec FR-001) — once broadcast is enabled a node that also listens would self-notify.
if (packet.from == myNodeNum) return
val payload = packet.decoded?.payload ?: return
val beacon = MeshBeacon.ADAPTER.decodeOrNull(payload, Logger)
// Only actionable beacons (carrying a channel offer) that we haven't already seen warrant a notification.
if (beacon?.offer_channel == null) return
val offer = MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon)
val offer =
MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon, snr = packet.rx_snr, rssi = packet.rx_rssi)
if (meshBeaconRepository.add(offer)) {
scope.launch {
notificationManager.dispatch(

View File

@@ -28,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
@@ -40,6 +41,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.MeshBeaconPrefs
import org.meshtastic.core.repository.MeshBeaconRepository
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
@@ -91,7 +93,6 @@ class MeshDataHandlerTest {
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill)
private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill)
private val meshBeaconRepository = MeshBeaconRepository()
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@@ -101,6 +102,18 @@ class MeshDataHandlerTest {
// still drives it; cancelled in tearDown.
private val geofenceScope = CoroutineScope(testDispatcher)
// Real repository over an in-memory prefs fake — its persistence write-through is exercised without a DataStore.
private val fakeBeaconPrefs =
object : MeshBeaconPrefs {
private val flow = MutableStateFlow<List<String>>(emptyList())
override val storedBeacons: StateFlow<List<String>> = flow
override fun setStoredBeacons(records: List<String>) {
flow.value = records
}
}
private val meshBeaconRepository = MeshBeaconRepository(fakeBeaconPrefs, geofenceScope)
@AfterTest
fun tearDown() {
geofenceScope.cancel()
@@ -416,6 +429,23 @@ class MeshDataHandlerTest {
assertEquals(0, meshBeaconRepository.offers.value.size)
}
@Test
fun `our own mesh beacon is ignored`() {
// Spec FR-001: ignore beacons from the scanning node itself (else a listen+broadcast node self-notifies).
val beacon = MeshBeacon(message = "Join us", offer_channel = ChannelSettings(name = "PartyNet"))
val packet =
MeshPacket(
from = 123, // == myNodeNum below
decoded = Data(portnum = PortNum.MESH_BEACON_APP, payload = beacon.encode().toByteString()),
)
every { dataMapper.toDataPacket(packet) } returns
DataPacket(from = "!self", bytes = beacon.encode().toByteString(), dataType = PortNum.MESH_BEACON_APP.value)
handler.handleReceivedData(packet, 123)
assertEquals(0, meshBeaconRepository.offers.value.size)
}
// --- Store-and-Forward handling ---
@Test

View File

@@ -81,6 +81,8 @@ class ModuleConfigDataSource(
config.tak != null -> current.copy(tak = config.tak)
config.mesh_beacon != null -> current.copy(mesh_beacon = config.mesh_beacon)
else -> current
}
}

View File

@@ -81,6 +81,13 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
*/
val supportsLockdown = atLeast(V2_8_0)
/**
* Support for the Mesh Beacon module (`ModuleConfig.MeshBeaconConfig` broadcast/listen). The proto is upstream but
* the firmware module traces to a community fork; gate the config editor to 2.8.0+ so older radios don't show a
* config they'd silently ignore.
*/
val supportsMeshBeacon = atLeast(V2_8_0)
companion object {
private val V2_6_8 = DeviceVersion("2.6.8")
private val V2_6_9 = DeviceVersion("2.6.9")

View File

@@ -16,6 +16,8 @@
*/
package org.meshtastic.core.model
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import org.meshtastic.proto.MeshBeacon
/**
@@ -25,8 +27,10 @@ import org.meshtastic.proto.MeshBeacon
*
* @param fromNodeNum The node that broadcast the beacon (informational only — beacons are unsigned).
* @param beacon The decoded advertisement, carrying the display [message][MeshBeacon.message] and the join offer.
* @param snr Signal-to-noise ratio of the received beacon packet, in dB (0 when unknown).
* @param rssi Received signal strength of the beacon packet, in dBm (0 when unknown).
*/
data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon) {
data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr: Float = 0f, val rssi: Int = 0) {
/** Stable identity for dedup/dismiss: a given sender advertising a given channel is one standing invitation. */
val key: String
get() = "$fromNodeNum:${beacon.offer_channel?.name.orEmpty()}"
@@ -36,4 +40,32 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon) {
val channelName: String?
get() = beacon.offer_channel?.name?.ifBlank { null }
/**
* Serializes to a single-line record `fromNodeNum|snr|rssi|<base64 MeshBeacon>` for lightweight prefs persistence.
* The base64 alphabet never contains `|`, so the delimiter is unambiguous.
*/
fun encode(): String {
val beaconB64 = MeshBeacon.ADAPTER.encode(beacon).toByteString().base64()
return "$fromNodeNum|$snr|$rssi|$beaconB64"
}
companion object {
private const val RECORD_FIELD_COUNT = 4
/**
* Inverse of [encode]; returns null for a structurally malformed record (wrong field count, unparseable node
* number, or an undecodable beacon payload). An unparseable snr/rssi falls back to 0 — they are non-critical
* display metrics, not identity, so a bad numeric there does not discard an otherwise-valid invitation.
*/
@Suppress("ReturnCount")
fun decode(record: String): MeshBeaconOffer? {
val parts = record.split('|', limit = RECORD_FIELD_COUNT)
if (parts.size != RECORD_FIELD_COUNT) return null
val node = parts[0].toIntOrNull() ?: return null
val beaconBytes = parts.last().decodeBase64()?.toByteArray() ?: return null
val beacon = runCatching { MeshBeacon.ADAPTER.decode(beaconBytes) }.getOrNull() ?: return null
return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull() ?: 0f, parts[2].toIntOrNull() ?: 0)
}
}
}

View File

@@ -22,9 +22,14 @@ import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.channelNum
import org.meshtastic.core.model.numChannels
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
import org.meshtastic.proto.MeshBeacon
import org.meshtastic.proto.ModuleSettings
/**
* Return a [ChannelSet] that represents the ChannelSet encoded by the URL.
@@ -79,19 +84,94 @@ val ChannelSet.primaryChannel: Channel?
fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null
/**
* Converts a received [MeshBeacon] join offer into a [ChannelSet], so it can be routed through the existing QR-code
* channel-import flow ([org.meshtastic.core.ui.qr.ScannedQrCodeViewModel]). Returns null when the beacon carries no
* join offer (an ambient message-only beacon).
*/
fun MeshBeacon.toChannelSet(): ChannelSet? {
val offerChannel = offer_channel ?: return null
// offer_region always has a value (proto default), but it's only meaningful paired with an explicit preset —
// without a preset we'd otherwise ship a LoRaConfig with use_preset=false and no custom radio fields set.
val loraConfig = offer_preset?.let { LoRaConfig(use_preset = true, modem_preset = it, region = offer_region) }
return ChannelSet(settings = listOf(offerChannel), lora_config = loraConfig)
/** How a received Mesh Beacon invitation can be joined, given the connected radio's current settings. */
enum class BeaconJoinOption {
/** Add the offered channel to a free secondary slot with no retune/reboot (mesh shares the radio's frequency). */
ADD,
/** Retune the radio's primary channel (and LoRa preset/region) onto the offered mesh — reboots the radio. */
SWITCH,
/** The beacon carries no join offer (message-only) — nothing to join. */
NONE,
}
/**
* Decides whether a beacon can be joined by simply **adding** its channel (no reboot) or requires a **switch**
* (retune + reboot). Adding works only when the offered mesh sits on the radio's *current* frequency slot — Meshtastic
* secondary channels ride the primary channel's frequency, so the offered channel must resolve (name-hash) to the same
* slot the radio's primary is on, under a matching preset and region. Mirrors the Apple `014-mesh-beacons`
* FR-016/FR-017 logic.
*
* @param currentLora The radio's current [LoRaConfig] (`null` → can't reason, so [SWITCH]).
* @param currentChannels The radio's current channel settings, index 0 = primary.
*/
@Suppress("ReturnCount")
fun MeshBeacon.beaconJoinOption(currentLora: LoRaConfig?, currentChannels: List<ChannelSettings>): BeaconJoinOption {
val offer = offer_channel ?: return BeaconJoinOption.NONE
val lora = currentLora ?: return BeaconJoinOption.SWITCH
// An offered preset must match the radio's; an omitted preset (null) means "ride the current preset" → matches.
val presetMatches = offer_preset == null || (lora.use_preset && offer_preset == lora.modem_preset)
// offer_region == UNSET (0) means "not offered"; only a set, differing region forces a switch.
val regionMatches = offer_region == RegionCode.UNSET || offer_region == lora.region
if (!presetMatches || !regionMatches) return BeaconJoinOption.SWITCH
// With an explicit slot override we can't compare the offered mesh's slot; be safe and switch.
if (lora.channel_num != 0 || lora.numChannels <= 0) return BeaconJoinOption.SWITCH
// Hash the *effective* names: an empty channel name resolves to its preset display name ("LongFast", …), which is
// what firmware hashes for the slot — comparing raw "" on both sides would misclassify an unnamed primary.
val currentSlot = lora.channelNum(Channel(currentChannels.firstOrNull() ?: ChannelSettings(), lora).name)
val offeredSlot = lora.channelNum(Channel(offer, lora).name)
return if (offeredSlot == currentSlot) BeaconJoinOption.ADD else BeaconJoinOption.SWITCH
}
/**
* Builds the [ChannelSet] to hand the QR channel-import dialog for a given [option].
*
* [ADD][BeaconJoinOption.ADD] omits `lora_config` so the dialog merges the offered channel into a free secondary slot
* with no reboot. [SWITCH][BeaconJoinOption.SWITCH] carries a `lora_config` derived from [currentLora] with only the
* advertised preset+region applied and `channel_num` reset to 0 (firmware derives the frequency from the offered
* channel name — Apple FR-006). Every other radio setting (`hop_limit`, `tx_power`, `tx_enabled`, …) is preserved from
* [currentLora], so joining never silently zeroes them — the beacon proto advertises no such fields. Returns `null` for
* [NONE][BeaconJoinOption.NONE] or a beacon with no offered channel.
*
* Both paths strip position sharing from the offered channel ([withoutPositionSharing]) so joining a stranger's mesh
* never leaks our location — matching Apple's `joinBeaconMesh`/`addBeaconChannel`.
*
* @param currentLora The radio's current [LoRaConfig]; a switch alters only preset/region/channel_num.
*/
fun MeshBeacon.toJoinChannelSet(option: BeaconJoinOption, currentLora: LoRaConfig?): ChannelSet? {
val offerChannel = (offer_channel ?: return null).withoutPositionSharing()
return when (option) {
BeaconJoinOption.ADD -> ChannelSet(settings = listOf(offerChannel))
BeaconJoinOption.SWITCH -> {
// Always ship a LoRaConfig so channel_num resets to 0 and firmware re-derives the frequency from the
// offered
// channel name (Apple FR-006) — even when no preset is offered (else an explicit channel_num override would
// strand the radio on the old slot). Preserve every current field; override only the advertised
// preset/region.
val base = currentLora ?: LoRaConfig()
val loraConfig =
base.copy(
use_preset = true,
channel_num = 0,
modem_preset = offer_preset ?: base.modem_preset,
region = if (offer_region != RegionCode.UNSET) offer_region else base.region,
)
ChannelSet(settings = listOf(offerChannel), lora_config = loraConfig)
}
BeaconJoinOption.NONE -> null
}
}
/**
* Zeroes `position_precision` on the offered channel so joining a beaconed mesh never broadcasts our location to a mesh
* of strangers (privacy-first; Apple sets `positionPrecision = 0` on both add and switch).
*/
private fun ChannelSettings.withoutPositionSharing(): ChannelSettings =
copy(module_settings = (module_settings ?: ModuleSettings()).copy(position_precision = 0))
/**
* Return a URL that represents the [ChannelSet]
*

View File

@@ -0,0 +1,196 @@
/*
* 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.model.util
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.model.MeshBeaconOffer
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
import org.meshtastic.proto.MeshBeacon
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class MeshBeaconOfferTest {
private val radioLora =
LoRaConfig(use_preset = true, modem_preset = ModemPreset.LONG_FAST, region = RegionCode.US, channel_num = 0)
private val radioChannels = listOf(ChannelSettings(name = "HomeMesh"))
@Test
fun `no offer channel yields NONE`() {
val beacon = MeshBeacon(message = "hi")
assertEquals(BeaconJoinOption.NONE, beacon.beaconJoinOption(radioLora, radioChannels))
}
@Test
fun `matching preset region and slot yields ADD`() {
// Offering the radio's own primary channel name forces an identical name-hash slot → addable with no reboot.
val beacon =
MeshBeacon(
offer_channel = ChannelSettings(name = "HomeMesh"),
offer_preset = ModemPreset.LONG_FAST,
offer_region = RegionCode.US,
)
assertEquals(BeaconJoinOption.ADD, beacon.beaconJoinOption(radioLora, radioChannels))
}
@Test
fun `unnamed primary matches a beacon that names the preset explicitly yielding ADD`() {
// The radio's primary has an empty name (resolves to the preset display name "LongFast" for the slot hash); a
// beacon offering a channel literally named "LongFast" on the same preset+region is the same slot -> ADD.
// Without effective-name resolution the empty primary would hash "" and misclassify as SWITCH.
val emptyPrimary = listOf(ChannelSettings(name = ""))
val beacon =
MeshBeacon(
offer_channel = ChannelSettings(name = "LongFast"),
offer_preset = ModemPreset.LONG_FAST,
offer_region = RegionCode.US,
)
assertEquals(BeaconJoinOption.ADD, beacon.beaconJoinOption(radioLora, emptyPrimary))
}
@Test
fun `different preset forces SWITCH`() {
val beacon =
MeshBeacon(
offer_channel = ChannelSettings(name = "HomeMesh"),
offer_preset = ModemPreset.SHORT_FAST,
offer_region = RegionCode.US,
)
assertEquals(BeaconJoinOption.SWITCH, beacon.beaconJoinOption(radioLora, radioChannels))
}
@Test
fun `different region forces SWITCH`() {
val beacon =
MeshBeacon(
offer_channel = ChannelSettings(name = "HomeMesh"),
offer_preset = ModemPreset.LONG_FAST,
offer_region = RegionCode.EU_868,
)
assertEquals(BeaconJoinOption.SWITCH, beacon.beaconJoinOption(radioLora, radioChannels))
}
@Test
fun `null lora config forces SWITCH`() {
val beacon = MeshBeacon(offer_channel = ChannelSettings(name = "HomeMesh"))
assertEquals(
BeaconJoinOption.SWITCH,
beacon.beaconJoinOption(currentLora = null, currentChannels = emptyList()),
)
}
@Test
fun `toJoinChannelSet omits lora for ADD and includes it for SWITCH`() {
val beacon =
MeshBeacon(
offer_channel = ChannelSettings(name = "PartyNet"),
offer_preset = ModemPreset.LONG_FAST,
offer_region = RegionCode.US,
)
assertNull(beacon.toJoinChannelSet(BeaconJoinOption.ADD, radioLora)?.lora_config, "ADD must not retune")
assertNotNull(beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, radioLora)?.lora_config, "SWITCH carries lora")
assertNull(beacon.toJoinChannelSet(BeaconJoinOption.NONE, radioLora))
}
@Test
fun `SWITCH preserves current lora fields and only overrides preset region and channel_num`() {
// Regression: the beacon proto advertises no hop_limit/tx_power/tx_enabled — a switch must not zero them.
val current =
radioLora.copy(
modem_preset = ModemPreset.MEDIUM_FAST,
region = RegionCode.EU_868,
hop_limit = 3,
tx_power = 27,
tx_enabled = true,
channel_num = 5,
)
val beacon =
MeshBeacon(
offer_channel = ChannelSettings(name = "PartyNet"),
offer_preset = ModemPreset.LONG_FAST,
offer_region = RegionCode.US,
)
val lora = beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, current)?.lora_config
assertNotNull(lora)
assertEquals(3, lora.hop_limit, "hop_limit must be preserved, not zeroed")
assertEquals(27, lora.tx_power, "tx_power must be preserved")
assertEquals(true, lora.tx_enabled, "tx_enabled must be preserved")
assertEquals(ModemPreset.LONG_FAST, lora.modem_preset, "offered preset applied")
assertEquals(RegionCode.US, lora.region, "offered region applied")
assertEquals(0, lora.channel_num, "channel_num reset to 0 for frequency derivation")
}
@Test
fun `SWITCH without an offered preset still resets channel_num and keeps the current preset`() {
// A channel-only beacon (no preset) must still reset channel_num=0 so firmware re-derives the frequency from
// the new primary name — otherwise a radio with an explicit channel_num override lands on the wrong slot.
val current = radioLora.copy(modem_preset = ModemPreset.MEDIUM_FAST, channel_num = 5)
val beacon = MeshBeacon(offer_channel = ChannelSettings(name = "PartyNet"))
val lora = beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, current)?.lora_config
assertNotNull(lora, "SWITCH must always carry lora so channel_num resets")
assertEquals(0, lora.channel_num, "channel_num reset to 0 even without an offered preset")
assertEquals(ModemPreset.MEDIUM_FAST, lora.modem_preset, "current preset kept when none offered")
}
@Test
fun `join strips position sharing from the offered channel`() {
// Privacy: joining a stranger's mesh must never broadcast our location (Apple sets positionPrecision=0).
val beacon =
MeshBeacon(
offer_channel =
ChannelSettings(
name = "PartyNet",
module_settings = org.meshtastic.proto.ModuleSettings(position_precision = 32),
),
offer_preset = ModemPreset.LONG_FAST,
offer_region = RegionCode.US,
)
val added = beacon.toJoinChannelSet(BeaconJoinOption.ADD, radioLora)?.settings?.first()
val switched = beacon.toJoinChannelSet(BeaconJoinOption.SWITCH, radioLora)?.settings?.first()
assertEquals(0, added?.module_settings?.position_precision, "ADD zeroes position precision")
assertEquals(0, switched?.module_settings?.position_precision, "SWITCH zeroes position precision")
}
@Test
fun `encode then decode round-trips a beacon offer`() {
val offer =
MeshBeaconOffer(
fromNodeNum = 42,
beacon =
MeshBeacon(
message = "Join us",
offer_channel = ChannelSettings(name = "PartyNet", psk = "secret".encodeUtf8()),
offer_preset = ModemPreset.LONG_FAST,
offer_region = RegionCode.US,
),
snr = 6.5f,
rssi = -70,
)
val restored = MeshBeaconOffer.decode(offer.encode())
assertEquals(offer, restored)
}
@Test
fun `decode returns null for a malformed record`() {
assertNull(MeshBeaconOffer.decode("not-a-valid-record"))
}
}

View File

@@ -158,6 +158,8 @@ sealed interface SettingsRoute : Route {
@Serializable data object TAK : SettingsRoute
@Serializable data object MeshBeacon : SettingsRoute
// endregion
// region advanced config routes

View File

@@ -0,0 +1,77 @@
/*
* 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.prefs.discovery
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshBeaconPrefs
@Single
class MeshBeaconPrefsImpl(
@Named("UiDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : MeshBeaconPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val storedBeacons: StateFlow<List<String>> =
dataStore.data
.map { prefs ->
val raw = prefs[KEY_STORED_BEACONS] ?: ""
if (raw.isBlank()) emptyList() else raw.split(RECORD_DELIMITER)
}
.stateIn(scope, SharingStarted.Eagerly, emptyList())
// Single conflated writer: rapid add()/dismiss() calls each publish the full latest list here, and one collector
// serializes the DataStore writes (latest-wins). Avoids launch-per-call races that could persist a stale snapshot.
// runCatching keeps a best-effort write failure (e.g. disk I/O) from escaping as an uncaught coroutine exception —
// the next add/dismiss retries, and on restart we hydrate from the last successful write.
private val pendingWrite = MutableStateFlow<List<String>?>(null)
init {
scope.launch {
pendingWrite.filterNotNull().collectLatest { records ->
runCatching { dataStore.edit { it[KEY_STORED_BEACONS] = records.joinToString(RECORD_DELIMITER) } }
}
}
}
override fun setStoredBeacons(records: List<String>) {
pendingWrite.value = records
}
private companion object {
val KEY_STORED_BEACONS = stringPreferencesKey("mesh_beacon_stored_offers")
// Newline can never appear in a beacon record (fields are numeric + base64), so it is a safe row separator.
const val RECORD_DELIMITER = "\n"
}
}

View File

@@ -370,3 +370,13 @@ interface DiscoveryPrefs {
const val DEFAULT_DWELL_MINUTES = 15
}
}
/**
* Reactive persistence for received Mesh Beacon invitations. Records are opaque, self-describing strings (see
* `MeshBeaconOffer.encode`) so this prefs layer stays free of proto/model types.
*/
interface MeshBeaconPrefs {
val storedBeacons: StateFlow<List<String>>
fun setStoredBeacons(records: List<String>)
}

View File

@@ -16,22 +16,42 @@
*/
package org.meshtastic.core.repository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.meshtastic.core.model.MeshBeaconOffer
/**
* Holds Mesh Beacon invitations received during this app session. Beacons are advisory, ephemeral, zero-hop
* advertisements from other meshes — not messages or contacts — so they're kept in memory only (capped, no persistence)
* and naturally age out on app restart. Consumed by the Discovery surface, which presents them for the user to Discover
* / Join / Dismiss. Room persistence can be added later if users want invitations to survive restarts.
* Holds Mesh Beacon invitations received from other meshes. Beacons are advisory, zero-hop advertisements — not
* messages or contacts — so they live in the Discovery surface, which presents them for the user to Discover / Join /
* Dismiss. The list is capped and deduped in memory (the source of truth) and written through to [MeshBeaconPrefs] so
* invitations survive an app restart until the user explicitly dismisses them (Apple `014-mesh-beacons` FR-015).
*
* Note: persisted as opaque delimited records in a Preferences DataStore, not a Room table — the set is tiny (≤
* [MAX_OFFERS]) and never queried. Promote to a Room entity alongside the discovery tables if querying/joins appear.
*/
class MeshBeaconRepository {
class MeshBeaconRepository(private val prefs: MeshBeaconPrefs, scope: CoroutineScope) {
private val _offers = MutableStateFlow<List<MeshBeaconOffer>>(emptyList())
val offers: StateFlow<List<MeshBeaconOffer>> = _offers.asStateFlow()
init {
// Hydrate once from disk. Memory is authoritative afterwards, so a live beacon that arrives before the async
// DataStore load wins; the guard also makes our own write-through re-emissions no-ops.
scope.launch {
prefs.storedBeacons.collect { records ->
if (records.isEmpty()) return@collect
// Fold the empty-guard into the atomic update so a live beacon that arrived first (or a concurrent
// add/dismiss) is never clobbered by the async DataStore snapshot.
_offers.update { current ->
if (current.isEmpty()) records.mapNotNull(MeshBeaconOffer::decode).take(MAX_OFFERS) else current
}
}
}
}
/**
* Records a received [offer], replacing any prior offer with the same [key][MeshBeaconOffer.key] (a re-broadcast of
* a standing invitation). Returns true when this is a newly-seen invitation, so the caller can notify only once
@@ -40,13 +60,17 @@ class MeshBeaconRepository {
fun add(offer: MeshBeaconOffer): Boolean {
val isNew = _offers.value.none { it.key == offer.key }
_offers.update { current -> (listOf(offer) + current.filterNot { it.key == offer.key }).take(MAX_OFFERS) }
persist()
return isNew
}
fun dismiss(key: String) {
_offers.update { current -> current.filterNot { it.key == key } }
persist()
}
private fun persist() = prefs.setStoredBeacons(_offers.value.map { it.encode() })
private companion object {
const val MAX_OFFERS = 20
}

View File

@@ -19,7 +19,9 @@ package org.meshtastic.core.repository.di
import org.koin.core.annotation.Module
import org.koin.core.annotation.Provided
import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MeshBeaconPrefs
import org.meshtastic.core.repository.MeshBeaconRepository
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NodeRepository
@@ -30,7 +32,11 @@ import org.meshtastic.core.repository.usecase.SendMessageUseCaseImpl
@Module
class CoreRepositoryModule {
@Single fun provideMeshBeaconRepository(): MeshBeaconRepository = MeshBeaconRepository()
@Single
fun provideMeshBeaconRepository(
@Provided meshBeaconPrefs: MeshBeaconPrefs,
@Provided applicationScope: ApplicationCoroutineScope,
): MeshBeaconRepository = MeshBeaconRepository(meshBeaconPrefs, applicationScope)
@Single
fun provideSendMessageUseCase(

View File

@@ -602,10 +602,10 @@
<string name="firmware_edition">Firmware Edition</string>
<string name="firmware_old">The radio firmware is too old to talk to this application. For more information on this see <a href="https://meshtastic.org/docs/getting-started/flashing-firmware">our Firmware Installation guide</a>.</string>
<string name="firmware_recovery_banner">Finish updating %1$s</string>
<string name="firmware_recovery_button">Resume firmware update</string>
<string name="firmware_recovery_explanation">This device is in bootloader mode from a firmware update that did not finish. Keep it nearby and it will be re-flashed to complete the update.</string>
<string name="firmware_recovery_ble_failed">Couldn\'t finish the update over Bluetooth. This device\'s stock bootloader can\'t reliably complete an interrupted update over the air. Connect it to a computer with USB and re-flash it using the vendor\'s serial DFU tool (for example adafruit-nrfutil) to recover the device.</string>
<string name="firmware_recovery_button">Resume firmware update</string>
<string name="firmware_recovery_dismiss">Dismiss firmware recovery</string>
<string name="firmware_recovery_explanation">This device is in bootloader mode from a firmware update that did not finish. Keep it nearby and it will be re-flashed to complete the update.</string>
<string name="firmware_too_old">Firmware update required.</string>
<string name="firmware_update_almost_there">Almost there...</string>
<string name="firmware_update_alpha">Alpha</string>
@@ -923,17 +923,43 @@
<string name="match_any">Match Any | All</string>
<string name="max">Max</string>
<!-- MESH -->
<string name="mesh_beacon">Mesh Beacon</string>
<string name="mesh_beacon_broadcast">Broadcast a beacon</string>
<string name="mesh_beacon_broadcast_summary">Periodically advertise this mesh to nearby nodes</string>
<string name="mesh_beacon_channels_title">Beacon channels</string>
<string name="mesh_beacon_interval">Broadcast interval (seconds)</string>
<string name="mesh_beacon_interval_error">Minimum %1$d seconds</string>
<string name="mesh_beacon_invitations_title">Mesh invitations</string>
<string name="mesh_beacon_listen">Listen for beacons</string>
<string name="mesh_beacon_listen_summary">Capture invitations advertised by nearby meshes</string>
<string name="mesh_beacon_message">Beacon message</string>
<string name="mesh_beacon_message_error">Maximum %1$d bytes</string>
<string name="mesh_beacon_notification_body">A nearby mesh invited you to join</string>
<string name="mesh_beacon_notification_title">Mesh invitation</string>
<string name="mesh_beacon_offer_add">Add channel</string>
<string name="mesh_beacon_offer_channel">Channel: %1$s</string>
<string name="mesh_beacon_offer_channel_key">Offered channel key (base64)</string>
<string name="mesh_beacon_offer_channel_name">Offered channel name</string>
<string name="mesh_beacon_offer_discover">Discover</string>
<string name="mesh_beacon_offer_dismiss">Dismiss</string>
<string name="mesh_beacon_offer_from_unknown">From an unknown node</string>
<string name="mesh_beacon_offer_join">Join</string>
<string name="mesh_beacon_offer_preset">Preset: %1$s</string>
<string name="mesh_beacon_offer_preset_setting">Offered preset</string>
<string name="mesh_beacon_offer_region">Region: %1$s</string>
<string name="mesh_beacon_offer_region_setting">Offered region</string>
<string name="mesh_beacon_offer_signal">Signal: %1$s / %2$s</string>
<string name="mesh_beacon_offer_title">Mesh invitation</string>
<string name="mesh_beacon_on_preset">Transmit preset</string>
<string name="mesh_beacon_on_region">Transmit region</string>
<string name="mesh_beacon_preset_indicator">Advertised by a nearby beacon</string>
<string name="mesh_beacon_preset_none">None</string>
<string name="mesh_beacon_send_as_node">Broadcast as node number (0 = this node)</string>
<string name="mesh_beacon_target">Target %1$d</string>
<string name="mesh_beacon_target_add">Add target</string>
<string name="mesh_beacon_target_channel_index">Channel index</string>
<string name="mesh_beacon_target_remove">Remove target</string>
<string name="mesh_beacon_targets">Broadcast targets</string>
<string name="mesh_map_location">Mesh Map Location</string>
<string name="mesh_map_location_description">Enables the blue location dot for your phone in the mesh map.</string>
<!-- MESHTASTIC -->

View File

@@ -155,8 +155,10 @@ fun ScannedQrCodeDialog(
channelSet.copy(
settings =
channelSet.settings.filterIndexed { i, _ ->
val isExisting = i < channels.settings.size
isExisting || channelSelections.getOrNull(i) == true
// Primary (index 0) is always kept; existing secondaries can be dropped to free a slot for the
// incoming channel when the radio is full (Apple FR-017 "replace a secondary, never the
// primary").
i == 0 || channelSelections.getOrNull(i) == true
},
)
}
@@ -232,13 +234,16 @@ fun ScannedQrCodeDialog(
index,
channel,
->
val isExisting = !shouldReplace && index < channels.settings.size
val isPrimary = index == 0
val channelObj = Channel(channel, channelSet.lora_config ?: Channel.default.loraConfig)
ChannelSelection(
index = index,
title = channel.name.ifEmpty { modemPresetName },
enabled = !isExisting,
isSelected = if (isExisting) true else channelSelections[index],
// Primary is always kept. In ADD mode existing secondaries become deselectable so the user can
// drop one to make room for the incoming channel on a full radio; REPLACE keeps its prior
// all-selectable behavior.
enabled = if (shouldReplace) true else !isPrimary,
isSelected = if (!shouldReplace && isPrimary) true else channelSelections[index],
onSelected = {
if (it || selectedChannelSet.settings.size > 1) {
channelSelections[index] = it

View File

@@ -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)

View File

@@ -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,
)
}

View File

@@ -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()}"
}

View File

@@ -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() }) }
}

View File

@@ -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(

View File

@@ -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,
)
}
}
}
}
}
}
}

View File

@@ -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))
}
}
}
}

View File

@@ -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
},
)
}
}

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))
}
}
}