Skip to content

Commit fc908ed

Browse files
committed
feat(remote-shell): PTY-over-mesh terminal with retro-CRT UI
Adds a full RemoteShell (portnum=13) implementation matching Jonathan's dmshell_client.py protocol: Protocol layer: - Seq/ack reliability: incrementing seq on every non-ACK frame, piggybacked ack_seq, out-of-order frame buffering, gap detection and replay requests - TX history ring buffer (last 50 frames) for retransmission on request - ACK frames carry optional 4-byte big-endian REPLAY_REQUEST payload - PING/PONG heartbeat with 8-byte status payload (lastTxSeq, lastRxSeq); PONG handler triggers replay if peer is behind - PKI: DataPacket.PKC_CHANNEL_INDEX so CommandSenderImpl applies Curve25519 encryption (firmware rejects non-PKI DMShell packets) - Input batching: 500ms debounce (matches Python client), immediate flush on \r, \t, buffer-full (64 bytes), or Enter Terminal UI: - Retro-CRT composables: TerminalCanvas (phosphor glow, two-pass bloom), ScanlinesOverlay, FlickerEffect (animated brightness variation), CrtCurvatureModifier (AGSL barrel distortion on Android 12+, no-op on JVM) - PhosphorPreset enum: GREEN (P1), AMBER (P3), WHITE (P4) - Pending-input rendered inline in preset.dim colour; snaps to confirmed on flush - Hidden zero-size BasicTextField captures soft and hardware keyboard input - Phosphor colour picker dropdown in top bar Capabilities gate: - supportsRemoteShell gated to UNRELEASED (9.9.9) - Entry only visible in AdministrationSection when node.capabilities.supportsRemoteShell
1 parent 3aadd29 commit fc908ed

18 files changed

Lines changed: 1594 additions & 14 deletions

File tree

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import org.meshtastic.core.repository.PacketHandler
4848
import org.meshtastic.core.repository.PacketRepository
4949
import org.meshtastic.core.repository.PlatformAnalytics
5050
import org.meshtastic.core.repository.RadioConfigRepository
51+
import org.meshtastic.core.repository.RemoteShellHandler
5152
import org.meshtastic.core.repository.ServiceBroadcasts
5253
import org.meshtastic.core.repository.ServiceRepository
5354
import org.meshtastic.core.repository.StoreForwardPacketHandler
@@ -96,6 +97,7 @@ class MeshDataHandlerImpl(
9697
private val storeForwardHandler: StoreForwardPacketHandler,
9798
private val telemetryHandler: TelemetryPacketHandler,
9899
private val adminPacketHandler: AdminPacketHandler,
100+
private val remoteShellHandler: RemoteShellHandler,
99101
@Named("ServiceScope") private val scope: CoroutineScope,
100102
) : MeshDataHandler {
101103

@@ -181,6 +183,12 @@ class MeshDataHandlerImpl(
181183
adminPacketHandler.handleAdminMessage(packet, myNodeNum)
182184
}
183185

186+
PortNum.REMOTE_SHELL_APP -> {
187+
remoteShellHandler.handleRemoteShell(packet)
188+
// Do not broadcast — RemoteShell frames are point-to-point PTY I/O
189+
shouldBroadcast = false
190+
}
191+
184192
PortNum.NEIGHBORINFO_APP -> {
185193
neighborInfoHandler.handleNeighborInfo(packet)
186194
shouldBroadcast = true
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright (c) 2025-2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.core.data.manager
18+
19+
import co.touchlab.kermit.Logger
20+
import kotlinx.coroutines.flow.MutableSharedFlow
21+
import kotlinx.coroutines.flow.SharedFlow
22+
import kotlinx.coroutines.flow.asSharedFlow
23+
import org.koin.core.annotation.Single
24+
import org.meshtastic.core.model.util.decodeOrNull
25+
import org.meshtastic.core.repository.ReceivedShellFrame
26+
import org.meshtastic.core.repository.RemoteShellHandler
27+
import org.meshtastic.proto.MeshPacket
28+
import org.meshtastic.proto.RemoteShell
29+
30+
/**
31+
* Handles incoming [RemoteShell] packets (REMOTE_SHELL_APP portnum = 13).
32+
*
33+
* This is a scaffold implementation. The RemoteShell firmware feature is currently unreleased (gated to
34+
* [org.meshtastic.core.model.Capabilities.supportsRemoteShell]). When the firmware ships, this handler should be
35+
* expanded to manage PTY session state and relay I/O to the UI.
36+
*/
37+
@Single
38+
class RemoteShellPacketHandlerImpl : RemoteShellHandler {
39+
40+
/**
41+
* Emits every received [ReceivedShellFrame] (decoded frame + sender node number).
42+
*
43+
* Uses [MutableSharedFlow] with a buffer so that rapid or structurally-identical frames are never silently dropped
44+
* (unlike `StateFlow` which conflates by equality).
45+
*/
46+
private val _lastFrame = MutableSharedFlow<ReceivedShellFrame>(extraBufferCapacity = 16)
47+
override val lastFrame: SharedFlow<ReceivedShellFrame> = _lastFrame.asSharedFlow()
48+
49+
override fun handleRemoteShell(packet: MeshPacket) {
50+
val payload = packet.decoded?.payload ?: return
51+
val frame = RemoteShell.ADAPTER.decodeOrNull(payload, Logger) ?: return
52+
Logger.d { "RemoteShell frame from ${packet.from}: op=${frame.op} sessionId=${frame.session_id}" }
53+
_lastFrame.tryEmit(ReceivedShellFrame(from = packet.from, frame = frame))
54+
}
55+
}

core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
3434
/** Ability to mute notifications from specific nodes via admin messages. */
3535
val canMuteNode = atLeast(V2_7_18)
3636

37-
/** Ability to request neighbor information from other nodes. Gated to [UNRELEASED] until working reliably. */
38-
val canRequestNeighborInfo = atLeast(UNRELEASED)
37+
/** Ability to request neighbor information from other nodes on demand. Supported since firmware v2.7.15. */
38+
val canRequestNeighborInfo = atLeast(V2_7_15)
3939

4040
/** Ability to send verified shared contacts. Supported since firmware v2.7.12. */
4141
val canSendVerifiedContacts = atLeast(V2_7_12)
@@ -49,11 +49,11 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
4949
/** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
5050
val supportsQrCodeSharing = atLeast(V2_6_8)
5151

52-
/** Support for Status Message module. Supported since firmware v2.8.0. */
53-
val supportsStatusMessage = atLeast(V2_8_0)
52+
/** Support for Status Message module. Supported since firmware v2.7.19. */
53+
val supportsStatusMessage = atLeast(V2_7_19)
5454

55-
/** Support for Traffic Management module. Supported since firmware v3.0.0. */
56-
val supportsTrafficManagementConfig = atLeast(V3_0_0)
55+
/** Support for Traffic Management module. Supported since firmware v2.7.20. */
56+
val supportsTrafficManagementConfig = atLeast(V2_7_20)
5757

5858
/** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */
5959
val supportsTakConfig = atLeast(V2_7_19)
@@ -64,15 +64,27 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
6464
/** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */
6565
val supportsEsp32Ota = atLeast(V2_7_18)
6666

67+
/**
68+
* Support for ATAK Plugin V2 (TAKPacketV2 + ATAK_PLUGIN_V2 portnum, zstd dictionary compression). Defined in
69+
* protobufs HEAD (post-v2.7.21); gated to [UNRELEASED] until a firmware release ships the AtakPluginV2 module.
70+
*/
71+
val supportsTakV2 = atLeast(UNRELEASED)
72+
73+
/**
74+
* Support for the RemoteShell module (PTY-over-mesh, REMOTE_SHELL_APP portnum). Defined in protobufs HEAD
75+
* (post-v2.7.21); gated to [UNRELEASED] until a firmware release ships it.
76+
*/
77+
val supportsRemoteShell = atLeast(UNRELEASED)
78+
6779
companion object {
6880
private val V2_6_8 = DeviceVersion("2.6.8")
6981
private val V2_6_9 = DeviceVersion("2.6.9")
7082
private val V2_6_10 = DeviceVersion("2.6.10")
7183
private val V2_7_12 = DeviceVersion("2.7.12")
84+
private val V2_7_15 = DeviceVersion("2.7.15")
7285
private val V2_7_18 = DeviceVersion("2.7.18")
7386
private val V2_7_19 = DeviceVersion("2.7.19")
74-
private val V2_8_0 = DeviceVersion("2.8.0")
75-
private val V3_0_0 = DeviceVersion("3.0.0")
87+
private val V2_7_20 = DeviceVersion("2.7.20")
7688
private val UNRELEASED = DeviceVersion("9.9.9")
7789
}
7890
}

core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ class CapabilitiesTest {
3232
}
3333

3434
@Test
35-
fun canRequestNeighborInfo_is_currently_disabled() {
35+
fun canRequestNeighborInfo_requires_V2_7_15() {
3636
assertFalse(caps("2.7.14").canRequestNeighborInfo)
37-
assertFalse(caps("3.0.0").canRequestNeighborInfo)
37+
assertTrue(caps("2.7.15").canRequestNeighborInfo)
38+
assertTrue(caps("3.0.0").canRequestNeighborInfo)
3839
}
3940

4041
@Test
@@ -68,14 +69,16 @@ class CapabilitiesTest {
6869
}
6970

7071
@Test
71-
fun supportsStatusMessage_requires_V2_8_0() {
72-
assertFalse(caps("2.7.21").supportsStatusMessage)
72+
fun supportsStatusMessage_requires_V2_7_19() {
73+
assertFalse(caps("2.7.18").supportsStatusMessage)
74+
assertTrue(caps("2.7.19").supportsStatusMessage)
7375
assertTrue(caps("2.8.0").supportsStatusMessage)
7476
}
7577

7678
@Test
77-
fun supportsTrafficManagementConfig_requires_V3_0_0() {
78-
assertFalse(caps("2.7.18").supportsTrafficManagementConfig)
79+
fun supportsTrafficManagementConfig_requires_V2_7_20() {
80+
assertFalse(caps("2.7.19").supportsTrafficManagementConfig)
81+
assertTrue(caps("2.7.20").supportsTrafficManagementConfig)
7982
assertTrue(caps("3.0.0").supportsTrafficManagementConfig)
8083
}
8184

@@ -85,6 +88,18 @@ class CapabilitiesTest {
8588
assertTrue(caps("2.7.19").supportsTakConfig)
8689
}
8790

91+
@Test
92+
fun supportsTakV2_is_currently_unreleased() {
93+
assertFalse(caps("2.7.22").supportsTakV2)
94+
assertFalse(caps("3.0.0").supportsTakV2)
95+
}
96+
97+
@Test
98+
fun supportsRemoteShell_is_currently_unreleased() {
99+
assertFalse(caps("2.7.22").supportsRemoteShell)
100+
assertFalse(caps("3.0.0").supportsRemoteShell)
101+
}
102+
88103
@Test
89104
fun supportsEsp32Ota_requires_V2_7_18() {
90105
assertFalse(caps("2.7.17").supportsEsp32Ota)
@@ -104,6 +119,8 @@ class CapabilitiesTest {
104119
assertFalse(c.supportsStatusMessage)
105120
assertFalse(c.supportsTrafficManagementConfig)
106121
assertFalse(c.supportsTakConfig)
122+
assertFalse(c.supportsTakV2)
123+
assertFalse(c.supportsRemoteShell)
107124
assertFalse(c.supportsEsp32Ota)
108125
}
109126

@@ -115,5 +132,7 @@ class CapabilitiesTest {
115132
assertTrue(c.supportsStatusMessage)
116133
assertTrue(c.supportsTrafficManagementConfig)
117134
assertTrue(c.supportsTakConfig)
135+
assertTrue(c.supportsTakV2)
136+
assertTrue(c.supportsRemoteShell)
118137
}
119138
}

core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ sealed interface NodeDetailRoute : Route {
9292
@Serializable data class PaxMetrics(val destNum: Int) : NodeDetailRoute
9393

9494
@Serializable data class NeighborInfoLog(val destNum: Int) : NodeDetailRoute
95+
96+
@Serializable data class RemoteShell(val destNum: Int) : NodeDetailRoute
9597
}
9698

9799
@Serializable
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright (c) 2025-2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.core.repository
18+
19+
import kotlinx.coroutines.flow.SharedFlow
20+
import org.meshtastic.proto.MeshPacket
21+
import org.meshtastic.proto.RemoteShell
22+
23+
/**
24+
* A decoded [RemoteShell] frame together with the node number that sent it.
25+
*
26+
* Propagating [from] allows downstream consumers (e.g. the ViewModel) to verify that a frame
27+
* actually originated from the expected peer rather than relying solely on [RemoteShell.session_id].
28+
*/
29+
data class ReceivedShellFrame(
30+
val from: Int,
31+
val frame: RemoteShell,
32+
)
33+
34+
/**
35+
* Interface for handling RemoteShell packets (REMOTE_SHELL_APP portnum = 13).
36+
*
37+
* RemoteShell is a PTY-over-mesh feature that relays a shell session across the mesh network. The firmware-side
38+
* implementation is currently unreleased (gated to [Capabilities.supportsRemoteShell]).
39+
*/
40+
interface RemoteShellHandler {
41+
/**
42+
* The most recently received [ReceivedShellFrame], emitted to collectors.
43+
*
44+
* Uses [SharedFlow] (not `StateFlow`) so that rapid or identical frames are never silently dropped.
45+
*/
46+
val lastFrame: SharedFlow<ReceivedShellFrame>
47+
48+
/**
49+
* Processes an incoming RemoteShell packet.
50+
*
51+
* @param packet The received mesh packet carrying a [RemoteShell] payload.
52+
*/
53+
fun handleRemoteShell(packet: MeshPacket)
54+
}

core/resources/src/commonMain/composeResources/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,8 @@
843843
<string name="modules_unlocked">Modules unlocked</string>
844844
<string name="modules_already_unlocked">Modules already unlocked</string>
845845
<string name="remote">Remote</string>
846+
<string name="remote_shell">Remote Shell</string>
847+
<string name="phosphor_colour">Phosphor colour</string>
846848
<string name="node_count_template">(%1$d online / %2$d shown / %3$d total)</string>
847849
<string name="react">React</string>
848850
<string name="disconnect">Disconnect</string>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright (c) 2025-2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.node.metrics.terminal
18+
19+
import android.graphics.RenderEffect
20+
import android.graphics.RuntimeShader
21+
import android.os.Build
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.remember
24+
import androidx.compose.ui.Modifier
25+
import androidx.compose.ui.graphics.asComposeRenderEffect
26+
import androidx.compose.ui.graphics.graphicsLayer
27+
28+
/**
29+
* AGSL barrel-distortion shader that simulates the curved screen of a CRT monitor.
30+
*
31+
* The UV coordinates are shifted from [0,1] into [-0.5, 0.5], squared, scaled by [strength], then used to warp the
32+
* sample position — producing the classic pincushion/barrel look.
33+
*
34+
* Only applied on Android 12+ (API 31+); older devices get the no-op pass-through.
35+
*/
36+
@Suppress("MagicNumber")
37+
private val CRT_AGSL =
38+
"""
39+
uniform shader image;
40+
uniform float2 resolution;
41+
uniform float strength;
42+
43+
half4 main(float2 fragCoord) {
44+
float2 uv = fragCoord / resolution;
45+
// Centre UV on (0,0)
46+
float2 c = uv - 0.5;
47+
// Barrel distortion — shift by c^2 * strength
48+
float2 distorted = uv + c * dot(c, c) * strength;
49+
// Clamp to avoid edge bleeding
50+
distorted = clamp(distorted, float2(0.0, 0.0), float2(1.0, 1.0));
51+
return image.eval(distorted * resolution);
52+
}
53+
"""
54+
.trimIndent()
55+
56+
@Composable
57+
actual fun Modifier.crtCurvature(strength: Float): Modifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
58+
val shader = remember { RuntimeShader(CRT_AGSL) }
59+
graphicsLayer {
60+
val w = size.width
61+
val h = size.height
62+
if (w > 0f && h > 0f) {
63+
shader.setFloatUniform("resolution", w, h)
64+
shader.setFloatUniform("strength", strength * 4f) // scale for visible effect
65+
renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "image").asComposeRenderEffect()
66+
}
67+
}
68+
} else {
69+
this
70+
}

feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,24 @@ import androidx.compose.runtime.Composable
2222
import androidx.compose.ui.Modifier
2323
import androidx.compose.ui.graphics.Color
2424
import org.jetbrains.compose.resources.stringResource
25+
import org.jetbrains.compose.resources.vectorResource
2526
import org.meshtastic.core.database.entity.FirmwareRelease
2627
import org.meshtastic.core.database.entity.asDeviceVersion
2728
import org.meshtastic.core.model.DeviceVersion
2829
import org.meshtastic.core.model.Node
2930
import org.meshtastic.core.model.service.ServiceAction
31+
import org.meshtastic.core.navigation.NodeDetailRoute
3032
import org.meshtastic.core.navigation.SettingsRoute
3133
import org.meshtastic.core.resources.Res
3234
import org.meshtastic.core.resources.administration
3335
import org.meshtastic.core.resources.firmware
3436
import org.meshtastic.core.resources.firmware_edition
37+
import org.meshtastic.core.resources.ic_terminal
3538
import org.meshtastic.core.resources.installed_firmware_version
3639
import org.meshtastic.core.resources.latest_alpha_firmware
3740
import org.meshtastic.core.resources.latest_stable_firmware
3841
import org.meshtastic.core.resources.remote_admin
42+
import org.meshtastic.core.resources.remote_shell
3943
import org.meshtastic.core.resources.request_metadata
4044
import org.meshtastic.core.ui.component.ListItem
4145
import org.meshtastic.core.ui.icon.ForkLeft
@@ -79,6 +83,17 @@ fun AdministrationSection(
7983
) {
8084
onAction(NodeDetailAction.Navigate(SettingsRoute.Settings(node.num)))
8185
}
86+
87+
if (node.capabilities.supportsRemoteShell) {
88+
SectionDivider()
89+
90+
ListItem(
91+
text = stringResource(Res.string.remote_shell),
92+
leadingIcon = vectorResource(Res.drawable.ic_terminal),
93+
) {
94+
onAction(NodeDetailAction.Navigate(NodeDetailRoute.RemoteShell(node.num)))
95+
}
96+
}
8297
}
8398
}
8499

0 commit comments

Comments
 (0)