From cc8c8789c667c6dc814cfb0befe717ee3b115262 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sat, 26 Sep 2026 15:40:25 +0000
Subject: [PATCH] feat(network): a hidden showcase scenario for Demo Mode
(#7377)
---
.../core/network/radio/MockRadioTransport.kt | 288 ++----------
.../core/network/radio/MockScenario.kt | 443 ++++++++++++++++++
.../network/radio/MockRadioTransportTest.kt | 59 +++
.../radio/DesktopRadioTransportFactory.kt | 15 +-
.../en/developer/navigation-and-deep-links.md | 2 +
5 files changed, 559 insertions(+), 248 deletions(-)
create mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockScenario.kt
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
index 83753a5124..dd840a1552 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
@@ -39,7 +39,6 @@ import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.FromRadio
-import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.Neighbor
@@ -54,7 +53,6 @@ import org.meshtastic.proto.ToRadio
import org.meshtastic.proto.User
import org.meshtastic.proto.Channel as ProtoChannel
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
-import org.meshtastic.proto.Position as ProtoPosition
private val defaultLoRaConfig =
Config.LoRaConfig.Builder()
@@ -99,6 +97,9 @@ class MockRadioTransport(
val address: String,
) : RadioTransport {
+ /** The mesh this transport plays, picked by the address suffix; see [MockScenario.forAddress]. */
+ private val scenario = MockScenario.forAddress(address)
+
/**
* Hands out packet ids.
*
@@ -219,7 +220,7 @@ class MockRadioTransport(
.also { wb ->
wb.statusmessage =
ModuleConfig.StatusMessageConfig.Builder()
- .also { wb -> wb.node_status = MY_NODE_STATUS }
+ .also { wb -> wb.node_status = scenario.nodeStatus }
.build()
}
.build()
@@ -248,8 +249,8 @@ class MockRadioTransport(
val metadata =
DeviceMetadata.Builder()
.also { wb ->
- wb.firmware_version = FIRMWARE_VERSION
- wb.hw_model = HardwareModel.ANDROID_SIM
+ wb.firmware_version = scenario.firmwareVersion
+ wb.hw_model = scenario.hwModel
}
.build()
val frames =
@@ -259,7 +260,7 @@ class MockRadioTransport(
wb.my_info =
ProtoMyNodeInfo.Builder()
.also { wb ->
- wb.my_node_num = MY_NODE
+ wb.my_node_num = scenario.myNode
wb.reboot_count = 3
}
.build()
@@ -307,9 +308,9 @@ class MockRadioTransport(
/** Stage 2: the node database. This is the only window in which the app accepts `node_info`. */
private fun sendNodeInfoStage() {
- Logger.d { "Mock transport answering node-info stage with ${SIM_PEERS.size + 1} nodes" }
+ Logger.d { "Mock transport answering node-info stage with ${scenario.peers.size + 1} nodes" }
callback.handleFromRadio(FromRadio.Builder().also { wb -> wb.node_info = localNodeInfo() }.build().encode())
- SIM_PEERS.forEach { peer ->
+ scenario.peers.forEach { peer ->
callback.handleFromRadio(
FromRadio.Builder().also { wb -> wb.node_info = peer.toNodeInfo() }.build().encode(),
)
@@ -328,19 +329,19 @@ class MockRadioTransport(
private fun localNodeInfo() = NodeInfo.Builder()
.also { wb ->
- wb.num = MY_NODE
+ wb.num = scenario.myNode
wb.last_heard = nowSeconds.toInt()
wb.user =
User.Builder()
.also { wb ->
- wb.id = NodeAddress.numToDefaultId(MY_NODE)
- wb.long_name = "Demo Handset"
- wb.short_name = "DEMO"
- wb.hw_model = HardwareModel.ANDROID_SIM
+ wb.id = NodeAddress.numToDefaultId(scenario.myNode)
+ wb.long_name = scenario.longName
+ wb.short_name = scenario.shortName
+ wb.hw_model = scenario.hwModel
wb.role = Config.DeviceConfig.Role.CLIENT
}
.build()
- wb.position = MY_POSITION.toProto()
+ wb.position = scenario.position.toProto()
wb.device_metrics =
DeviceMetrics.Builder()
.also { wb ->
@@ -397,35 +398,35 @@ class MockRadioTransport(
* points and messages.
*/
private suspend fun seedTraffic() {
- SIM_PEERS.forEach { peer ->
+ scenario.peers.forEach { peer ->
lifecycle.runIfOpen { callback.handleFromRadio(peer.positionPacket(nextPacketId()).encode()) }
delay(SEED_SPACING_MS)
}
- SIM_PEERS.take(TELEMETRY_PEER_COUNT).forEach { peer ->
+ scenario.peers.take(scenario.telemetryPeerCount).forEach { peer ->
lifecycle.runIfOpen {
callback.handleFromRadio(peer.deviceTelemetryPacket(nextPacketId(), tick = 0).encode())
}
delay(SEED_SPACING_MS)
}
- WEATHER_PEER_INDEXES.forEach { index ->
- val peer = SIM_PEERS[index]
+ scenario.weatherPeerIndexes.forEach { index ->
+ val peer = scenario.peers[index]
lifecycle.runIfOpen {
callback.handleFromRadio(peer.environmentTelemetryPacket(nextPacketId(), tick = 0).encode())
}
delay(SEED_SPACING_MS)
}
- lifecycle.runIfOpen { callback.handleFromRadio(SIM_PEERS[0].neighborInfoPacket(nextPacketId()).encode()) }
+ lifecycle.runIfOpen { callback.handleFromRadio(scenario.peers[0].neighborInfoPacket(nextPacketId()).encode()) }
delay(SEED_SPACING_MS)
- lifecycle.runIfOpen { callback.handleFromRadio(SIM_PEERS[1].nodeStatusPacket(nextPacketId()).encode()) }
+ lifecycle.runIfOpen { callback.handleFromRadio(scenario.peers[1].nodeStatusPacket(nextPacketId()).encode()) }
delay(SEED_SPACING_MS)
// Each message is stamped progressively closer to now, so the thread reads as a conversation that unfolded over
// the last while rather than a block of messages that all arrived in the same second.
- CHANNEL_CONVERSATION.forEachIndexed { index, (peerIndex, text) ->
- val peer = SIM_PEERS[peerIndex]
+ scenario.channelConversation.forEachIndexed { index, (peerIndex, text) ->
+ val peer = scenario.peers[peerIndex]
lifecycle.runIfOpen {
callback.handleFromRadio(
peer
@@ -433,7 +434,7 @@ class MockRadioTransport(
id = nextPacketId(),
to = BROADCAST_ADDR,
text = text,
- ageSeconds = messageAgeSeconds(CHANNEL_CONVERSATION.size, index),
+ ageSeconds = messageAgeSeconds(scenario.channelConversation.size, index),
)
.encode(),
)
@@ -441,22 +442,23 @@ class MockRadioTransport(
delay(SEED_SPACING_MS)
}
- DIRECT_CONVERSATION.forEachIndexed { index, text ->
+ scenario.directConversation.forEachIndexed { index, text ->
lifecycle.runIfOpen {
callback.handleFromRadio(
- SIM_PEERS[DIRECT_PEER_INDEX].textPacket(
- id = nextPacketId(),
- to = MY_NODE,
- text = text,
- ageSeconds = messageAgeSeconds(DIRECT_CONVERSATION.size, index),
- )
+ scenario.peers[scenario.directPeerIndex]
+ .textPacket(
+ id = nextPacketId(),
+ to = scenario.myNode,
+ text = text,
+ ageSeconds = messageAgeSeconds(scenario.directConversation.size, index),
+ )
.encode(),
)
}
delay(SEED_SPACING_MS)
}
- streamLiveTelemetry()
+ if (scenario.liveTelemetry) streamLiveTelemetry()
}
/** How long ago the message at [index] of a [count]-message seeded thread was "received". Oldest first. */
@@ -470,10 +472,10 @@ class MockRadioTransport(
var tick = 1
while (true) {
delay(LIVE_TICK_MS)
- val peer = SIM_PEERS[tick % TELEMETRY_PEER_COUNT]
+ val peer = scenario.peers[tick % scenario.telemetryPeerCount]
lifecycle.runIfOpen { callback.handleFromRadio(peer.deviceTelemetryPacket(nextPacketId(), tick).encode()) }
if (tick % WEATHER_TICK_INTERVAL == 0) {
- val weatherPeer = SIM_PEERS[WEATHER_PEER_INDEXES.first()]
+ val weatherPeer = scenario.peers[scenario.weatherPeerIndexes.first()]
lifecycle.runIfOpen {
callback.handleFromRadio(weatherPeer.environmentTelemetryPacket(nextPacketId(), tick).encode())
}
@@ -491,11 +493,11 @@ class MockRadioTransport(
val isBroadcast = packet.to == BROADCAST_ADDR
val responder =
if (isBroadcast) {
- SIM_PEERS[DIRECT_PEER_INDEX]
+ scenario.peers[scenario.directPeerIndex]
} else {
- SIM_PEERS.firstOrNull { it.num == packet.to } ?: return
+ scenario.peers.firstOrNull { it.num == packet.to } ?: return
}
- val replyTo = if (isBroadcast) BROADCAST_ADDR else MY_NODE
+ val replyTo = if (isBroadcast) BROADCAST_ADDR else scenario.myNode
transportScope.handledLaunch {
delay(REPLY_DELAY_MS)
@@ -669,7 +671,7 @@ class MockRadioTransport(
wb.last_sent_by_id = num
wb.node_broadcast_interval_secs = 900
wb.neighbors =
- SIM_PEERS.drop(1).take(3).map { neighbor ->
+ scenario.peers.drop(1).take(3).map { neighbor ->
Neighbor.Builder()
.also { wb ->
wb.node_id = neighbor.num
@@ -702,7 +704,7 @@ class MockRadioTransport(
wb.portnum = PortNum.NODE_STATUS_APP
wb.payload =
StatusMessage.Builder()
- .also { wb -> wb.status = PEER_NODE_STATUS }
+ .also { wb -> wb.status = scenario.peerNodeStatus }
.build()
.encode()
.toByteString()
@@ -787,54 +789,14 @@ class MockRadioTransport(
transportScope.handledLaunch {
delay(ACK_DELAY_MS)
lifecycle.runIfOpen {
- callback.handleFromRadio(makeAck(SIM_PEERS[DIRECT_PEER_INDEX].num, packet.from, packet.id).encode())
+ val directPeer = scenario.peers[scenario.directPeerIndex]
+ callback.handleFromRadio(makeAck(directPeer.num, packet.from, packet.id).encode())
}
}
}
- /** One simulated peer in the demo mesh. */
- private data class SimPeer(
- val num: Int,
- val longName: String,
- val shortName: String,
- val hwModel: HardwareModel,
- val role: Config.DeviceConfig.Role,
- val latitude: Double,
- val longitude: Double,
- val altitude: Int,
- val batteryLevel: Int,
- val voltage: Float,
- val snr: Float,
- val rssi: Int,
- val hops: Int,
- val secondsSinceHeard: Int,
- val uptimeSeconds: Int,
- )
-
- /** Latitude/longitude/altitude triple, converted to the proto's scaled-integer representation on demand. */
- private data class SimPosition(val latitude: Double, val longitude: Double, val altitude: Int) {
- fun toProto() = ProtoPosition.Builder()
- .also { wb ->
- wb.latitude_i = org.meshtastic.core.model.Position.degI(latitude)
- wb.longitude_i = org.meshtastic.core.model.Position.degI(longitude)
- wb.altitude = altitude
- wb.time = nowSeconds.toInt()
- // 32 bits is "full precision"; the coarse end of the scale draws a large uncertainty circle instead
- // of
- // placing the node where it actually is.
- wb.precision_bits = 32
- wb.sats_in_view = 9
- wb.location_source = ProtoPosition.LocSource.LOC_INTERNAL
- }
- .build()
- }
-
private companion object {
- const val MY_NODE = 0x42424242
const val BROADCAST_ADDR = -1 // 0xffffffff
- const val FIRMWARE_VERSION = "9.9.9.abcdefg"
- const val MY_NODE_STATUS = "Running Demo Mode — no radio attached."
- const val PEER_NODE_STATUS = "Solar powered, up on the ridge."
const val AUTO_REPLY_TEXT = "Got it, thanks! Message received on the demo mesh."
/** First packet id handed out; low enough to stay clear of ids the app generates for its own sends. */
@@ -851,9 +813,6 @@ class MockRadioTransport(
/** Hop budget the simulated nodes transmit with; `hop_limit` is derived so the app can infer hop distance. */
const val DEFAULT_HOP_START = 3
- const val DIRECT_PEER_INDEX = 0
- const val TELEMETRY_PEER_COUNT = 4
- val WEATHER_PEER_INDEXES = listOf(4)
/** Spacing between seeded frames; the app timestamps rows on persist, so a burst would collapse together. */
const val SEED_SPACING_MS = 120L
@@ -864,168 +823,5 @@ class MockRadioTransport(
const val ACK_DELAY_MS = 2_000L
val FAKE_SESSION_PASSKEY: okio.ByteString = okio.ByteString.of(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77)
-
- val MY_POSITION = SimPosition(latitude = 32.776665, longitude = -96.796989, altitude = 138)
-
- /**
- * The demo mesh. Deterministic on purpose — the same mesh every launch makes the demo reproducible for
- * screenshots, support requests and store reviews. Spread over ~15 km so the map has something to fit.
- */
- val SIM_PEERS =
- listOf(
- SimPeer(
- num = MY_NODE + 1,
- longName = "Riverside Base",
- shortName = "RVSD",
- hwModel = HardwareModel.HELTEC_V3,
- role = Config.DeviceConfig.Role.CLIENT,
- latitude = 32.802,
- longitude = -96.769,
- altitude = 152,
- batteryLevel = 92,
- voltage = 4.09f,
- snr = 11.5f,
- rssi = -62,
- hops = 0,
- secondsSinceHeard = 45,
- uptimeSeconds = 128_400,
- ),
- SimPeer(
- num = MY_NODE + 2,
- longName = "Trail Runner",
- shortName = "TRLR",
- hwModel = HardwareModel.TRACKER_T1000_E,
- role = Config.DeviceConfig.Role.TRACKER,
- latitude = 32.7605,
- longitude = -96.8305,
- altitude = 145,
- batteryLevel = 64,
- voltage = 3.87f,
- snr = 6.25f,
- rssi = -84,
- hops = 0,
- secondsSinceHeard = 130,
- uptimeSeconds = 41_900,
- ),
- SimPeer(
- num = MY_NODE + 3,
- longName = "Oak Cliff Repeater",
- shortName = "OAKR",
- hwModel = HardwareModel.RAK4631,
- role = Config.DeviceConfig.Role.ROUTER,
- latitude = 32.7395,
- longitude = -96.8215,
- altitude = 189,
- batteryLevel = 100,
- voltage = 4.14f,
- snr = 9.0f,
- rssi = -71,
- hops = 0,
- secondsSinceHeard = 20,
- uptimeSeconds = 903_600,
- ),
- SimPeer(
- num = MY_NODE + 4,
- longName = "Deep Ellum Handheld",
- shortName = "DEEP",
- hwModel = HardwareModel.T_DECK,
- role = Config.DeviceConfig.Role.CLIENT,
- latitude = 32.7842,
- longitude = -96.7845,
- altitude = 141,
- batteryLevel = 47,
- voltage = 3.74f,
- snr = -2.5f,
- rssi = -103,
- hops = 1,
- secondsSinceHeard = 320,
- uptimeSeconds = 9_800,
- ),
- SimPeer(
- num = MY_NODE + 5,
- longName = "Rooftop Weather",
- shortName = "WTHR",
- hwModel = HardwareModel.HELTEC_MESH_NODE_T114,
- role = Config.DeviceConfig.Role.SENSOR,
- latitude = 32.8145,
- longitude = -96.8055,
- altitude = 205,
- batteryLevel = 88,
- voltage = 4.02f,
- snr = 4.75f,
- rssi = -91,
- hops = 1,
- secondsSinceHeard = 210,
- uptimeSeconds = 512_000,
- ),
- SimPeer(
- num = MY_NODE + 6,
- longName = "Lakeside Solar",
- shortName = "LAKE",
- hwModel = HardwareModel.STATION_G2,
- role = Config.DeviceConfig.Role.CLIENT,
- latitude = 32.8365,
- longitude = -96.7325,
- altitude = 167,
- batteryLevel = 73,
- voltage = 3.94f,
- snr = 1.5f,
- rssi = -98,
- hops = 2,
- secondsSinceHeard = 640,
- uptimeSeconds = 254_300,
- ),
- SimPeer(
- num = MY_NODE + 7,
- longName = "Bike Courier",
- shortName = "BIKE",
- hwModel = HardwareModel.TBEAM,
- role = Config.DeviceConfig.Role.TRACKER,
- latitude = 32.7688,
- longitude = -96.7492,
- altitude = 134,
- batteryLevel = 31,
- voltage = 3.62f,
- snr = -6.5f,
- rssi = -112,
- hops = 2,
- secondsSinceHeard = 1_180,
- uptimeSeconds = 3_600,
- ),
- SimPeer(
- num = MY_NODE + 8,
- longName = "Field Kit Echo",
- shortName = "ECHO",
- hwModel = HardwareModel.T_ECHO,
- role = Config.DeviceConfig.Role.CLIENT_MUTE,
- latitude = 32.7215,
- longitude = -96.7738,
- altitude = 158,
- batteryLevel = 56,
- voltage = 3.81f,
- snr = 3.25f,
- rssi = -95,
- hops = 1,
- secondsSinceHeard = 2_400,
- uptimeSeconds = 76_500,
- ),
- )
-
- /** Seeded channel conversation, as (peer index, text) pairs. Oldest first. */
- val CHANNEL_CONVERSATION =
- listOf(
- 0 to "Morning all — base station is back online after the power cut.",
- 2 to "Copy that. Repeater on Oak Cliff is holding steady, 100% battery.",
- 1 to "Out on the trail loop, signal is solid the whole way today.",
- 4 to "Rooftop sensor reading 18.5C and 47% humidity if anyone cares.",
- 0 to "Nice. Net check complete, everyone reporting in.",
- )
-
- /** Seeded direct-message thread from [DIRECT_PEER_INDEX]. Oldest first. */
- val DIRECT_CONVERSATION =
- listOf(
- "Hey, are you still planning to bring the spare antenna tomorrow?",
- "No rush — just let me know before you set off.",
- )
}
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockScenario.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockScenario.kt
new file mode 100644
index 0000000000..2ae989808b
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockScenario.kt
@@ -0,0 +1,443 @@
+/*
+ * 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.network.radio
+
+import org.meshtastic.core.common.util.nowSeconds
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.HardwareModel
+import org.meshtastic.proto.Position as ProtoPosition
+
+/**
+ * The mesh a [MockRadioTransport] simulates: our node, its peers, and the history it seeds on connect. The address
+ * suffix after `m` picks it, so a scenario other than [DEMO] is reachable only by naming it, never from the picker.
+ */
+@Suppress("detekt:MagicNumber")
+internal data class MockScenario(
+ val myNode: Int,
+ val longName: String,
+ val shortName: String,
+ val hwModel: HardwareModel,
+ val firmwareVersion: String,
+ val nodeStatus: String,
+ val position: SimPosition,
+ val peers: List,
+ /** (index into [peers], text) pairs, oldest first. */
+ val channelConversation: List>,
+ val directPeerIndex: Int,
+ /** Texts from [directPeerIndex] to us, oldest first. */
+ val directConversation: List,
+ /** The first peers, which report device telemetry on connect and, with [liveTelemetry], every tick after. */
+ val telemetryPeerCount: Int,
+ val weatherPeerIndexes: List,
+ /** Status line of peer 1, the one that reports a node status. */
+ val peerNodeStatus: String,
+ /** Keep reporting telemetry after the seed pass. Off where a stable picture matters more than a live one. */
+ val liveTelemetry: Boolean,
+) {
+ companion object {
+ /** The suffix that selects [SHOWCASE]: `mshowcase`. */
+ const val SHOWCASE_ADDRESS = "showcase"
+
+ fun forAddress(address: String): MockScenario = if (address == SHOWCASE_ADDRESS) SHOWCASE else DEMO
+
+ private const val DEMO_NODE = 0x42424242
+
+ /**
+ * Demo Mode's mesh. Deterministic on purpose, so the demo is the same every launch for support requests and
+ * store reviews. Spread over ~15 km so the map has something to fit.
+ */
+ val DEMO =
+ MockScenario(
+ myNode = DEMO_NODE,
+ longName = "Demo Handset",
+ shortName = "DEMO",
+ hwModel = HardwareModel.ANDROID_SIM,
+ firmwareVersion = "9.9.9.abcdefg",
+ nodeStatus = "Running Demo Mode — no radio attached.",
+ position = SimPosition(latitude = 32.776665, longitude = -96.796989, altitude = 138),
+ peers =
+ listOf(
+ SimPeer(
+ num = DEMO_NODE + 1,
+ longName = "Riverside Base",
+ shortName = "RVSD",
+ hwModel = HardwareModel.HELTEC_V3,
+ role = Config.DeviceConfig.Role.CLIENT,
+ latitude = 32.802,
+ longitude = -96.769,
+ altitude = 152,
+ batteryLevel = 92,
+ voltage = 4.09f,
+ snr = 11.5f,
+ rssi = -62,
+ hops = 0,
+ secondsSinceHeard = 45,
+ uptimeSeconds = 128_400,
+ ),
+ SimPeer(
+ num = DEMO_NODE + 2,
+ longName = "Trail Runner",
+ shortName = "TRLR",
+ hwModel = HardwareModel.TRACKER_T1000_E,
+ role = Config.DeviceConfig.Role.TRACKER,
+ latitude = 32.7605,
+ longitude = -96.8305,
+ altitude = 145,
+ batteryLevel = 64,
+ voltage = 3.87f,
+ snr = 6.25f,
+ rssi = -84,
+ hops = 0,
+ secondsSinceHeard = 130,
+ uptimeSeconds = 41_900,
+ ),
+ SimPeer(
+ num = DEMO_NODE + 3,
+ longName = "Oak Cliff Repeater",
+ shortName = "OAKR",
+ hwModel = HardwareModel.RAK4631,
+ role = Config.DeviceConfig.Role.ROUTER,
+ latitude = 32.7395,
+ longitude = -96.8215,
+ altitude = 189,
+ batteryLevel = 100,
+ voltage = 4.14f,
+ snr = 9.0f,
+ rssi = -71,
+ hops = 0,
+ secondsSinceHeard = 20,
+ uptimeSeconds = 903_600,
+ ),
+ SimPeer(
+ num = DEMO_NODE + 4,
+ longName = "Deep Ellum Handheld",
+ shortName = "DEEP",
+ hwModel = HardwareModel.T_DECK,
+ role = Config.DeviceConfig.Role.CLIENT,
+ latitude = 32.7842,
+ longitude = -96.7845,
+ altitude = 141,
+ batteryLevel = 47,
+ voltage = 3.74f,
+ snr = -2.5f,
+ rssi = -103,
+ hops = 1,
+ secondsSinceHeard = 320,
+ uptimeSeconds = 9_800,
+ ),
+ SimPeer(
+ num = DEMO_NODE + 5,
+ longName = "Rooftop Weather",
+ shortName = "WTHR",
+ hwModel = HardwareModel.HELTEC_MESH_NODE_T114,
+ role = Config.DeviceConfig.Role.SENSOR,
+ latitude = 32.8145,
+ longitude = -96.8055,
+ altitude = 205,
+ batteryLevel = 88,
+ voltage = 4.02f,
+ snr = 4.75f,
+ rssi = -91,
+ hops = 1,
+ secondsSinceHeard = 210,
+ uptimeSeconds = 512_000,
+ ),
+ SimPeer(
+ num = DEMO_NODE + 6,
+ longName = "Lakeside Solar",
+ shortName = "LAKE",
+ hwModel = HardwareModel.STATION_G2,
+ role = Config.DeviceConfig.Role.CLIENT,
+ latitude = 32.8365,
+ longitude = -96.7325,
+ altitude = 167,
+ batteryLevel = 73,
+ voltage = 3.94f,
+ snr = 1.5f,
+ rssi = -98,
+ hops = 2,
+ secondsSinceHeard = 640,
+ uptimeSeconds = 254_300,
+ ),
+ SimPeer(
+ num = DEMO_NODE + 7,
+ longName = "Bike Courier",
+ shortName = "BIKE",
+ hwModel = HardwareModel.TBEAM,
+ role = Config.DeviceConfig.Role.TRACKER,
+ latitude = 32.7688,
+ longitude = -96.7492,
+ altitude = 134,
+ batteryLevel = 31,
+ voltage = 3.62f,
+ snr = -6.5f,
+ rssi = -112,
+ hops = 2,
+ secondsSinceHeard = 1_180,
+ uptimeSeconds = 3_600,
+ ),
+ SimPeer(
+ num = DEMO_NODE + 8,
+ longName = "Field Kit Echo",
+ shortName = "ECHO",
+ hwModel = HardwareModel.T_ECHO,
+ role = Config.DeviceConfig.Role.CLIENT_MUTE,
+ latitude = 32.7215,
+ longitude = -96.7738,
+ altitude = 158,
+ batteryLevel = 56,
+ voltage = 3.81f,
+ snr = 3.25f,
+ rssi = -95,
+ hops = 1,
+ secondsSinceHeard = 2_400,
+ uptimeSeconds = 76_500,
+ ),
+ ),
+ channelConversation =
+ listOf(
+ 0 to "Morning all — base station is back online after the power cut.",
+ 2 to "Copy that. Repeater on Oak Cliff is holding steady, 100% battery.",
+ 1 to "Out on the trail loop, signal is solid the whole way today.",
+ 4 to "Rooftop sensor reading 18.5C and 47% humidity if anyone cares.",
+ 0 to "Nice. Net check complete, everyone reporting in.",
+ ),
+ directPeerIndex = 0,
+ directConversation =
+ listOf(
+ "Hey, are you still planning to bring the spare antenna tomorrow?",
+ "No rush — just let me know before you set off.",
+ ),
+ telemetryPeerCount = 4,
+ weatherPeerIndexes = listOf(4),
+ peerNodeStatus = "Solar powered, up on the ridge.",
+ liveTelemetry = true,
+ )
+
+ private const val SHOWCASE_NODE = 0x2b3c4d5e
+ private const val SHOWCASE_LAT = 32.7767
+ private const val SHOWCASE_LON = -96.797
+
+ /** A peer [minutes] ago, 30 s into that minute so a slow frame cannot tip a "3 min" label over. */
+ private fun heard(minutes: Int): Int = if (minutes == 0) 5 else minutes * 60 + 30
+
+ /**
+ * The store listing's mesh: a hiking group around a base camp, every node named for a place or a person, and a
+ * channel thread that tells one morning. Static after the seed pass, so every capture shows the same numbers.
+ */
+ val SHOWCASE =
+ MockScenario(
+ myNode = SHOWCASE_NODE,
+ longName = "Base Camp",
+ shortName = "BASE",
+ hwModel = HardwareModel.HELTEC_V3,
+ firmwareVersion = "2.7.26.54e0d8d",
+ nodeStatus = "Base camp radio, on grid power.",
+ position = SimPosition(latitude = SHOWCASE_LAT, longitude = SHOWCASE_LON, altitude = 150),
+ peers =
+ listOf(
+ SimPeer(
+ num = 0x1a2b3c4d,
+ longName = "Ridge Top",
+ shortName = "RDGE",
+ hwModel = HardwareModel.RAK4631,
+ role = Config.DeviceConfig.Role.ROUTER,
+ latitude = SHOWCASE_LAT + 0.04,
+ longitude = SHOWCASE_LON + 0.03,
+ altitude = 214,
+ batteryLevel = 88,
+ voltage = 4.02f,
+ snr = 11.5f,
+ rssi = -68,
+ hops = 0,
+ secondsSinceHeard = heard(2),
+ uptimeSeconds = 19 * 86_400 + 7 * 3_600,
+ ),
+ SimPeer(
+ num = 0x5e6f7081,
+ longName = "Summit Solar",
+ shortName = "SMMT",
+ hwModel = HardwareModel.RAK4631,
+ role = Config.DeviceConfig.Role.ROUTER,
+ latitude = SHOWCASE_LAT + 0.05,
+ longitude = SHOWCASE_LON - 0.04,
+ altitude = 249,
+ batteryLevel = 91,
+ voltage = 4.09f,
+ snr = 3.5f,
+ rssi = -110,
+ hops = 2,
+ secondsSinceHeard = heard(15),
+ uptimeSeconds = 63 * 86_400 + 2 * 3_600,
+ ),
+ SimPeer(
+ num = 0x3c4d5e6f,
+ longName = "Trailhead",
+ shortName = "TRLH",
+ hwModel = HardwareModel.TBEAM,
+ role = Config.DeviceConfig.Role.CLIENT,
+ latitude = SHOWCASE_LAT - 0.03,
+ longitude = SHOWCASE_LON + 0.045,
+ altitude = 231,
+ batteryLevel = 72,
+ voltage = 3.89f,
+ snr = 8.25f,
+ rssi = -92,
+ hops = 1,
+ secondsSinceHeard = heard(3),
+ uptimeSeconds = 6 * 3_600,
+ ),
+ SimPeer(
+ num = 0x8192a3b4.toInt(),
+ longName = "Sarah's Truck",
+ shortName = "SRAH",
+ hwModel = HardwareModel.T_DECK,
+ role = Config.DeviceConfig.Role.CLIENT,
+ latitude = SHOWCASE_LAT + 0.025,
+ longitude = SHOWCASE_LON + 0.01,
+ altitude = 176,
+ batteryLevel = 83,
+ voltage = 3.98f,
+ snr = 9.0f,
+ rssi = -85,
+ hops = 1,
+ secondsSinceHeard = heard(4),
+ uptimeSeconds = 4 * 3_600,
+ ),
+ SimPeer(
+ num = 0x4d5e6f70,
+ longName = "River Crossing",
+ shortName = "RIVR",
+ hwModel = HardwareModel.T_ECHO,
+ role = Config.DeviceConfig.Role.CLIENT_MUTE,
+ latitude = SHOWCASE_LAT - 0.045,
+ longitude = SHOWCASE_LON - 0.02,
+ altitude = 128,
+ batteryLevel = 64,
+ voltage = 3.84f,
+ snr = 6.0f,
+ rssi = -101,
+ hops = 1,
+ secondsSinceHeard = heard(7),
+ uptimeSeconds = 2 * 86_400,
+ ),
+ SimPeer(
+ num = 0xa3b4c5d6.toInt(),
+ longName = "Ham Shack",
+ shortName = "SHCK",
+ hwModel = HardwareModel.STATION_G2,
+ role = Config.DeviceConfig.Role.CLIENT,
+ latitude = SHOWCASE_LAT - 0.02,
+ longitude = SHOWCASE_LON - 0.035,
+ altitude = 162,
+ batteryLevel = 77,
+ voltage = 3.92f,
+ snr = 4.5f,
+ rssi = -104,
+ hops = 2,
+ secondsSinceHeard = heard(11),
+ uptimeSeconds = 41 * 86_400,
+ ),
+ SimPeer(
+ num = 0x6f708192,
+ longName = "Kayak Dan",
+ shortName = "KDAN",
+ hwModel = HardwareModel.HELTEC_WIRELESS_TRACKER,
+ role = Config.DeviceConfig.Role.TRACKER,
+ latitude = SHOWCASE_LAT + 0.012,
+ longitude = SHOWCASE_LON - 0.042,
+ altitude = 120,
+ batteryLevel = 58,
+ voltage = 3.79f,
+ snr = 2.75f,
+ rssi = -113,
+ hops = 2,
+ secondsSinceHeard = heard(25),
+ uptimeSeconds = 3 * 3_600,
+ ),
+ SimPeer(
+ num = 0x708192a3,
+ longName = "Old Fire Lookout",
+ shortName = "LOOK",
+ hwModel = HardwareModel.TBEAM,
+ role = Config.DeviceConfig.Role.CLIENT,
+ latitude = SHOWCASE_LAT - 0.01,
+ longitude = SHOWCASE_LON + 0.02,
+ altitude = 268,
+ batteryLevel = 47,
+ voltage = 3.72f,
+ snr = -1.5f,
+ rssi = -118,
+ hops = 3,
+ secondsSinceHeard = heard(60),
+ uptimeSeconds = 9 * 86_400,
+ ),
+ ),
+ channelConversation =
+ listOf(
+ 2 to "Heading up from the trailhead now, 4 of us",
+ 3 to "Parked at the overflow lot, radio on",
+ 2 to "Made the ridge, good signal back to base",
+ 6 to "Water at the crossing is low, safe to ford",
+ 3 to "Bringing the truck around to the lower lot at 3",
+ 2 to "Lunch at the lookout, back on the air in 30",
+ ),
+ directPeerIndex = 6,
+ directConversation = listOf("Paddling past the crossing now.", "Can you see me on the map yet?"),
+ telemetryPeerCount = 4,
+ weatherPeerIndexes = listOf(0),
+ peerNodeStatus = "Solar powered, up on the summit.",
+ liveTelemetry = false,
+ )
+ }
+}
+
+internal data class SimPeer(
+ val num: Int,
+ val longName: String,
+ val shortName: String,
+ val hwModel: HardwareModel,
+ val role: Config.DeviceConfig.Role,
+ val latitude: Double,
+ val longitude: Double,
+ val altitude: Int,
+ val batteryLevel: Int,
+ val voltage: Float,
+ val snr: Float,
+ val rssi: Int,
+ val hops: Int,
+ val secondsSinceHeard: Int,
+ val uptimeSeconds: Int,
+)
+
+/** Latitude/longitude/altitude triple, converted to the proto's scaled-integer representation on demand. */
+@Suppress("detekt:MagicNumber")
+internal data class SimPosition(val latitude: Double, val longitude: Double, val altitude: Int) {
+ fun toProto() = ProtoPosition.Builder()
+ .also { wb ->
+ wb.latitude_i = org.meshtastic.core.model.Position.degI(latitude)
+ wb.longitude_i = org.meshtastic.core.model.Position.degI(longitude)
+ wb.altitude = altitude
+ wb.time = nowSeconds.toInt()
+ // 32 bits is "full precision"; the coarse end of the scale draws a large uncertainty circle instead of
+ // placing the node where it actually is.
+ wb.precision_bits = 32
+ wb.sats_in_view = 9
+ wb.location_source = ProtoPosition.LocSource.LOC_INTERNAL
+ }
+ .build()
+}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt
index 1e39ee8777..b858562af8 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt
@@ -509,6 +509,65 @@ class MockRadioTransportTest {
}
}
+ @Test
+ fun `the showcase address plays the showcase mesh under its own identity`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback, scope, address = MockScenario.SHOWCASE_ADDRESS)
+ transport.handleSendToRadio(wantConfig(HandshakeConstants.CONFIG_NONCE))
+ transport.handleSendToRadio(wantConfig(HandshakeConstants.NODE_INFO_NONCE))
+
+ val myInfo = assertNotNull(callback.received.firstNotNullOfOrNull { it.my_info })
+ assertEquals(MockScenario.SHOWCASE.myNode, myInfo.my_node_num)
+ val names = callback.nodeInfos.map { it.user?.long_name }
+ assertEquals(listOf("Base Camp") + MockScenario.SHOWCASE.peers.map { it.longName }, names)
+ assertFalse(names.contains("Demo Handset"), "the showcase must not carry Demo Mode's identity")
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `any other address plays the demo mesh`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback, scope, address = "anything")
+ transport.handleSendToRadio(wantConfig(HandshakeConstants.CONFIG_NONCE))
+ transport.handleSendToRadio(wantConfig(HandshakeConstants.NODE_INFO_NONCE))
+
+ assertEquals(MockScenario.DEMO.myNode, callback.received.firstNotNullOfOrNull { it.my_info }?.my_node_num)
+ assertEquals("Demo Handset", callback.nodeInfos.first().user?.long_name)
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `the showcase seeds its thread once and then stays still`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback, scope, address = MockScenario.SHOWCASE_ADDRESS)
+ transport.handleSendToRadio(wantConfig(HandshakeConstants.CONFIG_NONCE))
+ transport.handleSendToRadio(wantConfig(HandshakeConstants.NODE_INFO_NONCE))
+ testScheduler.advanceTimeBy(SEED_WINDOW_MS)
+
+ val texts = callback.packetsOn(PortNum.TEXT_MESSAGE_APP)
+ assertEquals(
+ MockScenario.SHOWCASE.channelConversation.size + MockScenario.SHOWCASE.directConversation.size,
+ texts.size,
+ )
+ val afterSeed = callback.received.size
+
+ testScheduler.advanceTimeBy(LIVE_TICK_MS * LIVE_TICKS_AFTER_CLOSE)
+ assertEquals(afterSeed, callback.received.size, "a capture must see the same numbers however late it runs")
+ } finally {
+ scope.cancel()
+ }
+ }
+
private companion object {
const val BROADCAST_ADDR = -1
const val MIN_DEMO_NODES = 8
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
index efa79d1d9c..12edbcb098 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
@@ -26,10 +26,12 @@ import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.network.SerialTransport
import org.meshtastic.core.network.radio.BaseRadioTransportFactory
+import org.meshtastic.core.network.radio.MockRadioTransport
import org.meshtastic.core.network.radio.TcpRadioTransport
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.core.repository.RadioTransportFactory
+import org.meshtastic.desktop.DesktopBuildConfig
/**
* Desktop implementation of [RadioTransportFactory] delegating multiplatform transports (BLE, TCP) and providing
@@ -47,13 +49,22 @@ class DesktopRadioTransportFactory(
override val supportedDeviceTypes: List = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB)
- // Desktop has no unlock gesture and no demo entry in its picker; the virtual transports stay inadmissible.
- override val mockTransportEnabled: StateFlow = MutableStateFlow(false)
+ // Desktop has no unlock gesture, so Demo Mode is a debug-build feature: it admits the `m` addresses, including
+ // the hidden showcase, and offers the demo entry in the picker.
+ override val mockTransportEnabled: StateFlow = MutableStateFlow(DesktopBuildConfig.IS_DEBUG)
/** Desktop bundles no capture asset, and [createPlatformTransport] does not wire a replay address. */
override val isReplayTransportAvailable: Boolean = false
override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport = when {
+ address.startsWith(InterfaceId.MOCK.id) -> {
+ MockRadioTransport(
+ callback = service,
+ scope = service.serviceScope,
+ address = address.removePrefix(InterfaceId.MOCK.id.toString()),
+ )
+ }
+
address.startsWith(InterfaceId.TCP.id) -> {
TcpRadioTransport(
callback = service,
diff --git a/docs/en/developer/navigation-and-deep-links.md b/docs/en/developer/navigation-and-deep-links.md
index e03e4976e2..28c4d47b19 100644
--- a/docs/en/developer/navigation-and-deep-links.md
+++ b/docs/en/developer/navigation-and-deep-links.md
@@ -110,6 +110,8 @@ manifest entry fails CI.
| `/firmware` | `FirmwareRoute.FirmwareGraph` | Firmware screen |
| `/firmware/update` | `FirmwareRoute.FirmwareUpdate` | Firmware update flow |
+Demo Mode's addresses start with `m`. In a debug build, or once hidden features are unlocked, `/connections?address=m` connects to the demo mesh and `/connections?address=mshowcase` to the store-screenshot mesh, which the device picker never lists. `MockScenario` in `:core:network` holds both.
+
### Backstack synthesis
Deep links synthesize a full backstack, not just the target screen: