Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .agent_memory/session_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@
- 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`.
- 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.

## 2026-05-17 — Added provisional mesh discovery beacon support
- Added `MeshDiscoveryBeacon` in `core:model` with conservative fixed-width decoding for the discovery/config beacon discussed in meshtastic/firmware#7183 and #10243.
- Surfaced decoded beacons passively on the LoRa settings screen and in debug payload decoding; the app never applies radio settings automatically.
- Guarded decoding to candidate discovery ports (`PRIVATE_APP` and `UNKNOWN_APP`) and accepted both sub-GHz and LORA_24 frequencies.
- Verified with `:core:model:jvmTest`, `:feature:settings:compileKotlinJvm`, scoped `spotlessCheck`, and clean `codex review --base origin/main`.

## 2026-05-12 — Implemented Apple alignment for docs feature (FR-038)
- Branch: `feat/20260507-161858-app-docs-markdown`
- Gap analysis against `meshtastic-apple` completed. Implemented 4 alignment items:
Expand Down
4 changes: 4 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")

package org.meshtastic.core.model

import okio.ByteString
import org.meshtastic.proto.PortNum

/**
* Compact app-side representation of the config discovery beacon proposed in meshtastic/firmware#7183 and
* meshtastic/firmware#10243.
*
* The firmware-side protocol is still under discussion, so this parser is intentionally conservative and side-effect
* free. It only accepts a tiny fixed-width payload and never applies radio settings automatically.
*/
data class MeshDiscoveryBeacon(
val version: Int,
val roleHint: RoleHint,
val forwardingHint: ForwardingHint,
val frequencyKHz: Int,
val bandwidth: Bandwidth,
val spreadingFactor: Int,
val codingRate: Int,
val nodeId: Int,
val primaryChannelHash: Int,
val primaryChannelName: String,
) {
val nodeIdString: String
get() = DataPacket.nodeNumToDefaultId(nodeId)

val frequencyMHz: Float
get() = frequencyKHz / KHZ_PER_MHZ

fun toDebugString(): String = buildString {
appendLine("MeshDiscoveryBeacon:")
appendLine(" version: $version")
appendLine(" role_hint: $roleHint")
appendLine(" forwarding_hint: $forwardingHint")
appendLine(" frequency_mhz: $frequencyMHz")
appendLine(" bandwidth: ${bandwidth.label}")
appendLine(" spreading_factor: $spreadingFactor")
appendLine(" coding_rate: 4/$codingRate")
appendLine(" node_id: $nodeIdString")
appendLine(" primary_channel_hash: $primaryChannelHash")
appendLine(" primary_channel_name: $primaryChannelName")
}

enum class RoleHint {
MIGHT_FORWARD,
WILL_FORWARD,
WILL_NOT_FORWARD,
UNKNOWN,
}

enum class ForwardingHint {
ALL,
CORE,
KNOWN,
NONE,
}

enum class Bandwidth(val label: String) {
BW_31("31.25 kHz"),
BW_62("62.5 kHz"),
BW_125("125 kHz"),
BW_250("250 kHz"),
BW_500("500 kHz"),
BW_812("812.5 kHz"),
BW_1625("1625 kHz"),
UNKNOWN("Unknown"),
}

companion object {
const val ENCODED_SIZE = 22
private const val KHZ_PER_MHZ = 1000f
private const val MAX_VERSION = 0
private const val MIN_FREQUENCY_KHZ = 400_000
private const val MAX_FREQUENCY_KHZ = 2_500_000
private const val CHANNEL_NAME_BYTES = 12
private const val MIN_SPREADING_FACTOR = 5
private const val MIN_CODING_RATE = 5

fun decode(portnumValue: Int, payload: ByteString): MeshDiscoveryBeacon? {
if (!isCandidatePort(portnumValue)) return null
return decode(payload)
}

fun isCandidatePort(portnumValue: Int): Boolean =
portnumValue == PortNum.PRIVATE_APP.value || portnumValue == PortNum.UNKNOWN_APP.value

fun decode(payload: ByteString): MeshDiscoveryBeacon? {
if (payload.size != ENCODED_SIZE) return null
val bytes = payload.toByteArray()

val header = bytes[0].unsigned
val version = header shr 6
val reserved = (header shr 4) and 0x03
if (version > MAX_VERSION || reserved != 0) return null

val frequencyKHz = bytes.uint24At(1)
if (frequencyKHz !in MIN_FREQUENCY_KHZ..MAX_FREQUENCY_KHZ) return null

val radio = bytes[4].unsigned
val bandwidth = Bandwidth.entries.getOrNull((radio shr 5) and 0x07) ?: Bandwidth.UNKNOWN
if (bandwidth == Bandwidth.UNKNOWN) return null
val spreadingFactor = ((radio shr 2) and 0x07) + MIN_SPREADING_FACTOR
val codingRate = (radio and 0x03) + MIN_CODING_RATE

val channelName = bytes.decodeChannelName()
if (channelName == null) return null

return MeshDiscoveryBeacon(
version = version,
roleHint = RoleHint.entries[(header shr 2) and 0x03],
forwardingHint = ForwardingHint.entries[header and 0x03],
frequencyKHz = frequencyKHz,
bandwidth = bandwidth,
spreadingFactor = spreadingFactor,
codingRate = codingRate,
nodeId = bytes.int32At(5),
primaryChannelHash = bytes[9].unsigned,
primaryChannelName = channelName,
)
}

private val Byte.unsigned: Int
get() = toInt() and 0xff

private fun ByteArray.uint24At(offset: Int): Int =
(this[offset].unsigned shl 16) or (this[offset + 1].unsigned shl 8) or this[offset + 2].unsigned

private fun ByteArray.int32At(offset: Int): Int = (this[offset].unsigned shl 24) or
(this[offset + 1].unsigned shl 16) or
(this[offset + 2].unsigned shl 8) or
this[offset + 3].unsigned

private fun ByteArray.decodeChannelName(): String? {
val end = indexOfFirstZero(startIndex = 10).takeIf { it >= 0 } ?: ENCODED_SIZE
if (end - 10 > CHANNEL_NAME_BYTES) return null
val nameBytes = copyOfRange(10, end)
if (nameBytes.any { it.unsigned !in 0x20..0x7e }) return null
return nameBytes.decodeToString()
}

private fun ByteArray.indexOfFirstZero(startIndex: Int): Int {
for (index in startIndex until ENCODED_SIZE) {
if (this[index] == 0.toByte()) return index
}
return -1
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")

package org.meshtastic.core.model

import okio.ByteString.Companion.toByteString
import org.meshtastic.proto.PortNum
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class MeshDiscoveryBeaconTest {
@Test
fun decode_validPayload() {
val payload = discoveryPayload()

val beacon = MeshDiscoveryBeacon.decode(PortNum.PRIVATE_APP.value, payload)

requireNotNull(beacon)
assertEquals(0, beacon.version)
assertEquals(MeshDiscoveryBeacon.RoleHint.WILL_FORWARD, beacon.roleHint)
assertEquals(MeshDiscoveryBeacon.ForwardingHint.CORE, beacon.forwardingHint)
assertEquals(915_000, beacon.frequencyKHz)
assertEquals(915f, beacon.frequencyMHz)
assertEquals(MeshDiscoveryBeacon.Bandwidth.BW_250, beacon.bandwidth)
assertEquals(7, beacon.spreadingFactor)
assertEquals(5, beacon.codingRate)
assertEquals(0x12345678, beacon.nodeId)
assertEquals("!12345678", beacon.nodeIdString)
assertEquals(0x5a, beacon.primaryChannelHash)
assertEquals("ShortFast", beacon.primaryChannelName)
}

@Test
fun decode_rejectsWrongSize() {
assertNull(MeshDiscoveryBeacon.decode(byteArrayOf(0).toByteString()))
}

@Test
fun decode_rejectsNonCandidatePort() {
assertNull(MeshDiscoveryBeacon.decode(PortNum.LORAWAN_BRIDGE.value, discoveryPayload()))
}

@Test
fun decode_acceptsLora24Frequency() {
val payload = discoveryPayload().toByteArray()
payload[1] = 0x25
payload[2] = 0x16
payload[3] = 0xa0.toByte() // 2430624 kHz

val beacon = MeshDiscoveryBeacon.decode(PortNum.PRIVATE_APP.value, payload.toByteString())

requireNotNull(beacon)
assertEquals(2_430_624, beacon.frequencyKHz)
}

@Test
fun decode_rejectsReservedHeaderBits() {
val payload = discoveryPayload().toByteArray()
payload[0] = 0b0001_0000

assertNull(MeshDiscoveryBeacon.decode(payload.toByteString()))
}

@Test
fun decode_rejectsImplausibleFrequency() {
val payload = discoveryPayload().toByteArray()
payload[1] = 0
payload[2] = 0
payload[3] = 1

assertNull(MeshDiscoveryBeacon.decode(payload.toByteString()))
}

@Test
fun decode_rejectsNonAsciiChannelName() {
val payload = discoveryPayload().toByteArray()
payload[10] = 0x01

assertNull(MeshDiscoveryBeacon.decode(payload.toByteString()))
}

private fun discoveryPayload(): okio.ByteString {
val payload = ByteArray(MeshDiscoveryBeacon.ENCODED_SIZE)
payload[0] = 0b0000_0101 // version 0, role WILL_FORWARD, forwarding CORE
payload[1] = 0x0d
payload[2] = 0xf6.toByte()
payload[3] = 0x38 // 915000 kHz
payload[4] = 0b0110_1000 // 250 kHz, SF7, CR 4/5
payload[5] = 0x12
payload[6] = 0x34
payload[7] = 0x56
payload[8] = 0x78
payload[9] = 0x5a
"ShortFast".encodeToByteArray().copyInto(payload, destinationOffset = 10)
return payload.toByteString()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,10 @@
<string name="match_all">Match All | Any</string>
<string name="match_any">Match Any | All</string>
<string name="max">Max</string>
<!-- MESH -->
<string name="mesh_discovery_beacon_summary">%1$s MHz • %2$s • SF%3$d CR4/%4$d • %5$s • %6$s</string>
<string name="mesh_discovery_beacons">Nearby mesh beacons</string>
<string name="mesh_discovery_beacons_summary">These passive beacons advertise nearby mesh settings. Review them before changing your radio configuration.</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 -->
Expand Down
9 changes: 7 additions & 2 deletions docs/en/user/settings-radio-user.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Settings — Radio & User
parent: User Guide
nav_order: 7
last_updated: 2026-05-20
last_updated: 2026-05-24
description: Configure your radio hardware, LoRa presets, user profile, position sharing, power management, and security.
aliases:
- settings
Expand Down Expand Up @@ -53,6 +53,12 @@ After modifying settings, tap **Save** to write the configuration to your radio.

> ⚠️ **Important:** You **must** set your region before transmitting. Operating without the correct region may violate local radio regulations. See the [region configuration guide](https://meshtastic.org/docs/getting-started/initial-config) on meshtastic.org for details.

#### Mesh Discovery Beacons

When compatible firmware sends mesh discovery beacons nearby, the app can show them at the top of **LoRa Config**. Each beacon summarizes the transmitting node, primary channel name, frequency, bandwidth, spreading factor, coding rate, and forwarding hint.

Discovery beacons are informational only. Review them as nearby mesh context; the app does not automatically change your radio configuration from a received beacon.

### Modem Presets

| Preset | Range | Speed | SNR Limit | Best For |
Expand Down Expand Up @@ -171,4 +177,3 @@ Settings use standard preference controls — dropdowns, toggles, and sliders:
- [Initial configuration](https://meshtastic.org/docs/getting-started/initial-config) — region setup guide on meshtastic.org

---

Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.MeshDiscoveryBeacon
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.decodeOrNull
Expand Down Expand Up @@ -495,7 +496,9 @@ class DebugViewModel(

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

else -> payload.joinToString(" ") { it.toHex() }
else ->
MeshDiscoveryBeacon.decode(portnumValue, decoded.payload)?.toDebugString()
?: payload.joinToString(" ") { it.toHex() }
}
} catch (e: Exception) {
"Failed to decode payload: ${e.message}"
Expand Down
Loading