feat(network): a hidden showcase scenario for Demo Mode (#7377)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-26 15:40:25 +00:00
1 parent edb37d8c35
commit cc8c8789c6
5 files changed
+559 -248

No files matched your search

@@ -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.",
)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<SimPeer>,
/** (index into [peers], text) pairs, oldest first. */
val channelConversation: List<Pair<Int, String>>,
val directPeerIndex: Int,
/** Texts from [directPeerIndex] to us, oldest first. */
val directConversation: List<String>,
/** The first peers, which report device telemetry on connect and, with [liveTelemetry], every tick after. */
val telemetryPeerCount: Int,
val weatherPeerIndexes: List<Int>,
/** 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()
}
@@ -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
@@ -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<DeviceType> = 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<Boolean> = 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<Boolean> = 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,
@@ -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: