Skip to content
Open
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
3 changes: 3 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.

22 changes: 22 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,25 @@ plugins.withId("org.meshtastic.flatpak.sources") {
dependencies {
dokkaPlugin(libs.dokka.android.documentation.plugin)
}

// ─── TEMPORARY: protobufs develop-SNAPSHOT preview (PR #5834) ────────────────────────────────────
// We track the unreleased protobufs develop-SNAPSHOT for the LoRa region→preset map (#951).
// takpacket-sdk:0.7.0 STILL transitively pins protobufs:2.7.25 (verified against its POM), and
// Gradle ranks 2.7.25 > develop-SNAPSHOT (a numeric part outranks the "develop" string qualifier).
// That downgrades the test *runtime* classpath to 2.7.25 while the common-metadata *compile* uses
// the snapshot, yielding NoSuchFieldError/NoSuchMethodError on the proto-generated classes at test
// runtime (assembleDebug/detekt don't catch it; test/allTests do). Force every protobufs* variant
// to the snapshot so compile and runtime agree. Safe because atak.proto is unchanged, so takpacket's
// own message ABI stays compatible with the newer protobufs.
// REMOVE once protobufs cuts a tagged release containing #951 (re-pin the catalog to it) — and,
// ideally, once takpacket-sdk is republished against that release.
allprojects {
configurations.all {
resolutionStrategy.eachDependency {
if (requested.group == "org.meshtastic" && requested.name.startsWith("protobufs")) {
useVersion("develop-SNAPSHOT")
because("preview #5834: override takpacket transitive protobufs:2.7.25 pin")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class FromRadioPacketHandlerImpl(
val clientNotification = proto.clientNotification
val deviceUIConfig = proto.deviceuiConfig
val fileInfo = proto.fileInfo
val regionPresets = proto.region_presets
val xmodemPacket = proto.xmodemPacket

when {
Expand Down Expand Up @@ -102,6 +103,10 @@ class FromRadioPacketHandlerImpl(

fileInfo != null -> configFlowManager.value.handleFileInfo(fileInfo)

// region_presets arrives during the handshake (after metadata, before channels). It tells the client
// which modem presets are legal per LoRa region. Absent on firmware < 2.8.
regionPresets != null -> configHandler.value.handleRegionPresets(regionPresets)

xmodemPacket != null -> xmodemManager.value.handleIncomingXModem(xmodemPacket)

clientNotification != null -> handleClientNotification(clientNotification)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ class MeshConfigFlowManagerImpl(
radioConfigRepository.clearLocalModuleConfig()
radioConfigRepository.clearDeviceUIConfig()
radioConfigRepository.clearFileManifest()
radioConfigRepository.clearLoraRegionPresetMap()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.LoRaRegionPresetMap
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
Expand Down Expand Up @@ -89,6 +90,11 @@ class MeshConfigHandlerImpl(
Logger.d { "DeviceUI config received" }
scope.handledLaunch { radioConfigRepository.setDeviceUIConfig(config) }
}

override fun handleRegionPresets(map: LoRaRegionPresetMap) {
Logger.d { "Region presets received (${map.region_groups.size} regions, ${map.groups.size} groups)" }
scope.handledLaunch { radioConfigRepository.setLoraRegionPresetMap(map) }
}
}

/** Returns a short summary of which Config variant is set. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.LoRaRegionPresetMap
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
Expand All @@ -43,6 +44,7 @@ import org.meshtastic.proto.ModuleConfig
* [LocalModuleConfig].
*/
@Single
@Suppress("TooManyFunctions") // All functions mandated by RadioConfigRepository interface; logically grouped by concern
open class RadioConfigRepositoryImpl(
private val nodeDB: NodeRepository,
private val channelSetDataSource: ChannelSetDataSource,
Expand Down Expand Up @@ -131,6 +133,18 @@ open class RadioConfigRepositoryImpl(
_fileManifestFlow.value = emptyList()
}

// Region→preset compatibility map is session-scoped: delivered once per handshake, cleared on each new handshake.
private val _loraRegionPresetMapFlow = MutableStateFlow<LoRaRegionPresetMap?>(null)
override val loraRegionPresetMapFlow: Flow<LoRaRegionPresetMap?> = _loraRegionPresetMapFlow.asStateFlow()

override suspend fun setLoraRegionPresetMap(map: LoRaRegionPresetMap) {
_loraRegionPresetMapFlow.value = map
}

override suspend fun clearLoraRegionPresetMap() {
_loraRegionPresetMapFlow.value = null
}

/** Flow representing the combined [DeviceProfile] protobuf. */
override val deviceProfileFlow: Flow<DeviceProfile> =
combine(nodeDB.ourNodeInfo, channelSetFlow, localConfigFlow, moduleConfigFlow) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LoRaRegionPresetMap
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.MyNodeInfo
Expand Down Expand Up @@ -150,6 +151,16 @@ class FromRadioPacketHandlerImplTest {
verify { configHandler.handleChannel(channel) }
}

@Test
fun `handleFromRadio routes REGION_PRESETS to configHandler`() {
val map = LoRaRegionPresetMap()
val proto = FromRadio(region_presets = map)

handler.handleFromRadio(proto)

verify { configHandler.handleRegionPresets(map) }
}

@Test
fun `handleFromRadio routes MQTT_CLIENT_PROXY_MESSAGE to mqttManager`() {
val proxyMsg = MqttClientProxyMessage(topic = "test/topic")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ class MeshConfigFlowManagerImplTest {
verifySuspend { radioConfigRepository.clearLocalModuleConfig() }
verifySuspend { radioConfigRepository.clearDeviceUIConfig() }
verifySuspend { radioConfigRepository.clearFileManifest() }
verifySuspend { radioConfigRepository.clearLoraRegionPresetMap() }
}

// ---------- handleLocalMetadata ----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.LoRaRegionPresetMap
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
Expand Down Expand Up @@ -228,4 +229,16 @@ class MeshConfigHandlerImplTest {

verifySuspend { radioConfigRepository.setDeviceUIConfig(config) }
}

// ---------- handleRegionPresets ----------

@Test
fun `handleRegionPresets persists map`() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val map = LoRaRegionPresetMap()
handler.handleRegionPresets(map)
advanceUntilIdle()

verifySuspend { radioConfigRepository.setLoraRegionPresetMap(map) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon
get() =
settings.name.ifEmpty {
// We have a new style 'empty' channel name. Use the same logic from the device to convert that to a
// human readable name
// human readable name.
//
// INTEROP-CRITICAL: these strings must byte-match the firmware, because the channel hash, channel
// number / radio frequency, and MQTT topic all derive from this name (see `hash`, `channelNum`).
// Source of truth: firmware DisplayFormatters::getModemPresetDisplayName(preset, useShortName=false).
// Two names are deliberately abbreviated (LONG_MODERATE -> "LongMod", VERY_LONG_SLOW -> "VLongSlow"),
// so do NOT auto-derive from the enum name. This `when` is intentionally exhaustive with no `else`: a
// new firmware preset SHOULD break the build here, forcing someone to copy its exact firmware name.
if (loraConfig.use_preset) {
when (loraConfig.modem_preset) {
ModemPreset.SHORT_TURBO -> "ShortTurbo"
Expand All @@ -84,6 +91,8 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon
ModemPreset.LITE_SLOW -> "LiteSlow"
ModemPreset.NARROW_FAST -> "NarrowFast"
ModemPreset.NARROW_SLOW -> "NarrowSlow"
ModemPreset.TINY_FAST -> "TinyFast"
ModemPreset.TINY_SLOW -> "TinySlow"
}
} else {
"Custom"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,11 @@ enum class ChannelOption(val modemPreset: ModemPreset, val bandwidth: Float) {
LITE_SLOW(ModemPreset.LITE_SLOW, 0.125f),
NARROW_FAST(ModemPreset.NARROW_FAST, 0.0625f),
NARROW_SLOW(ModemPreset.NARROW_SLOW, 0.0625f),

// 15.625 kHz LoRa bandwidth (firmware modemPresetToParams; the proto's "20kHz" is the
// padded channel spacing, not the modem bandwidth used for numChannels/radioFreq math).
TINY_FAST(ModemPreset.TINY_FAST, 0.015625f),
TINY_SLOW(ModemPreset.TINY_SLOW, 0.015625f),
;

companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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

import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
import org.meshtastic.proto.LoRaRegionPresetMap

/**
* The modem presets the firmware advertised as legal for one LoRa region, decoded from a [LoRaRegionPresetMap].
*
* @property presets the presets that are legal in the region.
* @property defaultPreset the firmware's default preset for the region (always one of [presets]).
* @property licensedOnly true when the region's presets are for licensed operators only (e.g. amateur bands); the whole
* group is gated, not individual presets.
*/
data class RegionPresetConstraint(
val presets: List<ModemPreset>,
val defaultPreset: ModemPreset,
val licensedOnly: Boolean,
) {
/** True when an operator with the given licensing state may not select any preset in this region. */
fun isGated(isLicensed: Boolean): Boolean = licensedOnly && !isLicensed
}

/**
* Resolves the [RegionPresetConstraint] the firmware advertised for [region], or `null` when there is no constraint
* information and the client must therefore NOT restrict the preset list. A `null` result happens when:
* - the map is `null` (firmware older than 2.8 never sends it),
* - [region] is absent from `region_groups` (no firmware table entry — treated as unconstrained),
* - the referenced `group_index` is out of range (defensive against a malformed map), or
* - the referenced group has no presets (a degenerate/malformed group — must not collapse the picker to nothing).
*/
@Suppress("ReturnCount") // Guard clauses for defensive null checks and missing lookups are idiomatic
fun LoRaRegionPresetMap?.constraintFor(region: RegionCode): RegionPresetConstraint? {
if (this == null) return null
val entry = region_groups.firstOrNull { it.region == region } ?: return null
val group = groups.getOrNull(entry.group_index)?.takeIf { it.presets.isNotEmpty() } ?: return null
return RegionPresetConstraint(
presets = group.presets,
defaultPreset = group.default_preset,
licensedOnly = group.licensed_only,
)
}

/**
* Returns the modem preset the LoRa form should hold for [region] given the currently-selected [current] preset.
*
* Keeps [current] when it is still legal in [region]; otherwise falls back to the region's default preset (and, if that
* is somehow not in the legal set, the first legal preset). Returns [current] unchanged when [region] is unconstrained.
* Licensing is intentionally NOT considered here — auto-swap is about legality; selectability is decided separately by
* [RegionPresetConstraint.isGated].
*/
fun LoRaRegionPresetMap?.repairPresetFor(region: RegionCode, current: ModemPreset): ModemPreset {
val constraint = constraintFor(region) ?: return current
return when {
current in constraint.presets -> current
constraint.defaultPreset in constraint.presets -> constraint.defaultPreset
else -> constraint.presets.firstOrNull() ?: current
}
}
Loading
Loading