Skip to content

Commit 9a9e9bc

Browse files
committed
feat(settings): show mesh discovery beacons
1 parent 0d07df7 commit 9a9e9bc

8 files changed

Lines changed: 360 additions & 2 deletions

File tree

.agent_memory/session_context.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@
130130
- Cleaned up leftover speed-up workarounds: completely removed the Flatpak Gradle Generator plugin application and tasks from the root project and all other library/feature subprojects (`core:ble`, `core:common`, `core:database`, `core:model`, `core:navigation`, `core:proto`, and `feature:messaging`), including deleting the unused `flatpakKmpAndroidMeta` configuration from `feature:messaging`.
131131
- Verified that local execution of `:desktopApp:flatpakGradleGenerator` runtimeClasspath resolution speed dropped from 46 seconds to 12 seconds, and all Spotless and Detekt linting checks passed.
132132

133+
## 2026-05-17 — Added provisional mesh discovery beacon support
134+
- Added `MeshDiscoveryBeacon` in `core:model` with conservative fixed-width decoding for the discovery/config beacon discussed in meshtastic/firmware#7183 and #10243.
135+
- Surfaced decoded beacons passively on the LoRa settings screen and in debug payload decoding; the app never applies radio settings automatically.
136+
- Guarded decoding to candidate discovery ports (`PRIVATE_APP` and `UNKNOWN_APP`) and accepted both sub-GHz and LORA_24 frequencies.
137+
- Verified with `:core:model:jvmTest`, `:feature:settings:compileKotlinJvm`, scoped `spotlessCheck`, and clean `codex review --base origin/main`.
138+
133139
## 2026-05-12 — Implemented Apple alignment for docs feature (FR-038)
134140
- Branch: `feat/20260507-161858-app-docs-markdown`
135141
- Gap analysis against `meshtastic-apple` completed. Implemented 4 alignment items:

