mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Merge 97efcca224 into 3322257cfd
This commit is contained in:
commit
e78faebde2
20 changed files with 1600 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
{ _, _ -> },
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue