mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-10 05:15:46 -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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
*
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user