.skills/compose-ui/strings-index.txt

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
@file:Suppress("MagicNumber")
18+
19+
package org.meshtastic.core.model
20+
21+
import okio.ByteString
22+
import org.meshtastic.proto.PortNum
23+
24+
/**
25+
* Compact app-side representation of the config discovery beacon proposed in meshtastic/firmware#7183 and
26+
* meshtastic/firmware#10243.
27+
*
28+
* The firmware-side protocol is still under discussion, so this parser is intentionally conservative and side-effect
29+
* free. It only accepts a tiny fixed-width payload and never applies radio settings automatically.
30+
*/
31+
data class MeshDiscoveryBeacon(
32+
val version: Int,
33+
val roleHint: RoleHint,
34+
val forwardingHint: ForwardingHint,
35+
val frequencyKHz: Int,
36+
val bandwidth: Bandwidth,
37+
val spreadingFactor: Int,
38+
val codingRate: Int,
39+
val nodeId: Int,
40+
val primaryChannelHash: Int,
41+
val primaryChannelName: String,
42+
) {
43+
val nodeIdString: String
44+
get() = DataPacket.nodeNumToDefaultId(nodeId)
45+
46+
val frequencyMHz: Float
47+
get() = frequencyKHz / KHZ_PER_MHZ
48+
49+
fun toDebugString(): String = buildString {
50+
appendLine("MeshDiscoveryBeacon:")
51+
appendLine(" version: $version")
52+
appendLine(" role_hint: $roleHint")
53+
appendLine(" forwarding_hint: $forwardingHint")
54+
appendLine(" frequency_mhz: $frequencyMHz")
55+
appendLine(" bandwidth: ${bandwidth.label}")
56+
appendLine(" spreading_factor: $spreadingFactor")
57+
appendLine(" coding_rate: 4/$codingRate")
58+
appendLine(" node_id: $nodeIdString")
59+
appendLine(" primary_channel_hash: $primaryChannelHash")
60+
appendLine(" primary_channel_name: $primaryChannelName")
61+
}
62+
63+
enum class RoleHint {
64+
MIGHT_FORWARD,
65+
WILL_FORWARD,
66+
WILL_NOT_FORWARD,
67+
UNKNOWN,
68+
}
69+
70+
enum class ForwardingHint {
71+
ALL,
72+
CORE,
73+
KNOWN,
74+
NONE,
75+
}
76+
77+
enum class Bandwidth(val label: String) {
78+
BW_31("31.25 kHz"),
79+
BW_62("62.5 kHz"),
80+
BW_125("125 kHz"),
81+
BW_250("250 kHz"),
82+
BW_500("500 kHz"),
83+
BW_812("812.5 kHz"),
84+
BW_1625("1625 kHz"),
85+
UNKNOWN("Unknown"),
86+
}
87+
88+
companion object {
89+
const val ENCODED_SIZE = 22
90+
private const val KHZ_PER_MHZ = 1000f
91+
private const val MAX_VERSION = 0
92+
private const val MIN_FREQUENCY_KHZ = 400_000
93+
private const val MAX_FREQUENCY_KHZ = 2_500_000
94+
private const val CHANNEL_NAME_BYTES = 12
95+
private const val MIN_SPREADING_FACTOR = 5
96+
private const val MIN_CODING_RATE = 5
97+
98+
fun decode(portnumValue: Int, payload: ByteString): MeshDiscoveryBeacon? {
99+
if (!isCandidatePort(portnumValue)) return null
100+
return decode(payload)
101+
}
102+
103+
fun isCandidatePort(portnumValue: Int): Boolean =
104+
portnumValue == PortNum.PRIVATE_APP.value || portnumValue == PortNum.UNKNOWN_APP.value
105+
106+
fun decode(payload: ByteString): MeshDiscoveryBeacon? {
107+
if (payload.size != ENCODED_SIZE) return null
108+
val bytes = payload.toByteArray()
109+
110+
val header = bytes[0].unsigned
111+
val version = header shr 6
112+
val reserved = (header shr 4) and 0x03
113+
if (version > MAX_VERSION || reserved != 0) return null
114+
115+
val frequencyKHz = bytes.uint24At(1)
116+
if (frequencyKHz !in MIN_FREQUENCY_KHZ..MAX_FREQUENCY_KHZ) return null
117+
118+
val radio = bytes[4].unsigned
119+
val bandwidth = Bandwidth.entries.getOrNull((radio shr 5) and 0x07) ?: Bandwidth.UNKNOWN
120+
if (bandwidth == Bandwidth.UNKNOWN) return null
121+
val spreadingFactor = ((radio shr 2) and 0x07) + MIN_SPREADING_FACTOR
122+
val codingRate = (radio and 0x03) + MIN_CODING_RATE
123+
124+
val channelName = bytes.decodeChannelName()
125+
if (channelName == null) return null
126+
127+
return MeshDiscoveryBeacon(
128+
version = version,
129+
roleHint = RoleHint.entries[(header shr 2) and 0x03],
130+
forwardingHint = ForwardingHint.entries[header and 0x03],
131+
frequencyKHz = frequencyKHz,
132+
bandwidth = bandwidth,
133+
spreadingFactor = spreadingFactor,
134+
codingRate = codingRate,
135+
nodeId = bytes.int32At(5),
136+
primaryChannelHash = bytes[9].unsigned,
137+
primaryChannelName = channelName,
138+
)
139+
}
140+
141+
private val Byte.unsigned: Int
142+
get() = toInt() and 0xff
143+
144+
private fun ByteArray.uint24At(offset: Int): Int =
145+
(this[offset].unsigned shl 16) or (this[offset + 1].unsigned shl 8) or this[offset + 2].unsigned
146+
147+
private fun ByteArray.int32At(offset: Int): Int = (this[offset].unsigned shl 24) or
148+
(this[offset + 1].unsigned shl 16) or
149+
(this[offset + 2].unsigned shl 8) or
150+
this[offset + 3].unsigned
151+
152+
private fun ByteArray.decodeChannelName(): String? {
153+
val end = indexOfFirstZero(startIndex = 10).takeIf { it >= 0 } ?: ENCODED_SIZE
154+
if (end - 10 > CHANNEL_NAME_BYTES) return null
155+
val nameBytes = copyOfRange(10, end)
156+
if (nameBytes.any { it.unsigned !in 0x20..0x7e }) return null
157+
return nameBytes.decodeToString()
158+
}
159+
160+
private fun ByteArray.indexOfFirstZero(startIndex: Int): Int {
161+
for (index in startIndex until ENCODED_SIZE) {
162+
if (this[index] == 0.toByte()) return index
163+
}
164+
return -1
165+
}
166+
}
167+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
@file:Suppress("MagicNumber")
18+
19+
package org.meshtastic.core.model
20+
21+
import okio.ByteString.Companion.toByteString
22+
import org.meshtastic.proto.PortNum
23+
import kotlin.test.Test
24+
import kotlin.test.assertEquals
25+
import kotlin.test.assertNull
26+
27+
class MeshDiscoveryBeaconTest {
28+
@Test
29+
fun decode_validPayload() {
30+
val payload = discoveryPayload()
31+
32+
val beacon = MeshDiscoveryBeacon.decode(PortNum.PRIVATE_APP.value, payload)
33+
34+
requireNotNull(beacon)
35+
assertEquals(0, beacon.version)
36+
assertEquals(MeshDiscoveryBeacon.RoleHint.WILL_FORWARD, beacon.roleHint)
37+
assertEquals(MeshDiscoveryBeacon.ForwardingHint.CORE, beacon.forwardingHint)
38+
assertEquals(915_000, beacon.frequencyKHz)
39+
assertEquals(915f, beacon.frequencyMHz)
40+
assertEquals(MeshDiscoveryBeacon.Bandwidth.BW_250, beacon.bandwidth)
41+
assertEquals(7, beacon.spreadingFactor)
42+
assertEquals(5, beacon.codingRate)
43+
assertEquals(0x12345678, beacon.nodeId)
44+
assertEquals("!12345678", beacon.nodeIdString)
45+
assertEquals(0x5a, beacon.primaryChannelHash)
46+
assertEquals("ShortFast", beacon.primaryChannelName)
47+
}
48+
49+
@Test
50+
fun decode_rejectsWrongSize() {
51+
assertNull(MeshDiscoveryBeacon.decode(byteArrayOf(0).toByteString()))
52+
}
53+
54+
@Test
55+
fun decode_rejectsNonCandidatePort() {
56+
assertNull(MeshDiscoveryBeacon.decode(PortNum.LORAWAN_BRIDGE.value, discoveryPayload()))
57+
}
58+
59+
@Test
60+
fun decode_acceptsLora24Frequency() {
61+
val payload = discoveryPayload().toByteArray()
62+
payload[1] = 0x25
63+
payload[2] = 0x16
64+
payload[3] = 0xa0.toByte() // 2430624 kHz
65+
66+
val beacon = MeshDiscoveryBeacon.decode(PortNum.PRIVATE_APP.value, payload.toByteString())
67+
68+
requireNotNull(beacon)
69+
assertEquals(2_430_624, beacon.frequencyKHz)
70+
}
71+
72+
@Test
73+
fun decode_rejectsReservedHeaderBits() {
74+
val payload = discoveryPayload().toByteArray()
75+
payload[0] = 0b0001_0000
76+
77+
assertNull(MeshDiscoveryBeacon.decode(payload.toByteString()))
78+
}
79+
80+
@Test
81+
fun decode_rejectsImplausibleFrequency() {
82+
val payload = discoveryPayload().toByteArray()
83+
payload[1] = 0
84+
payload[2] = 0
85+
payload[3] = 1
86+
87+
assertNull(MeshDiscoveryBeacon.decode(payload.toByteString()))
88+
}
89+
90+
@Test
91+
fun decode_rejectsNonAsciiChannelName() {
92+
val payload = discoveryPayload().toByteArray()
93+
payload[10] = 0x01
94+
95+
assertNull(MeshDiscoveryBeacon.decode(payload.toByteString()))
96+
}
97+
98+
private fun discoveryPayload(): okio.ByteString {
99+
val payload = ByteArray(MeshDiscoveryBeacon.ENCODED_SIZE)
100+
payload[0] = 0b0000_0101 // version 0, role WILL_FORWARD, forwarding CORE
101+
payload[1] = 0x0d
102+
payload[2] = 0xf6.toByte()
103+
payload[3] = 0x38 // 915000 kHz
104+
payload[4] = 0b0110_1000 // 250 kHz, SF7, CR 4/5
105+
payload[5] = 0x12
106+
payload[6] = 0x34
107+
payload[7] = 0x56
108+
payload[8] = 0x78
109+
payload[9] = 0x5a
110+
"ShortFast".encodeToByteArray().copyInto(payload, destinationOffset = 10)
111+
return payload.toByteString()
112+
}
113+
}

core/resources/src/commonMain/composeResources/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,10 @@
740740
<string name="match_all">Match All | Any</string>
741741
<string name="match_any">Match Any | All</string>
742742
<string name="max">Max</string>
743+
<!-- MESH -->
744+
<string name="mesh_discovery_beacon_summary">%1$s MHz • %2$s • SF%3$d CR4/%4$d • %5$s • %6$s</string>
745+
<string name="mesh_discovery_beacons">Nearby mesh beacons</string>
746+
<string name="mesh_discovery_beacons_summary">These passive beacons advertise nearby mesh settings. Review them before changing your radio configuration.</string>
743747
<string name="mesh_map_location">Mesh Map Location</string>
744748
<string name="mesh_map_location_description">Enables the blue location dot for your phone in the mesh map.</string>
745749
<!-- MESHTASTIC -->

feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import org.meshtastic.core.common.util.DateFormatter
3434
import org.meshtastic.core.common.util.ioDispatcher
3535
import org.meshtastic.core.common.util.nowInstant
3636
import org.meshtastic.core.database.entity.Packet
37+
import org.meshtastic.core.model.MeshDiscoveryBeacon
3738
import org.meshtastic.core.model.MeshLog
3839
import org.meshtastic.core.model.getTracerouteResponse
3940
import org.meshtastic.core.model.util.decodeOrNull
@@ -495,7 +496,9 @@ class DebugViewModel(
495496

496497
PortNum.TRACEROUTE_APP.value -> decodeTraceroute(packet, payload)
497498

498-
else -> payload.joinToString(" ") { it.toHex() }
499+
else ->
500+
MeshDiscoveryBeacon.decode(portnumValue, decoded.payload)?.toDebugString()
501+
?: payload.joinToString(" ") { it.toHex() }
499502
}
500503
} catch (e: Exception) {
501504
"Failed to decode payload: ${e.message}"

feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
4949
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
5050
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
5151
import org.meshtastic.core.model.ConnectionState
52+
import org.meshtastic.core.model.MeshDiscoveryBeacon
5253
import org.meshtastic.core.model.MqttConnectionState
5354
import org.meshtastic.core.model.MqttProbeStatus
5455
import org.meshtastic.core.model.MyNodeInfo
@@ -109,6 +110,7 @@ data class RadioConfigState(
109110
val analyticsAvailable: Boolean = true,
110111
val analyticsEnabled: Boolean = true,
111112
val nodeDbResetPreserveFavorites: Boolean = false,
113+
val meshDiscoveryBeacons: List<MeshDiscoveryBeacon> = emptyList(),
112114
)
113115

114116
@KoinViewModel
@@ -259,7 +261,12 @@ open class RadioConfigViewModel(
259261
.onEach { manifest -> _radioConfigState.update { it.copy(fileManifest = manifest) } }
260262
.launchIn(viewModelScope)
261263

262-
serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope)
264+
serviceRepository.meshPacketFlow
265+
.onEach {
266+
processPacketResponse(it)
267+
processMeshDiscoveryBeacon(it)
268+
}
269+
.launchIn(viewModelScope)
263270

264271
combine(serviceRepository.connectionState, radioConfigState) { connState, _ ->
265272
_radioConfigState.update { it.copy(connected = connState == ConnectionState.Connected) }
@@ -776,4 +783,22 @@ open class RadioConfigViewModel(
776783
}
777784
}
778785
}
786+
787+
private fun processMeshDiscoveryBeacon(packet: MeshPacket) {
788+
val decoded = packet.decoded ?: return
789+
val beacon = MeshDiscoveryBeacon.decode(decoded.portnum.value, decoded.payload) ?: return
790+
_radioConfigState.update { state ->
791+
val withoutPrevious =
792+
state.meshDiscoveryBeacons.filterNot {
793+
it.nodeId == beacon.nodeId &&
794+
it.primaryChannelHash == beacon.primaryChannelHash &&
795+
it.primaryChannelName == beacon.primaryChannelName
796+
}
797+
state.copy(meshDiscoveryBeacons = (listOf(beacon) + withoutPrevious).take(MAX_DISCOVERY_BEACONS))
798+
}
799+
}
800+
801+
companion object {
802+
private const val MAX_DISCOVERY_BEACONS = 8
803+
}
779804
}

0 commit comments

Comments
 (0)