mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-12 21:30:02 -04:00
feat(debug): PoC live device screen mirror + remote D-pad (Mirror tab)
Consumes the DisplayFrame PoC protobufs (2.99.0-screen-mirror-poc-SNAPSHOT from mavenLocal, useMavenLocal committed on this branch): DisplayMirrorManager reassembles FromRadio.display_frame chunks into MirrorFrame snapshots, AdminController gains setDisplayMirror + sendInputEvent, and the Debug panel gains a Mirror tab rendering the device's 1bpp framebuffer live with a D-pad driving the firmware InputBroker. PoC: tab strings deliberately unlocalized. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9bac697f6b
commit
c33fa4d225
9 files changed
+341
-1
No files matched your search
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.DisplayMirrorManager
|
||||
import org.meshtastic.core.repository.MirrorFrame
|
||||
import org.meshtastic.proto.DisplayFrame
|
||||
|
||||
/**
|
||||
* Display frame reassembly state machine.
|
||||
*
|
||||
* Chunks arrive in offset order within a frame (the firmware drains one snapshot before capturing the next), so a
|
||||
* chunk with a new `frame_id` or `offset == 0` starts a new frame and an out-of-sequence chunk drops the partial
|
||||
* frame. Called sequentially from [FromRadioPacketHandlerImpl] on a single IO coroutine.
|
||||
*/
|
||||
@Single
|
||||
class DisplayMirrorManagerImpl : DisplayMirrorManager {
|
||||
|
||||
private val _frame = MutableStateFlow<MirrorFrame?>(null)
|
||||
override val frame = _frame.asStateFlow()
|
||||
|
||||
private var buffer: ByteArray? = null
|
||||
private var frameId = 0
|
||||
private var received = 0
|
||||
|
||||
override fun handleIncomingFrame(chunk: DisplayFrame) {
|
||||
val data = chunk.data_.toByteArray()
|
||||
val total = chunk.total_size
|
||||
|
||||
if (total <= 0 || total > MAX_FRAME_BYTES || chunk.offset + data.size > total) {
|
||||
Logger.w { "DisplayMirror: dropping malformed chunk (offset=${chunk.offset} size=${data.size} total=$total)" }
|
||||
return
|
||||
}
|
||||
|
||||
if (chunk.offset == 0) {
|
||||
buffer = ByteArray(total)
|
||||
frameId = chunk.frame_id
|
||||
received = 0
|
||||
}
|
||||
|
||||
val buf = buffer
|
||||
if (buf == null || chunk.frame_id != frameId || chunk.offset != received || buf.size != total) {
|
||||
Logger.w { "DisplayMirror: out-of-sequence chunk (frame=${chunk.frame_id}/$frameId offset=${chunk.offset}/$received)" }
|
||||
buffer = null
|
||||
return
|
||||
}
|
||||
|
||||
data.copyInto(buf, chunk.offset)
|
||||
received += data.size
|
||||
|
||||
if (received == total) {
|
||||
_frame.value = MirrorFrame(width = chunk.width, height = chunk.height, frameId = frameId, pixels = buf)
|
||||
buffer = null
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Generous sanity cap: 320x240 at 1bpp is 9600 bytes.
|
||||
const val MAX_FRAME_BYTES = 16384
|
||||
}
|
||||
}
|
||||
+8
@@ -22,6 +22,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.model.util.isOtaStatusNotification
|
||||
import org.meshtastic.core.repository.DisplayMirrorManager
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.FromRadioPacketHandler
|
||||
import org.meshtastic.core.repository.LockdownCoordinator
|
||||
@@ -55,6 +56,7 @@ class FromRadioPacketHandlerImpl(
|
||||
private val configFlowManager: Lazy<MeshConfigFlowManager>,
|
||||
private val configHandler: Lazy<MeshConfigHandler>,
|
||||
private val xmodemManager: Lazy<XModemManager>,
|
||||
private val displayMirrorManager: Lazy<DisplayMirrorManager>,
|
||||
private val mqttManager: MqttManager,
|
||||
private val packetHandler: PacketHandler,
|
||||
private val notificationManager: NotificationManager,
|
||||
@@ -84,6 +86,7 @@ class FromRadioPacketHandlerImpl(
|
||||
val regionPresets = proto.region_presets
|
||||
val xmodemPacket = proto.xmodemPacket
|
||||
val lockdownStatus = proto.lockdown_status
|
||||
val displayFrame = proto.display_frame
|
||||
|
||||
when {
|
||||
myInfo != null -> configFlowManager.value.handleMyInfo(myInfo, session)
|
||||
@@ -131,6 +134,11 @@ class FromRadioPacketHandlerImpl(
|
||||
xmodemPacket != null ->
|
||||
runIfSessionActive(session, "XModem packet") { xmodemManager.value.handleIncomingXModem(xmodemPacket) }
|
||||
|
||||
displayFrame != null ->
|
||||
runIfSessionActive(session, "display frame") {
|
||||
displayMirrorManager.value.handleIncomingFrame(displayFrame)
|
||||
}
|
||||
|
||||
lockdownStatus != null ->
|
||||
runIfSessionActive(session, "lockdown status") {
|
||||
lockdownCoordinator.handleLockdownStatus(lockdownStatus)
|
||||
|
||||
+12
@@ -51,6 +51,18 @@ interface AdminController {
|
||||
/** Updates a local radio channel. Same fire-and-forget contract as [setLocalConfig]. */
|
||||
suspend fun setLocalChannel(channel: Channel)
|
||||
|
||||
/**
|
||||
* Enables or disables live display mirroring on the locally connected node. While enabled, the device streams its
|
||||
* framebuffer as `FromRadio.display_frame` chunks — collect them via [DisplayMirrorManager].
|
||||
*/
|
||||
suspend fun setDisplayMirror(enabled: Boolean)
|
||||
|
||||
/**
|
||||
* Injects a physical input event (button/key/touch) into the locally connected node's UI via the firmware
|
||||
* InputBroker. Event codes are the firmware's `input_broker_event` values.
|
||||
*/
|
||||
suspend fun sendInputEvent(eventCode: Int, kbChar: Int = 0, touchX: Int = 0, touchY: Int = 0)
|
||||
|
||||
// ── Remote configuration ────────────────────────────────────────────────
|
||||
|
||||
/** Updates the owner (user info) on a remote node. */
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.meshtastic.proto.DisplayFrame
|
||||
|
||||
/**
|
||||
* Reassembles the chunked [DisplayFrame] stream from the connected device into complete framebuffer snapshots.
|
||||
*
|
||||
* The device streams its 1bpp framebuffer as `FromRadio.display_frame` chunks while display mirroring is enabled
|
||||
* (see `AdminMessage.set_display_mirror`). Chunks of one frame share a `frame_id`; a frame is complete when
|
||||
* `offset + data.size == total_size`.
|
||||
*/
|
||||
interface DisplayMirrorManager {
|
||||
/** Latest completely reassembled frame, or null before the first one arrives. */
|
||||
val frame: StateFlow<MirrorFrame?>
|
||||
|
||||
/** Routes an incoming display frame chunk from the device to the reassembly state machine. */
|
||||
fun handleIncomingFrame(chunk: DisplayFrame)
|
||||
}
|
||||
|
||||
/**
|
||||
* One complete device framebuffer snapshot.
|
||||
*
|
||||
* [pixels] is MONO_VLSB: 1 bit per pixel in vertical LSB-first pages — byte index = `x + (y / 8) * width`,
|
||||
* bit index = `y % 8`.
|
||||
*/
|
||||
data class MirrorFrame(val width: Int, val height: Int, val frameId: Int, val pixels: ByteArray) {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is MirrorFrame &&
|
||||
other.width == width &&
|
||||
other.height == height &&
|
||||
other.frameId == frameId &&
|
||||
other.pixels.contentEquals(pixels)
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = width
|
||||
result = 31 * result + height
|
||||
result = 31 * result + frameId
|
||||
result = 31 * result + pixels.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,24 @@ internal class AdminControllerImpl(
|
||||
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
|
||||
}
|
||||
|
||||
override suspend fun setDisplayMirror(enabled: Boolean) {
|
||||
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_display_mirror = enabled) }
|
||||
}
|
||||
|
||||
override suspend fun sendInputEvent(eventCode: Int, kbChar: Int, touchX: Int, touchY: Int) {
|
||||
commandSender.sendAdmin(myNodeNum) {
|
||||
AdminMessage(
|
||||
send_input_event =
|
||||
AdminMessage.InputEvent(
|
||||
event_code = eventCode,
|
||||
kb_char = kbChar,
|
||||
touch_x = touchX,
|
||||
touch_y = touchY,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
|
||||
commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_config = config) }
|
||||
if (destNum == nodeManager.myNodeNum.value) {
|
||||
|
||||
+10
@@ -184,11 +184,21 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) {
|
||||
onClick = { selectedTab = 1 },
|
||||
text = { Text(stringResource(Res.string.debug_tab_app_logs)) },
|
||||
)
|
||||
Tab(
|
||||
selected = selectedTab == 2,
|
||||
onClick = { selectedTab = 2 },
|
||||
// PoC tab; deliberately unlocalized.
|
||||
text = { Text("Mirror") },
|
||||
)
|
||||
}
|
||||
if (selectedTab == 1) {
|
||||
LogcatContent(modifier = Modifier.fillMaxSize())
|
||||
return@Column
|
||||
}
|
||||
if (selectedTab == 2) {
|
||||
DisplayMirrorContent(modifier = Modifier.fillMaxSize())
|
||||
return@Column
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) {
|
||||
stickyHeader {
|
||||
val animatedAlpha by
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.settings.debugging
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.repository.DisplayMirrorManager
|
||||
import org.meshtastic.core.repository.MirrorFrame
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
|
||||
// Firmware input_broker_event codes (src/input/InputBroker.h).
|
||||
private const val INPUT_SELECT = 10
|
||||
private const val INPUT_UP = 17
|
||||
private const val INPUT_DOWN = 18
|
||||
private const val INPUT_LEFT = 19
|
||||
private const val INPUT_RIGHT = 20
|
||||
private const val INPUT_BACK = 27
|
||||
|
||||
@KoinViewModel
|
||||
class DisplayMirrorViewModel(
|
||||
displayMirrorManager: DisplayMirrorManager,
|
||||
private val radioController: RadioController,
|
||||
) : ViewModel() {
|
||||
|
||||
val frame: StateFlow<MirrorFrame?> = displayMirrorManager.frame
|
||||
|
||||
private val _mirroring = MutableStateFlow(false)
|
||||
val mirroring: StateFlow<Boolean> = _mirroring.asStateFlow()
|
||||
|
||||
fun setMirror(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
radioController.setDisplayMirror(enabled)
|
||||
_mirroring.value = enabled
|
||||
}
|
||||
}
|
||||
|
||||
fun sendKey(eventCode: Int) {
|
||||
viewModelScope.launch { radioController.sendInputEvent(eventCode) }
|
||||
}
|
||||
}
|
||||
|
||||
/** PoC live view of the connected device's screen, with remote D-pad control. Strings are deliberately unlocalized. */
|
||||
@Composable
|
||||
fun DisplayMirrorContent(modifier: Modifier = Modifier, viewModel: DisplayMirrorViewModel = koinViewModel()) {
|
||||
val frame by viewModel.frame.collectAsStateWithLifecycle()
|
||||
val mirroring by viewModel.mirroring.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Switch(checked = mirroring, onCheckedChange = viewModel::setMirror)
|
||||
Text(text = if (mirroring) "Mirroring" else "Mirror off", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
val currentFrame = frame
|
||||
if (currentFrame != null) {
|
||||
MirrorFrameCanvas(currentFrame)
|
||||
Text(
|
||||
text = "${currentFrame.width}x${currentFrame.height} frame #${currentFrame.frameId}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
} else {
|
||||
Text(text = "No frame received yet — enable mirroring above.")
|
||||
}
|
||||
|
||||
// Remote D-pad
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilledTonalButton(onClick = { viewModel.sendKey(INPUT_UP) }) { Text("Up") }
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilledTonalButton(onClick = { viewModel.sendKey(INPUT_LEFT) }) { Text("Left") }
|
||||
FilledTonalButton(onClick = { viewModel.sendKey(INPUT_SELECT) }) { Text("OK") }
|
||||
FilledTonalButton(onClick = { viewModel.sendKey(INPUT_RIGHT) }) { Text("Right") }
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilledTonalButton(onClick = { viewModel.sendKey(INPUT_DOWN) }) { Text("Down") }
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilledTonalButton(onClick = { viewModel.sendKey(INPUT_BACK) }) { Text("Back") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws a MONO_VLSB 1bpp framebuffer scaled to the available width, one filled rect per lit pixel. */
|
||||
@Composable
|
||||
private fun MirrorFrameCanvas(frame: MirrorFrame) {
|
||||
val aspect = frame.width.toFloat() / frame.height.toFloat()
|
||||
Canvas(modifier = Modifier.fillMaxWidth().aspectRatio(aspect).background(Color.Black)) {
|
||||
val scale = size.width / frame.width
|
||||
val pixel = Size(scale, scale)
|
||||
for (y in 0 until frame.height) {
|
||||
val page = (y / 8) * frame.width
|
||||
val bit = 1 shl (y % 8)
|
||||
for (x in 0 until frame.width) {
|
||||
val index = page + x
|
||||
if (index < frame.pixels.size && frame.pixels[index].toInt() and bit != 0) {
|
||||
drawRect(color = Color.White, topLeft = Offset(x * scale, y * scale), size = pixel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,3 +33,6 @@ org.gradle.priority=low
|
||||
# stays parallel if Isolated Projects is ever off.
|
||||
org.gradle.tooling.parallel=true
|
||||
org.gradle.welcome=never
|
||||
|
||||
# PoC: resolve org.meshtastic:protobufs 2.99.0-screen-mirror-poc-SNAPSHOT from mavenLocal
|
||||
useMavenLocal=true
|
||||
@@ -104,7 +104,10 @@ mqttastic = "0.8.1"
|
||||
jmdns = "3.6.3"
|
||||
qrcode-kotlin = "4.5.0"
|
||||
takpacket-sdk = "0.9.1"
|
||||
meshtastic-protobufs = "2.8.0.23-gca2cb1a-SNAPSHOT"
|
||||
# PoC: DisplayFrame screen mirroring, locally published from protobufs@screen-mirror-poc.
|
||||
# Revert to main's pin once protobufs#1054 is tagged. main tracks 2.8.0.23-gca2cb1a-SNAPSHOT.
|
||||
# (requires -PuseMavenLocal or the gradle.properties flag on this branch)
|
||||
meshtastic-protobufs = "2.99.0-screen-mirror-poc-SNAPSHOT"
|
||||
|
||||
# Gradle Plugins
|
||||
ccud = "2.8.0"
|
||||
|
||||
Reference in new issue
Block a user