This commit is contained in:
James Rich 2026-04-20 07:28:34 -05:00 committed by GitHub
commit e78faebde2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1600 additions and 0 deletions

View file

@ -48,6 +48,7 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RemoteShellHandler
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
@ -96,6 +97,7 @@ class MeshDataHandlerImpl(
private val storeForwardHandler: StoreForwardPacketHandler,
private val telemetryHandler: TelemetryPacketHandler,
private val adminPacketHandler: AdminPacketHandler,
private val remoteShellHandler: RemoteShellHandler,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshDataHandler {
@ -181,6 +183,12 @@ class MeshDataHandlerImpl(
adminPacketHandler.handleAdminMessage(packet, myNodeNum)
}
PortNum.REMOTE_SHELL_APP -> {
remoteShellHandler.handleRemoteShell(packet)
// Do not broadcast — RemoteShell frames are point-to-point PTY I/O
shouldBroadcast = false
}
PortNum.NEIGHBORINFO_APP -> {
neighborInfoHandler.handleNeighborInfo(packet)
shouldBroadcast = true

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2025-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.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.koin.core.annotation.Single
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.repository.ReceivedShellFrame
import org.meshtastic.core.repository.RemoteShellHandler
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.RemoteShell
/**
* Handles incoming [RemoteShell] packets (REMOTE_SHELL_APP portnum = 13).
*
* This is a scaffold implementation. The RemoteShell firmware feature is currently unreleased (gated to
* [org.meshtastic.core.model.Capabilities.supportsRemoteShell]). When the firmware ships, this handler should be
* expanded to manage PTY session state and relay I/O to the UI.
*/
@Single
class RemoteShellPacketHandlerImpl : RemoteShellHandler {
/**
* Emits every received [ReceivedShellFrame] (decoded frame + sender node number).
*
* Uses [MutableSharedFlow] with a buffer so that rapid or structurally-identical frames are never silently dropped
* (unlike `StateFlow` which conflates by equality).
*/
private val _lastFrame = MutableSharedFlow<ReceivedShellFrame>(extraBufferCapacity = 16)
override val lastFrame: SharedFlow<ReceivedShellFrame> = _lastFrame.asSharedFlow()
override fun handleRemoteShell(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val frame = RemoteShell.ADAPTER.decodeOrNull(payload, Logger) ?: return
Logger.d { "RemoteShell frame from ${packet.from}: op=${frame.op} sessionId=${frame.session_id}" }
_lastFrame.tryEmit(ReceivedShellFrame(from = packet.from, frame = frame))
}
}

View file

@ -45,6 +45,7 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RemoteShellHandler
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
@ -84,6 +85,7 @@ class MeshDataHandlerTest {
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill)
private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill)
private val remoteShellHandler: RemoteShellHandler = mock(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@ -108,6 +110,7 @@ class MeshDataHandlerTest {
storeForwardHandler = storeForwardHandler,
telemetryHandler = telemetryHandler,
adminPacketHandler = adminPacketHandler,
remoteShellHandler = remoteShellHandler,
scope = testScope,
)

View file

@ -64,6 +64,12 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
/** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */
val supportsEsp32Ota = atLeast(V2_7_18)
/**
* Support for the RemoteShell module (PTY-over-mesh, REMOTE_SHELL_APP portnum). Defined in protobufs HEAD
* (post-v2.7.21); gated to [UNRELEASED] until a firmware release ships it.
*/
val supportsRemoteShell = atLeast(UNRELEASED)
companion object {
private val V2_6_8 = DeviceVersion("2.6.8")
private val V2_6_9 = DeviceVersion("2.6.9")

View file

@ -85,6 +85,12 @@ class CapabilitiesTest {
assertTrue(caps("2.7.19").supportsTakConfig)
}
@Test
fun supportsRemoteShell_is_currently_unreleased() {
assertFalse(caps("2.7.22").supportsRemoteShell)
assertFalse(caps("3.0.0").supportsRemoteShell)
}
@Test
fun supportsEsp32Ota_requires_V2_7_18() {
assertFalse(caps("2.7.17").supportsEsp32Ota)
@ -104,6 +110,7 @@ class CapabilitiesTest {
assertFalse(c.supportsStatusMessage)
assertFalse(c.supportsTrafficManagementConfig)
assertFalse(c.supportsTakConfig)
assertFalse(c.supportsRemoteShell)
assertFalse(c.supportsEsp32Ota)
}
@ -115,5 +122,6 @@ class CapabilitiesTest {
assertTrue(c.supportsStatusMessage)
assertTrue(c.supportsTrafficManagementConfig)
assertTrue(c.supportsTakConfig)
assertTrue(c.supportsRemoteShell)
}
}

View file

@ -92,6 +92,8 @@ sealed interface NodeDetailRoute : Route {
@Serializable data class PaxMetrics(val destNum: Int) : NodeDetailRoute
@Serializable data class NeighborInfoLog(val destNum: Int) : NodeDetailRoute
@Serializable data class RemoteShell(val destNum: Int) : NodeDetailRoute
}
@Serializable

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2025-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.repository
import kotlinx.coroutines.flow.SharedFlow
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.RemoteShell
/**
* A decoded [RemoteShell] frame together with the node number that sent it.
*
* Propagating [from] allows downstream consumers (e.g. the ViewModel) to verify that a frame actually originated from
* the expected peer rather than relying solely on [RemoteShell.session_id].
*/
data class ReceivedShellFrame(val from: Int, val frame: RemoteShell)
/**
* Interface for handling RemoteShell packets (REMOTE_SHELL_APP portnum = 13).
*
* RemoteShell is a PTY-over-mesh feature that relays a shell session across the mesh network. The firmware-side
* implementation is currently unreleased (gated to [Capabilities.supportsRemoteShell]).
*/
interface RemoteShellHandler {
/**
* The most recently received [ReceivedShellFrame], emitted to collectors.
*
* Uses [SharedFlow] (not `StateFlow`) so that rapid or identical frames are never silently dropped.
*/
val lastFrame: SharedFlow<ReceivedShellFrame>
/**
* Processes an incoming RemoteShell packet.
*
* @param packet The received mesh packet carrying a [RemoteShell] payload.
*/
fun handleRemoteShell(packet: MeshPacket)
}

View file

@ -862,6 +862,8 @@
<string name="modules_unlocked">Modules unlocked</string>
<string name="modules_already_unlocked">Modules already unlocked</string>
<string name="remote">Remote</string>
<string name="remote_shell">Remote Shell</string>
<string name="phosphor_colour">Phosphor colour</string>
<string name="node_count_template">(%1$d online / %2$d shown / %3$d total)</string>
<string name="react">React</string>
<string name="disconnect">Disconnect</string>

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2025-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.feature.node.metrics.terminal
import android.graphics.RenderEffect
import android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.graphics.graphicsLayer
/**
* AGSL barrel-distortion shader that simulates the curved screen of a CRT monitor.
*
* The UV coordinates are shifted from [0,1] into [-0.5, 0.5], squared, scaled by [strength], then used to warp the
* sample position producing the classic pincushion/barrel look.
*
* Only applied on Android 12+ (API 31+); older devices get the no-op pass-through.
*/
@Suppress("MagicNumber")
private val CRT_AGSL =
"""
uniform shader image;
uniform float2 resolution;
uniform float strength;
half4 main(float2 fragCoord) {
float2 uv = fragCoord / resolution;
// Centre UV on (0,0)
float2 c = uv - 0.5;
// Barrel distortion — shift by c^2 * strength
float2 distorted = uv + c * dot(c, c) * strength;
// Clamp to avoid edge bleeding
distorted = clamp(distorted, float2(0.0, 0.0), float2(1.0, 1.0));
return image.eval(distorted * resolution);
}
"""
.trimIndent()
/** Multiplier applied to strength for a visible CRT curvature effect with the barrel-distortion shader. */
private const val CRT_STRENGTH_SCALE = 4f
@Composable
actual fun Modifier.crtCurvature(strength: Float): Modifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val shader = remember { RuntimeShader(CRT_AGSL) }
graphicsLayer {
val w = size.width
val h = size.height
if (w > 0f && h > 0f) {
shader.setFloatUniform("resolution", w, h)
shader.setFloatUniform("strength", strength * CRT_STRENGTH_SCALE)
renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "image").asComposeRenderEffect()
}
}
} else {
this
}

View file

@ -22,20 +22,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.navigation.NodeDetailRoute
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.administration
import org.meshtastic.core.resources.firmware
import org.meshtastic.core.resources.firmware_edition
import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.installed_firmware_version
import org.meshtastic.core.resources.latest_alpha_firmware
import org.meshtastic.core.resources.latest_stable_firmware
import org.meshtastic.core.resources.remote_admin
import org.meshtastic.core.resources.remote_shell
import org.meshtastic.core.resources.request_metadata
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.icon.ForkLeft
@ -79,6 +83,17 @@ fun AdministrationSection(
) {
onAction(NodeDetailAction.Navigate(SettingsRoute.Settings(node.num)))
}
if (node.capabilities.supportsRemoteShell) {
SectionDivider()
ListItem(
text = stringResource(Res.string.remote_shell),
leadingIcon = vectorResource(Res.drawable.ic_terminal),
) {
onAction(NodeDetailAction.Navigate(NodeDetailRoute.RemoteShell(node.num)))
}
}
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2025-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.feature.node.metrics.terminal
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Applies a barrel-distortion CRT curvature effect to the composable.
*
* On Android 12+ (API 31+) this uses [android.graphics.RenderEffect] with a [android.graphics.RuntimeShader] (AGSL) to
* simulate the curved screen of a classic cathode-ray tube.
*
* On all other platforms (desktop, iOS, older Android) this is a no-op.
*
* @param strength Barrel distortion factor in the range [0, 1]. 0 = flat, 1 = heavy curve.
*/
@Composable expect fun Modifier.crtCurvature(strength: Float = 0.08f): Modifier

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2025-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.feature.node.metrics.terminal
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import kotlinx.coroutines.delay
import kotlin.random.Random
/** Minimum brightness during a flicker dip (fraction of full alpha). */
@Suppress("MagicNumber")
private const val FLICKER_MIN = 0.88f
/** Maximum brightness during a flicker peak. */
private const val FLICKER_MAX = 1.00f
/** Duration of a single flicker transition in milliseconds. */
@Suppress("MagicNumber")
private const val FLICKER_TRANSITION_MS = 80
/** Delay between random flicker events in milliseconds. Keeps the effect subtle. */
@Suppress("MagicNumber")
private const val FLICKER_INTERVAL_MS = 1_500L
/**
* Produces a continuously animated phosphor brightness value suitable for driving CRT flicker.
*
* The returned [State] holds a float in [[FLICKER_MIN]..[FLICKER_MAX]] that dips and recovers at random intervals,
* simulating the subtle intensity variation of an analogue CRT.
*
* Usage:
* ```
* val flickerAlpha by rememberFlickerAlpha()
* TerminalCanvas(lines, preset, flickerAlpha, showCursor)
* ScanlinesOverlay(preset, flickerAlpha)
* ```
*
* @return A [State]<[Float]> updated by the running animation.
*/
@Composable
fun rememberFlickerAlpha(): State<Float> {
val animatable = remember { Animatable(FLICKER_MAX) }
LaunchedEffect(Unit) {
while (true) {
// Wait a randomised interval before the next dip
delay(FLICKER_INTERVAL_MS + Random.nextLong(0L, FLICKER_INTERVAL_MS))
val target = FLICKER_MIN + Random.nextFloat() * (FLICKER_MAX - FLICKER_MIN)
animatable.animateTo(target, animationSpec = tween(durationMillis = FLICKER_TRANSITION_MS))
animatable.animateTo(FLICKER_MAX, animationSpec = tween(durationMillis = FLICKER_TRANSITION_MS * 2))
}
}
return animatable.asState()
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2025-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.feature.node.metrics.terminal
import androidx.compose.ui.graphics.Color
/**
* Phosphor colour presets for the RetroShell terminal.
* - [GREEN] P1 phosphor, VT100 / IBM 3101 style (#33FF33 on near-black)
* - [AMBER] P3 phosphor, IBM 3278 / Televideo 925 style (#FFB000 on near-black)
* - [WHITE] P4 phosphor, DEC VT220 / paper-white style (#E0E0E0 on near-black)
*/
@Suppress("MagicNumber")
enum class PhosphorPreset(
/** The main text / glyph colour. */
val fg: Color,
/** The terminal background — deliberately slightly off-black to avoid pure-black crush. */
val bg: Color,
/** A dimmer variant used for scanline fill and unfocused UI elements. */
val dim: Color,
/** Glow halo colour — same hue as [fg] but very transparent. */
val glow: Color,
) {
GREEN(fg = Color(0xFF33FF33), bg = Color(0xFF0A0F0A), dim = Color(0xFF0D2B0D), glow = Color(0x4033FF33)),
AMBER(fg = Color(0xFFFFB000), bg = Color(0xFF100C00), dim = Color(0xFF2E1E00), glow = Color(0x40FFB000)),
WHITE(fg = Color(0xFFE0E0E0), bg = Color(0xFF0C0C0C), dim = Color(0xFF222222), glow = Color(0x40E0E0E0)),
}

View file

@ -0,0 +1,235 @@
/*
* Copyright (c) 2025-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.feature.node.metrics.terminal
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.phosphor_colour
import org.meshtastic.core.resources.remote_shell
import org.meshtastic.core.ui.component.MainAppBar
/** Cursor blink period in milliseconds. */
@Suppress("MagicNumber")
private const val CURSOR_BLINK_MS = 530L
/**
* Full retro-CRT terminal screen for the RemoteShell feature (portnum = 13).
*
* ### Input model
* Input is **raw / streaming** there is no visible text field. A zero-size [BasicTextField] holds keyboard focus and
* is the sole entry point for both hardware key events and the Android soft keyboard.
* - Each printable character is routed to [RemoteShellViewModel.typeKey].
* - Enter / newline is routed to [RemoteShellViewModel.typeEnter] (immediate flush).
* - Backspace is routed to [RemoteShellViewModel.typeBackspace].
* - The ViewModel batches keystrokes and flushes over the mesh after a 50 ms debounce or when the 64-byte buffer fills.
*
* A small "tap to type" button at the bottom re-acquires focus when the soft keyboard is dismissed.
*
* ### Pending input rendering
* Unflushed characters from [RemoteShellViewModel.pendingInput] are passed to [TerminalCanvas] and drawn inline after
* the last confirmed output line using [PhosphorPreset.dim], giving immediate visual feedback without implying the
* bytes have been transmitted.
*
* @param viewModel [RemoteShellViewModel] for this destination node.
* @param onNavigateUp Callback invoked when the user presses the navigation-up button.
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun RemoteShellScreen(viewModel: RemoteShellViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) {
val outputLines by viewModel.outputLines.collectAsStateWithLifecycle()
val pendingInput by viewModel.pendingInput.collectAsStateWithLifecycle()
val phosphor by viewModel.phosphor.collectAsStateWithLifecycle()
// Cursor blink
var showCursor by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
while (true) {
delay(CURSOR_BLINK_MS)
showCursor = !showCursor
}
}
// Open session on composition entry
LaunchedEffect(Unit) { viewModel.openSession() }
// Flicker animation
val flickerAlpha by rememberFlickerAlpha()
// Phosphor picker dropdown state
var dropdownExpanded by remember { mutableStateOf(false) }
// Focus management for the hidden input sink
val focusRequester = remember { FocusRequester() }
// Request focus when the screen first appears
LaunchedEffect(Unit) {
// Small delay to let the composition settle before requesting focus
delay(FOCUS_REQUEST_DELAY_MS)
focusRequester.requestFocus()
}
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = viewModel.nodeLongName,
subtitle = stringResource(Res.string.remote_shell),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
Box {
IconButton(onClick = { dropdownExpanded = true }) {
Icon(
painter = painterResource(Res.drawable.ic_terminal),
contentDescription = stringResource(Res.string.phosphor_colour),
tint = phosphor.fg,
)
}
DropdownMenu(expanded = dropdownExpanded, onDismissRequest = { dropdownExpanded = false }) {
PhosphorPreset.entries.forEach { preset ->
DropdownMenuItem(
text = {
Text(text = preset.name, color = preset.fg, fontFamily = FontFamily.Monospace)
},
onClick = {
viewModel.setPhosphor(preset)
dropdownExpanded = false
},
modifier = Modifier.background(preset.bg),
)
}
}
}
},
onClickChip = {},
)
},
containerColor = phosphor.bg,
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues).imePadding()) {
// --- Terminal surface ---
Box(
modifier =
Modifier.fillMaxSize()
.crtCurvature()
// Tapping anywhere on the terminal re-acquires keyboard focus
.clickable { focusRequester.requestFocus() },
) {
TerminalCanvas(
lines = outputLines,
pendingInput = pendingInput,
preset = phosphor,
flickerAlpha = flickerAlpha,
showCursor = showCursor,
modifier = Modifier.fillMaxSize(),
)
ScanlinesOverlay(preset = phosphor, flickerAlpha = flickerAlpha, modifier = Modifier.fillMaxSize())
}
// --- Hidden keyboard sink ---
// Zero-size BasicTextField that holds focus so both hardware and soft keyboard input
// is captured. onKeyEvent handles hardware keys (including backspace and enter);
// onValueChange handles soft-keyboard input where key events may not fire.
BasicTextField(
value = "",
onValueChange = { newText ->
// Soft keyboard delivers text via onValueChange. Since we always reset to ""
// the entire newText string is fresh input.
newText.forEach { char ->
when {
char == '\n' || char == '\r' -> viewModel.typeEnter()
char == '\b' -> viewModel.typeBackspace()
char == '\t' -> viewModel.typeKey('\t') // tab: immediate flush in VM
char.isISOControl() -> Unit // ignore other control chars
else -> viewModel.typeKey(char)
}
}
},
modifier =
Modifier.size(1.dp) // zero visible footprint
.align(Alignment.BottomStart)
.focusRequester(focusRequester)
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.Enter,
Key.NumPadEnter,
-> {
viewModel.typeEnter()
true
}
Key.Tab -> {
viewModel.typeKey('\t')
true
}
Key.Backspace -> {
viewModel.typeBackspace()
true
}
else -> false
}
},
textStyle = androidx.compose.ui.text.TextStyle(color = Color.Transparent, fontSize = 1.sp),
cursorBrush = SolidColor(Color.Transparent),
)
}
}
}
/** Delay before the initial focus request, in milliseconds. */
@Suppress("MagicNumber")
private const val FOCUS_REQUEST_DELAY_MS = 100L

View file

@ -0,0 +1,712 @@
/*
* Copyright (c) 2025-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.feature.node.metrics.terminal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okio.Buffer
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RemoteShellHandler
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.RemoteShell
import kotlin.concurrent.Volatile
// ---------------------------------------------------------------------------
// Protocol constants (matched to dmshell_client.py / DMShell.cpp)
// ---------------------------------------------------------------------------
/** Maximum number of output lines held in the ring buffer before the oldest are dropped. */
private const val MAX_OUTPUT_LINES = 500
/** Default PTY column count sent in OPEN/RESIZE frames. */
private const val DEFAULT_COLS = 80
/** Default PTY row count sent in OPEN/RESIZE frames. */
private const val DEFAULT_ROWS = 24
/**
* Maximum payload bytes per INPUT frame. Matches the firmware constant (meshtastic_Constants_DATA_PAYLOAD_LEN is 237
* but the POC client caps at 64 per batch for radio efficiency).
*/
private const val MAX_INPUT_CHUNK_BYTES = 64
/**
* Default debounce window in milliseconds. Matches the Python client's INPUT_BATCH_WINDOW_SEC = 0.5 s. Configurable via
* [RemoteShellViewModel.setFlushWindowMs].
*/
private const val DEFAULT_FLUSH_WINDOW_MS = 500L
/** Number of sent frames to keep in the retransmission history ring buffer. */
private const val TX_HISTORY_MAX = 50
/** Idle period (ms) after which the first heartbeat PING is sent. */
private const val HEARTBEAT_IDLE_DELAY_MS = 5_000L
/** Interval (ms) between repeated heartbeat PINGs while the session is otherwise idle. */
private const val HEARTBEAT_REPEAT_MS = 15_000L
/** Poll interval (ms) for the heartbeat coroutine. */
private const val HEARTBEAT_POLL_MS = 250L
/** Minimum ms between re-requesting the same missing sequence number. */
private const val MISSING_SEQ_RETRY_MS = 1_000L
/** Maximum configurable flush window in milliseconds. */
private const val MAX_FLUSH_WINDOW_MS = 5_000L
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Size in bytes of an encoded uint32 (big-endian). */
private const val UINT32_BYTES = 4
private fun decodeUint32BE(bytes: ByteArray, offset: Int = 0): Int =
Buffer().apply { write(bytes, offset, UINT32_BYTES) }.readInt()
// ---------------------------------------------------------------------------
// Sent-frame record for retransmission history
// ---------------------------------------------------------------------------
private data class SentFrame(
val op: RemoteShell.OpCode,
val sessionId: Int,
val seq: Int,
val ackSeq: Int,
val payload: ByteString,
val cols: Int,
val rows: Int,
val flags: Int = 0,
val lastTxSeq: Int = 0,
val lastRxSeq: Int = 0,
)
// ---------------------------------------------------------------------------
// ViewModel
// ---------------------------------------------------------------------------
/**
* ViewModel for the RemoteShell terminal screen.
*
* ### Protocol fidelity
* Implements the same reliability layer as `dmshell_client.py`:
* - Every non-ACK outbound frame carries an incrementing [nextTxSeq] and a piggybacked [lastRxSeq] in the `ack_seq`
* field.
* - Out-of-order inbound frames are buffered; a gap triggers an `ACK` with a 4-byte `REPLAY_REQUEST` payload asking the
* sender to retransmit from the first missing seq.
* - The last [TX_HISTORY_MAX] sent frames are kept in [txHistory] for retransmission on request.
* - PING/PONG heartbeats carry an 8-byte status payload `(lastTxSeq, lastRxSeq)`. On PONG, if the peer is behind our tx
* cursor we replay; if the peer reports frames we haven't seen we request a replay.
*
* ### PKI
* Outbound [DataPacket]s use [DataPacket.PKC_CHANNEL_INDEX] so that `CommandSenderImpl` applies Curve25519 encryption
* before handing the frame to the radio. The firmware rejects DMShell packets that are not PKI-encrypted.
*
* ### Input model
* Raw streaming input with a configurable debounce flush window ([DEFAULT_FLUSH_WINDOW_MS] = 500 ms, matching the
* Python client). Flush is also triggered immediately when the buffer reaches [MAX_INPUT_CHUNK_BYTES] or when the user
* types a line terminator (`\n`, `\r`) or a tab (`\t`).
*
* ### Pending-input visibility
* Unflushed keystrokes are exposed via [pendingInput] and rendered dim in the UI until the batch is sent
* (snap-to-confirmed on flush).
*/
@Suppress("TooManyFunctions")
@KoinViewModel
class RemoteShellViewModel(
@InjectedParam val destNum: Int,
private val dispatchers: CoroutineDispatchers,
private val nodeRepository: NodeRepository,
private val commandSender: CommandSender,
private val remoteShellHandler: RemoteShellHandler,
) : ViewModel() {
// region --- Session state ---
enum class SessionState {
IDLE,
OPENING,
OPEN,
CLOSING,
CLOSED,
ERROR,
}
private val _sessionState = MutableStateFlow(SessionState.IDLE)
val sessionState: StateFlow<SessionState> = _sessionState.asStateFlow()
companion object {
private val OPENABLE_STATES = setOf(SessionState.IDLE, SessionState.CLOSED, SessionState.ERROR)
}
/** Session ID negotiated during OPEN / OPEN_OK exchange. */
private val sessionId = MutableStateFlow(0)
/** Remote PTY PID received in the OPEN_OK payload (0 if not provided). */
private val _remotePid = MutableStateFlow(0)
val remotePid: StateFlow<Int> = _remotePid.asStateFlow()
// endregion
// region --- Outbound sequence / retransmission ---
/** Next seq to assign to an outbound non-ACK frame. */
@Volatile private var nextTxSeq: Int = 1
/** Ring buffer of the last [TX_HISTORY_MAX] sent frames for replay. */
private val txHistory = ArrayDeque<SentFrame>()
private val txMutex = Mutex()
private suspend fun allocSeq(): Int = txMutex.withLock { nextTxSeq++ }
private suspend fun rememberSent(frame: SentFrame) {
if (frame.seq == 0 || frame.op == RemoteShell.OpCode.ACK) return
txMutex.withLock {
txHistory.addLast(frame)
if (txHistory.size > TX_HISTORY_MAX) txHistory.removeFirst()
}
}
private suspend fun pruneSentFrames(ackSeq: Int) {
if (ackSeq <= 0) return
txMutex.withLock { txHistory.removeAll { it.seq <= ackSeq } }
}
private suspend fun replayFrom(startSeq: Int) {
val frame = txMutex.withLock { txHistory.firstOrNull { it.seq == startSeq } }
if (frame == null) {
Logger.w { "RemoteShell replay unavailable for seq=$startSeq" }
return
}
Logger.d { "RemoteShell replaying seq=${frame.seq} op=${frame.op}" }
sendFrame(
RemoteShell(
op = frame.op,
session_id = frame.sessionId,
seq = frame.seq,
ack_seq = currentAckSeq(),
payload = frame.payload,
cols = frame.cols,
rows = frame.rows,
flags = frame.flags,
last_tx_seq = frame.lastTxSeq,
last_rx_seq = frame.lastRxSeq,
),
remember = false,
)
}
private suspend fun currentAckSeq(): Int = rxMutex.withLock { lastRxSeq }
private suspend fun highestSentSeq(): Int = txMutex.withLock { nextTxSeq - 1 }
// endregion
// region --- Inbound sequence tracking ---
private val rxMutex = Mutex()
/** Highest in-order rx seq we have delivered to the output buffer. */
@Volatile private var lastRxSeq: Int = 0
/** Next rx seq we expect to receive in order. */
@Volatile private var nextExpectedRxSeq: Int = 1
/** Highest seq we have ever seen from the peer (may be ahead of [nextExpectedRxSeq]). */
@Volatile private var highestSeenRxSeq: Int = 0
/** Out-of-order frames buffered while waiting for a gap to fill. */
private val pendingRxFrames = mutableMapOf<Int, RemoteShell>()
/** Tracks when we last requested a specific missing seq, to rate-limit retries. */
@Volatile private var lastRequestedMissingSeq: Int = 0
@Volatile private var lastMissingRequestTimeMs: Long = 0L
private enum class RxAction {
PROCESS,
GAP,
DUPLICATE,
}
private suspend fun noteReceivedSeq(seq: Int): RxAction = rxMutex.withLock {
if (seq == 0) return@withLock RxAction.PROCESS
when {
seq < nextExpectedRxSeq -> RxAction.DUPLICATE
seq > nextExpectedRxSeq -> {
if (seq > highestSeenRxSeq) highestSeenRxSeq = seq
RxAction.GAP
}
else -> {
lastRxSeq = seq
nextExpectedRxSeq = seq + 1
if (seq > highestSeenRxSeq) highestSeenRxSeq = seq
if (lastRequestedMissingSeq != 0 && nextExpectedRxSeq > lastRequestedMissingSeq) {
lastRequestedMissingSeq = 0
}
RxAction.PROCESS
}
}
}
private suspend fun requestMissingSeqOnce(): Int? = rxMutex.withLock {
if (highestSeenRxSeq < nextExpectedRxSeq) return@withLock null
val now = nowMillis
if (
lastRequestedMissingSeq == nextExpectedRxSeq && (now - lastMissingRequestTimeMs) < MISSING_SEQ_RETRY_MS
) {
return@withLock null
}
lastRequestedMissingSeq = nextExpectedRxSeq
lastMissingRequestTimeMs = now
nextExpectedRxSeq
}
// endregion
// region --- Output buffer ---
private val _outputLines = MutableStateFlow<List<String>>(emptyList())
val outputLines: StateFlow<List<String>> = _outputLines.asStateFlow()
// endregion
// region --- Raw input / pending buffer ---
private val inputBuffer = StringBuilder()
private val _pendingInput = MutableStateFlow("")
val pendingInput: StateFlow<String> = _pendingInput.asStateFlow()
private var flushJob: Job? = null
private val _flushWindowMs = MutableStateFlow(DEFAULT_FLUSH_WINDOW_MS)
val flushWindowMs: StateFlow<Long> = _flushWindowMs.asStateFlow()
fun setFlushWindowMs(ms: Long) {
_flushWindowMs.value = ms.coerceIn(0L, MAX_FLUSH_WINDOW_MS)
}
// endregion
// region --- Terminal size ---
private val _cols = MutableStateFlow(DEFAULT_COLS)
val cols: StateFlow<Int> = _cols.asStateFlow()
private val _rows = MutableStateFlow(DEFAULT_ROWS)
val rows: StateFlow<Int> = _rows.asStateFlow()
// endregion
// region --- Phosphor colour pref ---
private val _phosphor = MutableStateFlow(PhosphorPreset.GREEN)
val phosphor: StateFlow<PhosphorPreset> = _phosphor.asStateFlow()
fun setPhosphor(preset: PhosphorPreset) {
_phosphor.value = preset
}
// endregion
// region --- Heartbeat timing ---
@Volatile private var lastActivityMs: Long = nowMillis
@Volatile private var lastHeartbeatSentMs: Long = 0L
private fun noteActivity(isHeartbeat: Boolean = false) {
val now = nowMillis
if (isHeartbeat) lastHeartbeatSentMs = now else lastActivityMs = now
}
private fun isHeartbeatDue(): Boolean {
val now = nowMillis
if ((now - lastActivityMs) < HEARTBEAT_IDLE_DELAY_MS) return false
return lastHeartbeatSentMs <= lastActivityMs || (now - lastHeartbeatSentMs) >= HEARTBEAT_REPEAT_MS
}
// endregion
// region --- Node info ---
val nodeLongName: String
get() = nodeRepository.nodeDBbyNum.value[destNum]?.user?.long_name ?: destNum.toString()
// endregion
// region --- Session control ---
fun openSession() {
if (_sessionState.value !in OPENABLE_STATES) return
_sessionState.update { SessionState.OPENING }
viewModelScope.launch {
val newSessionId = commandSender.generatePacketId()
sessionId.update { newSessionId }
rxMutex.withLock {
lastRxSeq = 0
nextExpectedRxSeq = 1
highestSeenRxSeq = 0
pendingRxFrames.clear()
}
txMutex.withLock { nextTxSeq = 1 }
sendFrame(
RemoteShell(
op = RemoteShell.OpCode.OPEN,
session_id = newSessionId,
seq = allocSeq(),
cols = _cols.value,
rows = _rows.value,
),
)
Logger.d { "RemoteShell OPEN → destNum=$destNum sessionId=$newSessionId" }
startHeartbeatLoop()
}
}
fun closeSession() {
if (_sessionState.value != SessionState.OPEN) return
_sessionState.update { SessionState.CLOSING }
viewModelScope.launch {
sendFrame(
RemoteShell(
op = RemoteShell.OpCode.CLOSE,
session_id = sessionId.value,
seq = allocSeq(),
ack_seq = currentAckSeq(),
),
)
}
}
fun resize(cols: Int, rows: Int) {
_cols.value = cols
_rows.value = rows
if (_sessionState.value == SessionState.OPEN) {
viewModelScope.launch {
sendFrame(
RemoteShell(
op = RemoteShell.OpCode.RESIZE,
session_id = sessionId.value,
seq = allocSeq(),
ack_seq = currentAckSeq(),
cols = cols,
rows = rows,
),
)
}
}
}
// endregion
// region --- Raw input handling ---
/**
* Appends [char] to the pending buffer and schedules a debounced flush. Flushes immediately on line-terminators and
* tab to match the Python client's early-flush heuristic, and when the buffer reaches [MAX_INPUT_CHUNK_BYTES].
*/
fun typeKey(char: Char) {
inputBuffer.append(char)
_pendingInput.value = inputBuffer.toString()
when {
char == '\n' || char == '\r' || char == '\t' -> flushBuffer()
inputBuffer.length >= MAX_INPUT_CHUNK_BYTES -> flushBuffer()
else -> scheduleFlush()
}
}
/** Appends `\r` and flushes immediately (Enter key on mobile). */
fun typeEnter() {
inputBuffer.append('\r')
_pendingInput.value = inputBuffer.toString()
flushBuffer()
}
/** Removes the last byte from the pending buffer; cancels or reschedules the flush job. */
fun typeBackspace() {
if (inputBuffer.isEmpty()) return
inputBuffer.deleteAt(inputBuffer.lastIndex)
_pendingInput.value = inputBuffer.toString()
if (inputBuffer.isEmpty()) {
flushJob?.cancel()
flushJob = null
} else {
scheduleFlush()
}
}
private fun scheduleFlush() {
flushJob?.cancel()
flushJob =
viewModelScope.launch {
delay(_flushWindowMs.value)
flushBuffer()
}
}
private fun flushBuffer() {
flushJob?.cancel()
flushJob = null
if (inputBuffer.isEmpty()) return
val text = inputBuffer.toString()
inputBuffer.clear()
_pendingInput.value = ""
if (_sessionState.value != SessionState.OPEN) return
// No local echo — unflushed keystrokes are shown via pendingInput (rendered dim) and the
// remote PTY will echo them back as OUTPUT frames once the INPUT is delivered.
val payload = text.encodeToByteArray().toByteString()
viewModelScope.launch {
var offset = 0
while (offset < payload.size) {
val end = (offset + MAX_INPUT_CHUNK_BYTES).coerceAtMost(payload.size)
sendFrame(
RemoteShell(
op = RemoteShell.OpCode.INPUT,
session_id = sessionId.value,
seq = allocSeq(),
ack_seq = currentAckSeq(),
payload = payload.substring(offset, end),
),
)
offset = end
}
}
}
// endregion
// region --- Heartbeat loop ---
private var heartbeatJob: Job? = null
@Suppress("LoopWithTooManyJumpStatements")
private fun startHeartbeatLoop() {
heartbeatJob?.cancel()
heartbeatJob =
safeLaunch(context = dispatchers.io, tag = "remoteShellHeartbeat") {
val terminalStates = setOf(SessionState.CLOSED, SessionState.ERROR, SessionState.CLOSING)
while (_sessionState.value !in terminalStates) {
delay(HEARTBEAT_POLL_MS)
if (_sessionState.value != SessionState.OPEN) continue
if (!isHeartbeatDue()) continue
sendFrame(
RemoteShell(
op = RemoteShell.OpCode.PING,
session_id = sessionId.value,
seq = allocSeq(),
ack_seq = currentAckSeq(),
last_tx_seq = highestSentSeq(),
last_rx_seq = currentAckSeq(),
),
isHeartbeat = true,
)
}
}
}
// endregion
// region --- Inbound frame processing ---
init {
safeLaunch(context = dispatchers.io, tag = "remoteShellFrameCollector") {
remoteShellHandler.lastFrame.collect { (from, frame) ->
if (from != destNum) return@collect
val ourSession = sessionId.value
if (ourSession != 0 && frame.session_id != ourSession) return@collect
noteActivity()
processFrame(frame)
}
}
}
@Suppress("CyclomaticComplexMethod")
private suspend fun processFrame(frame: RemoteShell) {
pruneSentFrames(frame.ack_seq)
if (frame.op == RemoteShell.OpCode.ACK) {
if (frame.last_rx_seq > 0) {
replayFrom(frame.last_rx_seq + 1)
}
return
}
when (noteReceivedSeq(frame.seq)) {
RxAction.DUPLICATE -> {
requestMissingSeqOnce()?.let { sendAck(replayFrom = it) }
}
RxAction.GAP -> {
rxMutex.withLock { pendingRxFrames[frame.seq] = frame }
requestMissingSeqOnce()?.let { sendAck(replayFrom = it) }
}
RxAction.PROCESS -> {
handleInOrderFrame(frame)
drainPendingRxFrames()
requestMissingSeqOnce()?.let { sendAck(replayFrom = it) }
}
}
}
@Suppress("LoopWithTooManyJumpStatements")
private suspend fun drainPendingRxFrames() {
while (true) {
val next = rxMutex.withLock { pendingRxFrames.remove(nextExpectedRxSeq) } ?: break
if (noteReceivedSeq(next.seq) == RxAction.PROCESS) {
handleInOrderFrame(next)
} else {
rxMutex.withLock { pendingRxFrames[next.seq] = next }
break
}
}
}
@Suppress("CyclomaticComplexMethod", "ReturnCount")
private suspend fun handleInOrderFrame(frame: RemoteShell) {
when (frame.op) {
RemoteShell.OpCode.OPEN_OK -> {
_sessionState.update { SessionState.OPEN }
val payload = frame.payload.toByteArray()
if (payload.size >= UINT32_BYTES) {
_remotePid.update { decodeUint32BE(payload) }
}
Logger.i { "RemoteShell OPEN_OK session=${frame.session_id} pid=${_remotePid.value}" }
}
RemoteShell.OpCode.OUTPUT -> {
val text =
frame.payload.utf8().ifEmpty {
return
}
text.lines().forEach { appendOutput(it) }
}
RemoteShell.OpCode.ERROR -> {
appendOutput("[error] ${frame.payload.utf8().ifEmpty { "unknown error" }}")
_sessionState.update { SessionState.ERROR }
}
RemoteShell.OpCode.CLOSED -> {
val msg = frame.payload.utf8()
appendOutput(if (msg.isNotEmpty()) "[session closed: $msg]" else "[session closed]")
_sessionState.update { SessionState.CLOSED }
}
RemoteShell.OpCode.PONG -> {
val peerLastTxSeq = frame.last_tx_seq
val peerLastRxSeq = frame.last_rx_seq
val ourHighestTx = highestSentSeq()
if (peerLastRxSeq in 1..<ourHighestTx) {
replayFrom(peerLastRxSeq + 1)
}
if (peerLastTxSeq > currentAckSeq()) {
rxMutex.withLock { if (peerLastTxSeq > highestSeenRxSeq) highestSeenRxSeq = peerLastTxSeq }
requestMissingSeqOnce()?.let { sendAck(replayFrom = it) }
}
}
else -> Logger.d { "RemoteShell unhandled in-order op=${frame.op}" }
}
}
// endregion
// region --- Frame dispatch ---
private suspend fun sendAck(replayFrom: Int? = null) {
sendFrame(
RemoteShell(
op = RemoteShell.OpCode.ACK,
session_id = sessionId.value,
seq = 0,
ack_seq = currentAckSeq(),
last_rx_seq = replayFrom?.let { it - 1 } ?: 0,
),
remember = false,
)
}
private suspend fun sendFrame(frame: RemoteShell, remember: Boolean = true, isHeartbeat: Boolean = false) {
if (remember) {
rememberSent(
SentFrame(
op = frame.op,
sessionId = frame.session_id,
seq = frame.seq,
ackSeq = frame.ack_seq,
payload = frame.payload,
cols = frame.cols,
rows = frame.rows,
flags = frame.flags,
lastTxSeq = frame.last_tx_seq,
lastRxSeq = frame.last_rx_seq,
),
)
}
noteActivity(isHeartbeat)
safeLaunch(context = dispatchers.io, tag = "remoteShellSend") {
val myNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0
val packet =
DataPacket(
to = DataPacket.nodeNumToDefaultId(destNum),
from = DataPacket.nodeNumToDefaultId(myNum),
bytes = RemoteShell.ADAPTER.encode(frame).toByteString(),
dataType = PortNum.REMOTE_SHELL_APP.value,
// PKC_CHANNEL_INDEX (8) triggers Curve25519 encryption in CommandSenderImpl.
// The firmware rejects DMShell packets that are not PKI-encrypted.
channel = DataPacket.PKC_CHANNEL_INDEX,
)
commandSender.sendData(packet)
}
}
// endregion
// region --- Helpers ---
private fun appendOutput(line: String) {
_outputLines.update { current -> (current + line).takeLast(MAX_OUTPUT_LINES) }
}
// endregion
override fun onCleared() {
super.onCleared()
heartbeatJob?.cancel()
if (_sessionState.value == SessionState.OPEN) closeSession()
Logger.d { "RemoteShellViewModel cleared for destNum=$destNum" }
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2025-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.feature.node.metrics.terminal
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
/** Pitch between scanlines in pixels (one dark line every N px). */
@Suppress("MagicNumber")
private const val SCANLINE_PITCH_PX = 2f
/** Opacity of each dark scanline stripe. Higher = more pronounced CRT effect. */
@Suppress("MagicNumber")
private const val SCANLINE_ALPHA = 0.18f
/**
* Draws horizontal semi-transparent dark stripes over its entire bounds to simulate the inter-scan dark bands of a CRT
* phosphor screen.
*
* Render this as a transparent overlay on top of [TerminalCanvas]. The [flickerAlpha] modulates the stripe opacity so
* the scanlines breathe in sync with the phosphor flicker.
*
* @param preset Active [PhosphorPreset] (used for scanline tint colour).
* @param flickerAlpha Animated alpha from [FlickerEffect]; modulates stripe visibility.
*/
@Suppress("MagicNumber")
@Composable
fun ScanlinesOverlay(preset: PhosphorPreset, flickerAlpha: Float, modifier: Modifier = Modifier) {
Canvas(modifier = modifier.fillMaxSize()) {
val stripeColor = preset.bg.copy(alpha = SCANLINE_ALPHA * flickerAlpha)
var y = 0f
while (y < size.height) {
drawLine(color = stripeColor, start = Offset(0f, y), end = Offset(size.width, y), strokeWidth = 1f)
y += SCANLINE_PITCH_PX
}
}
}

View file

@ -0,0 +1,162 @@
/*
* Copyright (c) 2025-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.feature.node.metrics.terminal
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
/** Extra vertical gap between lines to mimic CRT inter-scan spacing (px). */
@Suppress("MagicNumber")
private const val LINE_SPACING_PX = 2f
/** Terminal glyph size in SP. */
@Suppress("MagicNumber")
private const val TERMINAL_FONT_SIZE_SP = 13
/**
* Draws [lines] of terminal output as a monospace character grid with a phosphor bloom effect.
*
* Each confirmed text row is rendered in two passes:
* 1. A glow pass (two translucent offset copies) using [PhosphorPreset.glow] to produce the phosphor halo.
* 2. A sharp pass with [PhosphorPreset.fg] for crisp readable glyphs on top.
*
* The last row additionally renders any [pendingInput] text as a dim suffix using [PhosphorPreset.dim]. Pending
* characters are keystrokes that have been typed but not yet flushed to the mesh they appear immediately for
* responsiveness but at reduced brightness to signal their "in-flight" status. On flush [pendingInput] is `""` and the
* dim suffix disappears instantly.
*
* Only Compose/KMP APIs are used here no `android.*` imports.
*
* @param lines Ordered confirmed output lines (oldest first).
* @param pendingInput Unflushed keystrokes to render as a dim suffix on the last line.
* @param preset Active [PhosphorPreset].
* @param flickerAlpha Animated alpha from [FlickerEffect].
* @param showCursor Whether to render the blinking block cursor after [pendingInput].
*/
@Suppress("LongMethod", "MagicNumber")
@Composable
fun TerminalCanvas(
lines: List<String>,
pendingInput: String,
preset: PhosphorPreset,
flickerAlpha: Float,
showCursor: Boolean,
modifier: Modifier = Modifier,
) {
val measurer = rememberTextMeasurer()
val baseStyle =
remember(preset) { TextStyle(fontFamily = FontFamily.Monospace, fontSize = TERMINAL_FONT_SIZE_SP.sp) }
val fgStyle = remember(preset) { baseStyle.copy(color = preset.fg) }
val glowStyle = remember(preset) { baseStyle.copy(color = preset.glow) }
val dimStyle = remember(preset) { baseStyle.copy(color = preset.dim) }
val charWidthPx = remember(preset) { measurer.measure("M", fgStyle).size.width.toFloat() }
Canvas(modifier = modifier.fillMaxSize()) {
drawRect(color = preset.bg)
val fontSizePx = TERMINAL_FONT_SIZE_SP.sp.toPx()
val lineHeightPx = fontSizePx + LINE_SPACING_PX
val visibleLines = (size.height / lineHeightPx).toInt().coerceAtLeast(1)
val displayLines = lines.takeLast(visibleLines)
displayLines.forEachIndexed { index, line ->
val y = index * lineHeightPx
val isLastLine = index == displayLines.lastIndex
// --- Glow pass ---
drawText(
textMeasurer = measurer,
text = line,
style = glowStyle.copy(color = preset.glow.copy(alpha = flickerAlpha * 0.6f)),
topLeft = Offset(-1f, y - 1f),
)
drawText(
textMeasurer = measurer,
text = line,
style = glowStyle.copy(color = preset.glow.copy(alpha = flickerAlpha * 0.4f)),
topLeft = Offset(1f, y + 1f),
)
// --- Sharp foreground pass ---
drawText(
textMeasurer = measurer,
text = line,
style = fgStyle.copy(color = preset.fg.copy(alpha = flickerAlpha)),
topLeft = Offset(0f, y),
)
// --- Pending input suffix (last line only) ---
if (isLastLine && pendingInput.isNotEmpty()) {
val confirmedWidthPx = measurer.measure(line, fgStyle).size.width.toFloat()
drawText(
textMeasurer = measurer,
text = pendingInput,
style = dimStyle.copy(color = preset.dim.copy(alpha = flickerAlpha)),
topLeft = Offset(confirmedWidthPx, y),
)
}
}
// If there are no confirmed lines yet but there is pending input, draw it on row 0.
if (displayLines.isEmpty() && pendingInput.isNotEmpty()) {
drawText(
textMeasurer = measurer,
text = pendingInput,
style = dimStyle.copy(color = preset.dim.copy(alpha = flickerAlpha)),
topLeft = Offset.Zero,
)
}
// --- Block cursor (positioned after pending input, or after the last confirmed line) ---
if (showCursor) {
val lastConfirmed = displayLines.lastOrNull() ?: ""
val cursorRow = displayLines.lastIndex.coerceAtLeast(0)
val confirmedWidth =
if (lastConfirmed.isNotEmpty()) {
measurer.measure(lastConfirmed, fgStyle).size.width.toFloat()
} else {
0f
}
val pendingWidth =
if (pendingInput.isNotEmpty()) {
measurer.measure(pendingInput, dimStyle).size.width.toFloat()
} else {
0f
}
val cursorX = confirmedWidth + pendingWidth
val cursorY = cursorRow * lineHeightPx
drawRect(
color = preset.fg.copy(alpha = flickerAlpha),
topLeft = Offset(cursorX, cursorY),
size = Size(charWidthPx, lineHeightPx - LINE_SPACING_PX),
)
}
}
}

View file

@ -46,10 +46,12 @@ import org.meshtastic.core.resources.ic_memory
import org.meshtastic.core.resources.ic_perm_scan_wifi
import org.meshtastic.core.resources.ic_power
import org.meshtastic.core.resources.ic_router
import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.pax
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.power
import org.meshtastic.core.resources.remote_shell
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
@ -66,6 +68,8 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import org.meshtastic.feature.node.metrics.terminal.RemoteShellScreen
import org.meshtastic.feature.node.metrics.terminal.RemoteShellViewModel
import kotlin.reflect.KClass
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@ -147,6 +151,15 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() }
}
// RemoteShell uses its own ViewModel and is wired up separately from the MetricsViewModel-based screens.
entry<NodeDetailRoute.RemoteShell>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val remoteShellViewModel = koinViewModel<RemoteShellViewModel> { parametersOf(args.destNum) }
RemoteShellScreen(
viewModel = remoteShellViewModel,
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
)
}
NodeDetailScreen.entries.forEach { routeInfo ->
when (routeInfo.routeClass) {
NodeDetailRoute.DeviceMetrics::class ->
@ -165,6 +178,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
addNodeDetailScreenComposable<NodeDetailRoute.PaxMetrics>(backStack, routeInfo) { it.destNum }
NodeDetailRoute.NeighborInfoLog::class ->
addNodeDetailScreenComposable<NodeDetailRoute.NeighborInfoLog>(backStack, routeInfo) { it.destNum }
// NodeDetailRoute.RemoteShell is handled by the dedicated entry above.
else -> Unit
}
}
@ -248,4 +262,12 @@ enum class NodeDetailScreen(
Res.drawable.ic_group,
{ metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) },
),
REMOTE_SHELL(
Res.string.remote_shell,
NodeDetailRoute.RemoteShell::class,
Res.drawable.ic_terminal,
// Navigation for RemoteShell is handled by a dedicated entry<NodeDetailRoute.RemoteShell>
// block in nodeDetailGraph() that resolves RemoteShellViewModel instead of MetricsViewModel.
{ _, _ -> },
),
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2025-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.feature.node.metrics.terminal
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/** iOS actual — CRT curvature is a no-op; AGSL is Android-only. */
@Composable actual fun Modifier.crtCurvature(strength: Float): Modifier = this

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2025-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.feature.node.metrics.terminal
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/** JVM (desktop/test) actual — CRT curvature is a no-op; AGSL is Android-only. */
@Composable actual fun Modifier.crtCurvature(strength: Float): Modifier = this