diff --git a/.agent_memory/session_context.md b/.agent_memory/session_context.md index cb07fa3664..6c7a1f5c1c 100644 --- a/.agent_memory/session_context.md +++ b/.agent_memory/session_context.md @@ -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: diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index f6884c99d5..80fe827193 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -710,6 +710,10 @@ mark_as_read match_all match_any max +### MESH ### +mesh_discovery_beacon_summary +mesh_discovery_beacons +mesh_discovery_beacons_summary mesh_map_location mesh_map_location_description ### MESHTASTIC ### diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshDiscoveryBeacon.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshDiscoveryBeacon.kt new file mode 100644 index 0000000000..1aa86f0663 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshDiscoveryBeacon.kt @@ -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 . + */ +@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 + } + } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/MeshDiscoveryBeaconTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/MeshDiscoveryBeaconTest.kt new file mode 100644 index 0000000000..400f37fe62 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/MeshDiscoveryBeaconTest.kt @@ -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 . + */ +@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() + } +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 6c59d355d7..08e387ba2d 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -740,6 +740,10 @@ Match All | Any Match Any | All Max + + %1$s MHz • %2$s • SF%3$d CR4/%4$d • %5$s • %6$s + Nearby mesh beacons + These passive beacons advertise nearby mesh settings. Review them before changing your radio configuration. Mesh Map Location Enables the blue location dot for your phone in the mesh map. diff --git a/docs/en/user/settings-radio-user.md b/docs/en/user/settings-radio-user.md index 7876ba4069..8ab0e89003 100644 --- a/docs/en/user/settings-radio-user.md +++ b/docs/en/user/settings-radio-user.md @@ -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 @@ -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 | @@ -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 --- - diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 0a72e1545d..c2a93c517e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -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 @@ -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}" diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 4b9fdcdd1b..35b951099e 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -49,6 +49,7 @@ import org.meshtastic.core.domain.usecase.settings.RadioResponseResult import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.MeshDiscoveryBeacon import org.meshtastic.core.model.MqttConnectionState import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.MyNodeInfo @@ -109,6 +110,7 @@ data class RadioConfigState( val analyticsAvailable: Boolean = true, val analyticsEnabled: Boolean = true, val nodeDbResetPreserveFavorites: Boolean = false, + val meshDiscoveryBeacons: List = emptyList(), ) @KoinViewModel @@ -259,7 +261,12 @@ open class RadioConfigViewModel( .onEach { manifest -> _radioConfigState.update { it.copy(fileManifest = manifest) } } .launchIn(viewModelScope) - serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope) + serviceRepository.meshPacketFlow + .onEach { + processPacketResponse(it) + processMeshDiscoveryBeacon(it) + } + .launchIn(viewModelScope) combine(serviceRepository.connectionState, radioConfigState) { connState, _ -> _radioConfigState.update { it.copy(connected = connState == ConnectionState.Connected) } @@ -776,4 +783,22 @@ open class RadioConfigViewModel( } } } + + private fun processMeshDiscoveryBeacon(packet: MeshPacket) { + val decoded = packet.decoded ?: return + val beacon = MeshDiscoveryBeacon.decode(decoded.portnum.value, decoded.payload) ?: return + _radioConfigState.update { state -> + val withoutPrevious = + state.meshDiscoveryBeacons.filterNot { + it.nodeId == beacon.nodeId && + it.primaryChannelHash == beacon.primaryChannelHash && + it.primaryChannelName == beacon.primaryChannelName + } + state.copy(meshDiscoveryBeacons = (listOf(beacon) + withoutPrevious).take(MAX_DISCOVERY_BEACONS)) + } + } + + companion object { + private const val MAX_DISCOVERY_BEACONS = 8 + } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt index ced63fff66..13a5c95be5 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt @@ -27,8 +27,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalFocusManager import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.model.Channel import org.meshtastic.core.model.ChannelOption +import org.meshtastic.core.model.MeshDiscoveryBeacon import org.meshtastic.core.model.RegionInfo import org.meshtastic.core.model.numChannels import org.meshtastic.core.resources.Res @@ -43,6 +45,9 @@ import org.meshtastic.core.resources.frequency_slot import org.meshtastic.core.resources.hop_limit import org.meshtastic.core.resources.ignore_mqtt import org.meshtastic.core.resources.lora +import org.meshtastic.core.resources.mesh_discovery_beacon_summary +import org.meshtastic.core.resources.mesh_discovery_beacons +import org.meshtastic.core.resources.mesh_discovery_beacons_summary import org.meshtastic.core.resources.modem_preset import org.meshtastic.core.resources.ok_to_mqtt import org.meshtastic.core.resources.options @@ -57,6 +62,7 @@ import org.meshtastic.core.resources.tx_power_dbm import org.meshtastic.core.resources.use_modem_preset import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.EditTextPreference +import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.SignedIntegerEditTextPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.TitledCard @@ -89,6 +95,10 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { viewModel.setConfig(config) }, ) { + if (state.meshDiscoveryBeacons.isNotEmpty()) { + item { MeshDiscoveryBeaconCard(beacons = state.meshDiscoveryBeacons) } + } + item { TitledCard(title = stringResource(Res.string.options)) { DropDownPreference( @@ -280,3 +290,29 @@ private fun ManualModemSettings( ) } } + +@Composable +private fun MeshDiscoveryBeaconCard(beacons: List) { + TitledCard(title = stringResource(Res.string.mesh_discovery_beacons)) { + ListItem(text = stringResource(Res.string.mesh_discovery_beacons_summary), trailingIcon = null) + beacons.forEach { beacon -> + HorizontalDivider() + val frequency = NumberFormatter.format(beacon.frequencyMHz, 3) + ListItem( + text = beacon.primaryChannelName.ifBlank { beacon.nodeIdString }, + supportingText = + stringResource( + Res.string.mesh_discovery_beacon_summary, + frequency, + beacon.bandwidth.label, + beacon.spreadingFactor, + beacon.codingRate, + beacon.forwardingHint.name, + beacon.nodeIdString, + ), + trailingIcon = null, + copyable = true, + ) + } + } +}