feat(debug): color-accurate mirror via DisplayPalette

DisplayMirrorManager reassembles FromRadio.display_palette chunks
(signature-keyed, defaults captured on the offset-0 chunk) into a
MirrorPalette; frames carry palette_signature and the renderer resolves
each pixel through the region table (last overlapping region wins,
matching firmware precedence) with RGB565 conversion, falling back to
monochrome when the referenced palette hasn't arrived. Renderer split
into MirrorFrameRenderer.kt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Fable 5 committed 2026-09-09 11:19:43 -05:00
1 parent 4b06abfd17
commit 3c8e94b40b
6 files changed
+262 -59

No files matched your search

@@ -22,7 +22,9 @@ 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.core.repository.MirrorPalette
import org.meshtastic.proto.DisplayFrame
import org.meshtastic.proto.DisplayPalette
/**
* Display frame reassembly state machine.
@@ -37,10 +39,19 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
private val _frame = MutableStateFlow<MirrorFrame?>(null)
override val frame = _frame.asStateFlow()
private val _palette = MutableStateFlow<MirrorPalette?>(null)
override val palette = _palette.asStateFlow()
private var buffer: ByteArray? = null
private var frameId = 0
private var received = 0
private var paletteRegions = mutableListOf<DisplayPalette.ColorRegion>()
private var paletteSignature = 0
private var paletteReceived = 0
private var paletteDefaultOn = 0
private var paletteDefaultOff = 0
override fun handleIncomingFrame(chunk: DisplayFrame) {
val data = chunk.data_.toByteArray()
val total = chunk.total_size
@@ -64,11 +75,47 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager {
received += data.size
if (received == total) {
_frame.value = MirrorFrame(width = chunk.width, height = chunk.height, frameId = frameId, pixels = buf)
_frame.value =
MirrorFrame(
width = chunk.width,
height = chunk.height,
frameId = frameId,
paletteSignature = chunk.palette_signature,
pixels = buf,
)
buffer = null
}
}
override fun handleIncomingPalette(chunk: DisplayPalette) {
if (chunk.region_offset == 0) {
paletteRegions = mutableListOf()
paletteSignature = chunk.signature
paletteReceived = 0
// Defaults are authoritative on the first chunk; later chunks may omit them.
paletteDefaultOn = chunk.default_on_color
paletteDefaultOff = chunk.default_off_color
}
if (chunk.signature != paletteSignature || chunk.region_offset != paletteReceived) {
Logger.w { "DisplayMirror: bad palette chunk ${chunk.signature}@${chunk.region_offset}" }
paletteRegions.clear()
paletteReceived = -1 // poison until the next offset-0 chunk restarts
return
}
paletteRegions.addAll(chunk.regions)
paletteReceived += chunk.regions.size
if (paletteReceived >= chunk.region_total) {
_palette.value =
MirrorPalette(
signature = chunk.signature,
defaultOnColor = paletteDefaultOn,
defaultOffColor = paletteDefaultOff,
regions = paletteRegions.toList(),
)
}
}
/**
* Format must be MONO_VLSB and width/height must describe exactly total_size bytes; a zero or lying dimension would
* otherwise reach the renderer (aspectRatio requires > 0).
@@ -47,6 +47,7 @@ import org.meshtastic.core.resources.key_verification_title
import org.meshtastic.core.resources.low_entropy_key_title
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DisplayFrame
import org.meshtastic.proto.DisplayPalette
import org.meshtastic.proto.FromRadio
/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
@@ -88,6 +89,7 @@ class FromRadioPacketHandlerImpl(
val xmodemPacket = proto.xmodemPacket
val lockdownStatus = proto.lockdown_status
val displayFrame = proto.display_frame
val displayPalette = proto.display_palette
when {
myInfo != null -> configFlowManager.value.handleMyInfo(myInfo, session)
@@ -137,6 +139,8 @@ class FromRadioPacketHandlerImpl(
displayFrame != null -> handleDisplayFrame(displayFrame, session)
displayPalette != null -> handleDisplayPalette(displayPalette, session)
lockdownStatus != null ->
runIfSessionActive(session, "lockdown status") {
lockdownCoordinator.handleLockdownStatus(lockdownStatus)
@@ -156,6 +160,9 @@ class FromRadioPacketHandlerImpl(
private fun handleDisplayFrame(frame: DisplayFrame, session: RadioSessionContext) =
runIfSessionActive(session, "display frame") { displayMirrorManager.value.handleIncomingFrame(frame) }
private fun handleDisplayPalette(palette: DisplayPalette, session: RadioSessionContext) =
runIfSessionActive(session, "display palette") { displayMirrorManager.value.handleIncomingPalette(palette) }
private fun runIfSessionActive(session: RadioSessionContext, operation: String, block: () -> Unit) {
if (!radioInterfaceService.runIfSessionActive(session, block)) {
Logger.d { "Discarding $operation from stale transport session" }
@@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager
import okio.ByteString.Companion.toByteString
import org.meshtastic.proto.DisplayFrame
import org.meshtastic.proto.DisplayPalette
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
@@ -126,6 +127,50 @@ class DisplayMirrorManagerImplTest {
assertNull(manager.frame.value)
}
@Test
fun `reassembles a two-chunk palette and tags frames with its signature`() {
val region =
DisplayPalette.ColorRegion(x = 0, y = 0, width = 128, height = 16, on_color = 0xF800, off_color = 0)
manager.handleIncomingPalette(
DisplayPalette(
signature = 42,
default_on_color = 0xFFFF,
default_off_color = 0,
region_offset = 0,
region_total = 3,
regions = listOf(region, region),
),
)
assertNull(manager.palette.value)
manager.handleIncomingPalette(
DisplayPalette(signature = 42, region_offset = 2, region_total = 3, regions = listOf(region)),
)
val palette = manager.palette.value!!
assertEquals(42, palette.signature)
assertEquals(0xFFFF, palette.defaultOnColor)
assertEquals(3, palette.regions.size)
}
@Test
fun `out-of-sequence palette chunk is dropped until a restart`() {
val region = DisplayPalette.ColorRegion(x = 0, y = 0, width = 8, height = 8)
manager.handleIncomingPalette(
DisplayPalette(signature = 7, region_offset = 0, region_total = 2, regions = listOf(region)),
)
manager.handleIncomingPalette(
DisplayPalette(signature = 7, region_offset = 5, region_total = 2, regions = listOf(region)),
)
assertNull(manager.palette.value)
// A fresh offset-0 chunk recovers
manager.handleIncomingPalette(
DisplayPalette(signature = 8, region_offset = 0, region_total = 1, regions = listOf(region)),
)
assertEquals(8, manager.palette.value?.signature)
}
private companion object {
const val WIDTH = 128
const val HEIGHT = 64
@@ -18,6 +18,7 @@ package org.meshtastic.core.repository
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.proto.DisplayFrame
import org.meshtastic.proto.DisplayPalette
/**
* Reassembles the chunked [DisplayFrame] stream from the connected device into complete framebuffer snapshots.
@@ -30,27 +31,52 @@ interface DisplayMirrorManager {
/** Latest completely reassembled frame, or null before the first one arrives. */
val frame: StateFlow<MirrorFrame?>
/** Latest completely reassembled color palette, or null when the device renders monochrome. */
val palette: StateFlow<MirrorPalette?>
/** Routes an incoming display frame chunk from the device to the reassembly state machine. */
fun handleIncomingFrame(chunk: DisplayFrame)
/** Routes an incoming palette chunk from the device to the reassembly state machine. */
fun handleIncomingPalette(chunk: DisplayPalette)
}
/**
* A complete color palette for [MirrorFrame]s whose `paletteSignature` matches [signature]: per-region RGB565 on/off
* colors (later regions override earlier ones where they overlap) plus defaults for pixels outside all regions.
*/
data class MirrorPalette(
val signature: Int,
val defaultOnColor: Int,
val defaultOffColor: Int,
val regions: List<DisplayPalette.ColorRegion>,
)
/**
* 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) {
data class MirrorFrame(
val width: Int,
val height: Int,
val frameId: Int,
val paletteSignature: Int,
val pixels: ByteArray,
) {
override fun equals(other: Any?): Boolean = other is MirrorFrame &&
other.width == width &&
other.height == height &&
other.frameId == frameId &&
other.paletteSignature == paletteSignature &&
other.pixels.contentEquals(pixels)
override fun hashCode(): Int {
var result = width
result = 31 * result + height
result = 31 * result + frameId
result = 31 * result + paletteSignature
result = 31 * result + pixels.contentHashCode()
return result
}
@@ -17,7 +17,6 @@
package org.meshtastic.feature.settings.debugging
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
@@ -28,9 +27,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -55,12 +52,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
@@ -68,8 +60,6 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -91,6 +81,7 @@ import org.meshtastic.core.repository.AdminController
import org.meshtastic.core.repository.ConnectionStateProvider
import org.meshtastic.core.repository.DisplayMirrorManager
import org.meshtastic.core.repository.MirrorFrame
import org.meshtastic.core.repository.MirrorPalette
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.ui.icon.KeyboardArrowDown
import org.meshtastic.core.ui.icon.KeyboardArrowLeft
@@ -99,9 +90,6 @@ import org.meshtastic.core.ui.icon.KeyboardArrowUp
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.proto.DisplayInfo
// 4x scale for the common 128px-wide OLED; caps the image so the D-pad stays above the fold on desktop.
private val MAX_CANVAS_WIDTH = 512.dp
// M3 comfortable target for remote-control keys (48dp minimum + breathing room).
private val DPAD_KEY_SIZE = 56.dp
private val DPAD_GAP = 8.dp
@@ -113,9 +101,6 @@ private const val REPEAT_INTERVAL_MS = 100L
// Swipes on the mirror shorter than this are ignored as accidental.
private val SWIPE_THRESHOLD = 48.dp
// MONO_VLSB packs 8 vertically adjacent pixels per byte (one "page" row).
private const val PIXELS_PER_PAGE = 8
// Firmware input_broker_event codes (src/input/InputBroker.h).
private const val INPUT_SELECT = 10
private const val INPUT_SELECT_LONG = 11
@@ -135,6 +120,8 @@ class DisplayMirrorViewModel(
val frame: StateFlow<MirrorFrame?> = displayMirrorManager.frame
val palette: StateFlow<MirrorPalette?> = displayMirrorManager.palette
val connected: StateFlow<Boolean> =
connectionStateProvider.connectionState
.map { it is ConnectionState.Connected }
@@ -174,6 +161,7 @@ class DisplayMirrorViewModel(
@Composable
fun DisplayMirrorContent(modifier: Modifier = Modifier, viewModel: DisplayMirrorViewModel = koinViewModel()) {
val frame by viewModel.frame.collectAsStateWithLifecycle()
val palette by viewModel.palette.collectAsStateWithLifecycle()
val mirroring by viewModel.mirroring.collectAsStateWithLifecycle()
val connected by viewModel.connected.collectAsStateWithLifecycle()
val displayInfo by viewModel.displayInfo.collectAsStateWithLifecycle()
@@ -208,7 +196,7 @@ fun DisplayMirrorContent(modifier: Modifier = Modifier, viewModel: DisplayMirror
when {
!connected -> Text(text = "Not connected to a device.")
currentFrame != null -> MirrorSurface(currentFrame, onEvent = viewModel::sendKey)
currentFrame != null -> MirrorSurface(currentFrame, palette, onEvent = viewModel::sendKey)
else -> Text(text = "No frame received yet — enable mirroring or tap Refresh.")
}
@@ -221,7 +209,12 @@ fun DisplayMirrorContent(modifier: Modifier = Modifier, viewModel: DisplayMirror
* Esc/Backspace = Back), swipe in a cardinal direction for a single direction event.
*/
@Composable
private fun MirrorSurface(frame: MirrorFrame, onEvent: (Int) -> Unit, modifier: Modifier = Modifier) {
private fun MirrorSurface(
frame: MirrorFrame,
palette: MirrorPalette?,
onEvent: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
val focusColor = MaterialTheme.colorScheme.primary
@@ -247,7 +240,7 @@ private fun MirrorSurface(frame: MirrorFrame, onEvent: (Int) -> Unit, modifier:
.swipeToDirection(onEvent)
.border(width = 2.dp, color = if (focused) focusColor else Color.Transparent),
) {
MirrorFrameImage(frame)
MirrorFrameImage(frame, palette)
}
Text(
text =
@@ -419,41 +412,3 @@ private fun BackKey(enabled: Boolean, onBack: () -> Unit) {
@Composable
private fun contentColorFor(enabled: Boolean): Color =
if (enabled) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
/**
* Renders a MONO_VLSB 1bpp framebuffer once per frame into a 1:1 [ImageBitmap] and scales it up with nearest-neighbor
* filtering — crisp device pixels, no fractional-scale seams, one pixel walk per frame instead of per recomposition.
*/
@Composable
private fun MirrorFrameImage(frame: MirrorFrame, modifier: Modifier = Modifier) {
val bitmap = remember(frame) { renderFrame(frame) }
Image(
bitmap = bitmap,
contentDescription = "Device screen",
modifier =
modifier
.widthIn(max = MAX_CANVAS_WIDTH)
.fillMaxWidth()
.aspectRatio(frame.width.toFloat() / frame.height.toFloat()),
filterQuality = FilterQuality.None,
)
}
private fun renderFrame(frame: MirrorFrame): ImageBitmap {
val bitmap = ImageBitmap(frame.width, frame.height)
val size = Size(frame.width.toFloat(), frame.height.toFloat())
CanvasDrawScope().draw(Density(1f), LayoutDirection.Ltr, Canvas(bitmap), size) {
drawRect(color = Color.Black)
val pixel = Size(1f, 1f)
for (y in 0 until frame.height) {
val page = (y / PIXELS_PER_PAGE) * frame.width
val bit = 1 shl (y % PIXELS_PER_PAGE)
for (x in 0 until frame.width) {
if (frame.pixels[page + x].toInt() and bit != 0) {
drawRect(color = Color.White, topLeft = Offset(x.toFloat(), y.toFloat()), size = pixel)
}
}
}
}
return bitmap
}
@@ -0,0 +1,123 @@
/*
* 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.Image
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
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.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import org.meshtastic.core.repository.MirrorFrame
import org.meshtastic.core.repository.MirrorPalette
// 4x scale for the common 128px-wide OLED; caps the image so the D-pad stays above the fold on desktop.
private val MAX_CANVAS_WIDTH = 512.dp
// MONO_VLSB packs 8 vertically adjacent pixels per byte (one "page" row).
private const val PIXELS_PER_PAGE = 8
/**
* Renders a MONO_VLSB 1bpp framebuffer once per frame into a 1:1 [ImageBitmap] and scales it up with nearest-neighbor
* filtering — crisp device pixels, no fractional-scale seams, one pixel walk per frame instead of per recomposition.
*/
@Composable
internal fun MirrorFrameImage(frame: MirrorFrame, palette: MirrorPalette?, modifier: Modifier = Modifier) {
// A palette only applies when the frame references it; otherwise render monochrome.
val activePalette = palette?.takeIf { frame.paletteSignature != 0 && it.signature == frame.paletteSignature }
val bitmap = remember(frame, activePalette) { renderFrame(frame, activePalette) }
Image(
bitmap = bitmap,
contentDescription = "Device screen",
modifier =
modifier
.widthIn(max = MAX_CANVAS_WIDTH)
.fillMaxWidth()
.aspectRatio(frame.width.toFloat() / frame.height.toFloat()),
filterQuality = FilterQuality.None,
)
}
private class ResolvedRegion(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
val on: Color,
val off: Color,
)
private fun rgb565ToColor(v: Int): Color = Color(
red = ((v ushr 11) and 0x1F) * 255 / 31,
green = ((v ushr 5) and 0x3F) * 255 / 63,
blue = (v and 0x1F) * 255 / 31,
)
private fun renderFrame(frame: MirrorFrame, palette: MirrorPalette?): ImageBitmap {
val defaultOn = palette?.let { rgb565ToColor(it.defaultOnColor) } ?: Color.White
val defaultOff = palette?.let { rgb565ToColor(it.defaultOffColor) } ?: Color.Black
val regions =
palette?.regions.orEmpty().map {
ResolvedRegion(
left = it.x,
top = it.y,
right = it.x + it.width,
bottom = it.y + it.height,
on = rgb565ToColor(it.on_color),
off = rgb565ToColor(it.off_color),
)
}
val bitmap = ImageBitmap(frame.width, frame.height)
val size = Size(frame.width.toFloat(), frame.height.toFloat())
CanvasDrawScope().draw(Density(1f), LayoutDirection.Ltr, Canvas(bitmap), size) {
drawRect(color = defaultOff)
val pixel = Size(1f, 1f)
for (y in 0 until frame.height) {
val page = (y / PIXELS_PER_PAGE) * frame.width
val bit = 1 shl (y % PIXELS_PER_PAGE)
// Cull to the regions overlapping this row; the last matching region wins,
// matching the firmware's precedence.
val rowRegions = regions.filter { y >= it.top && y < it.bottom }
for (x in 0 until frame.width) {
val set = frame.pixels[page + x].toInt() and bit != 0
val region = rowRegions.lastOrNull { x >= it.left && x < it.right }
val color =
when {
region != null -> if (set) region.on else region.off
set -> defaultOn
else -> defaultOff
}
if (color != defaultOff) {
drawRect(color = color, topLeft = Offset(x.toFloat(), y.toFloat()), size = pixel)
}
}
}
}
return bitmap
}