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: