diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index f6884c99d5..e4e05df8c5 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -614,6 +614,8 @@ label_narrow_slow
label_short_fast
label_short_slow
label_short_turbo
+label_tiny_fast
+label_tiny_slow
label_very_long_slow
last_heard_filter_label
last_position_update
diff --git a/build.gradle.kts b/build.gradle.kts
index e6b32a9736..b5eece44ae 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -60,3 +60,23 @@ plugins.withId("org.meshtastic.flatpak.sources") {
dependencies {
dokkaPlugin(libs.dokka.android.documentation.plugin)
}
+
+// ─── TEMPORARY: protobufs develop-SNAPSHOT preview (PR #5790) ────────────────────────────────────
+// We track the unreleased protobufs develop-SNAPSHOT. takpacket-sdk-jvm:0.5.3 transitively pins
+// protobufs:2.7.25, 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 takpacket-sdk (and mqtt) are republished against the new protobufs.
+allprojects {
+ configurations.all {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.meshtastic" && requested.name.startsWith("protobufs")) {
+ useVersion("develop-SNAPSHOT")
+ because("preview #5790: override takpacket transitive protobufs:2.7.25 pin")
+ }
+ }
+ }
+}
diff --git a/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/ChannelTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
index 656afc56aa..ce98ce998b 100644
--- a/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
+++ b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
@@ -24,7 +24,6 @@ import org.meshtastic.core.model.util.CHANNEL_URL_PREFIX
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.proto.ChannelSet
-import org.meshtastic.proto.Config
@RunWith(AndroidJUnit4::class)
class ChannelTest {
@@ -64,18 +63,4 @@ class ChannelTest {
Assert.assertEquals(906.875f, ch.radioFreq)
}
-
- @Test
- fun allModemPresetsHaveValidNames() {
- Config.LoRaConfig.ModemPreset.entries.forEach { preset ->
- // Skip UNRECOGNIZED if it exists (Wire generates it sometimes) or generic UNSET values if applicable
- if (preset.name == "UNSET" || preset.name == "UNRECOGNIZED") return@forEach
-
- val loraConfig = Channel.default.loraConfig.copy(use_preset = true, modem_preset = preset)
- val channel = Channel(loraConfig = loraConfig)
-
- // We want to ensure it is NOT "Invalid"
- Assert.assertNotEquals("Preset ${preset.name} should typically have a valid name", "Invalid", channel.name)
- }
- }
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt
index 51f27fdbcc..cb058cedbe 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt
@@ -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"
@@ -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"
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
index da6ae71cda..d64ba13de6 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt
@@ -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 {
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/ChannelPresetNameTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/ChannelPresetNameTest.kt
new file mode 100644
index 0000000000..3aa87b1db9
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/ChannelPresetNameTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.model
+
+import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * Contract test pinning the preset -> channel-name mapping in [Channel.name].
+ *
+ * This name is INTEROP-CRITICAL: for a channel with an empty name and `use_preset = true`, the name is hashed into the
+ * channel hash, the channel number / radio frequency, and the MQTT topic, so it must byte-match the firmware. Source of
+ * truth is firmware `DisplayFormatters::getModemPresetDisplayName(preset, useShortName = false)`. Two names are
+ * deliberately abbreviated (LONG_MODERATE -> "LongMod", VERY_LONG_SLOW -> "VLongSlow") and must NOT be auto-derived
+ * from the enum name.
+ *
+ * When firmware adds a preset, [Channel.name]'s exhaustive `when` fails to compile first (by design). This test is the
+ * backstop: it forces the new branch to carry the EXACT firmware name, guards the mapping against accidental edits, and
+ * fails if a preset is left unpinned (e.g. someone silenced the compile error with an `else`). It deliberately covers
+ * the deprecated VERY_LONG_SLOW, which still has a real firmware name. The numeric anchors (hash/channelNum/radioFreq)
+ * live in ChannelTest and are the genuine on-air interop guard — keep both.
+ */
+class ChannelPresetNameTest {
+
+ // Firmware-canonical names: DisplayFormatters::getModemPresetDisplayName(preset, useShortName = false).
+ private val expectedNames =
+ mapOf(
+ ModemPreset.SHORT_TURBO to "ShortTurbo",
+ ModemPreset.SHORT_FAST to "ShortFast",
+ ModemPreset.SHORT_SLOW to "ShortSlow",
+ ModemPreset.MEDIUM_FAST to "MediumFast",
+ ModemPreset.MEDIUM_SLOW to "MediumSlow",
+ ModemPreset.LONG_FAST to "LongFast",
+ ModemPreset.LONG_SLOW to "LongSlow",
+ ModemPreset.LONG_MODERATE to "LongMod",
+ ModemPreset.VERY_LONG_SLOW to "VLongSlow",
+ ModemPreset.LONG_TURBO to "LongTurbo",
+ ModemPreset.LITE_FAST to "LiteFast",
+ ModemPreset.LITE_SLOW to "LiteSlow",
+ ModemPreset.NARROW_FAST to "NarrowFast",
+ ModemPreset.NARROW_SLOW to "NarrowSlow",
+ ModemPreset.TINY_FAST to "TinyFast",
+ ModemPreset.TINY_SLOW to "TinySlow",
+ )
+
+ private fun presetChannelName(preset: ModemPreset): String =
+ Channel(loraConfig = Channel.default.loraConfig.copy(use_preset = true, modem_preset = preset)).name
+
+ @Test
+ fun every_preset_maps_to_its_exact_firmware_name() {
+ expectedNames.forEach { (preset, expected) ->
+ assertEquals(expected, presetChannelName(preset), "Channel name for $preset must match firmware exactly")
+ }
+ }
+
+ @Test
+ fun every_ModemPreset_is_pinned() {
+ val protoPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet()
+ assertEquals(
+ protoPresets,
+ expectedNames.keys,
+ "Every ModemPreset must be pinned to its firmware name here. A new preset was added to the protos " +
+ "(and to Channel.name's `when`) but not recorded in this contract — add it with its exact " +
+ "DisplayFormatters::getModemPresetDisplayName string.",
+ )
+ }
+}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 6c59d355d7..92b6dffab9 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -638,6 +638,8 @@
Short Range - Fast
Short Range - Slow
Short Range - Turbo
+ Tiny - Fast
+ Tiny - Slow
Very Long Range - Slow
Filter by Last Heard time: %1$s
Last position update
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModelExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModelExtensions.kt
index a6dd1b947c..c1250f15fd 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModelExtensions.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ModelExtensions.kt
@@ -33,6 +33,8 @@ import org.meshtastic.core.resources.label_narrow_slow
import org.meshtastic.core.resources.label_short_fast
import org.meshtastic.core.resources.label_short_slow
import org.meshtastic.core.resources.label_short_turbo
+import org.meshtastic.core.resources.label_tiny_fast
+import org.meshtastic.core.resources.label_tiny_slow
import org.meshtastic.core.resources.label_very_long_slow
import org.meshtastic.core.resources.traceroute_endpoint_missing
import org.meshtastic.core.resources.traceroute_map_no_data
@@ -54,6 +56,8 @@ val ChannelOption.labelRes: StringResource
ChannelOption.LITE_SLOW -> Res.string.label_lite_slow
ChannelOption.NARROW_FAST -> Res.string.label_narrow_fast
ChannelOption.NARROW_SLOW -> Res.string.label_narrow_slow
+ ChannelOption.TINY_FAST -> Res.string.label_tiny_fast
+ ChannelOption.TINY_SLOW -> Res.string.label_tiny_slow
}
fun TracerouteMapAvailability.toMessageRes(): StringResource? = when (this) {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5a828f60c3..9f622b1316 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -88,7 +88,7 @@ mqttastic = "0.3.8"
jmdns = "3.6.3"
qrcode-kotlin = "4.5.0"
takpacket-sdk = "0.5.3"
-meshtastic-protobufs = "2.7.25"
+meshtastic-protobufs = "develop-SNAPSHOT"
# Gradle Plugins
develocity = "4.4.2"