From ebcba96042b0991b2270cd643e95c65b12f40fd6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:02:29 -0500 Subject: [PATCH] feat(debug): render MUI dirty-rect streams (Format.RGB565) DisplayMirrorManager reassembles RGB565 rect chunks (sequence, geometry, and panel-bound validation) and composites them into a persistent little-endian RGB565 canvas emitted per completed rect; the renderer gains a true-color branch using the same run-length row drawing. Mono frames are untouched. Co-Authored-By: Claude Fable 5 --- .../data/manager/DisplayMirrorManagerImpl.kt | 95 +++++++++++++++++++ .../core/repository/DisplayMirrorManager.kt | 18 ++-- .../settings/debugging/MirrorFrameRenderer.kt | 47 ++++++++- 3 files changed, 153 insertions(+), 7 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImpl.kt index 61be808a11..7a5e4bdbd6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DisplayMirrorManagerImpl.kt @@ -21,6 +21,7 @@ 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.MirrorFormat import org.meshtastic.core.repository.MirrorFrame import org.meshtastic.core.repository.MirrorPalette import org.meshtastic.proto.DisplayFrame @@ -56,6 +57,14 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager { private var paletteDefaultOff = 0 override fun handleIncomingFrame(chunk: DisplayFrame) { + if (chunk.format == DisplayFrame.Format.RGB565 && chunk.rect_width > 0) { + handleRectChunk(chunk) + } else { + handleMonoChunk(chunk) + } + } + + private fun handleMonoChunk(chunk: DisplayFrame) { val data = chunk.data_.toByteArray() val total = chunk.total_size @@ -164,9 +173,92 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager { return acceptable } + // ── RGB565 dirty-rect stream (LVGL/MUI devices) ───────────────────────── + // Rects composite into a persistent little-endian RGB565 canvas; frames + // are emitted coalesced so a burst of rects costs one canvas copy. + + private var canvas: ByteArray? = null + private var canvasWidth = 0 + private var canvasHeight = 0 + private var rectBuffer: ByteArray? = null + private var rectReceived = 0 + private var rectFrameId = 0 + + private fun handleRectChunk(chunk: DisplayFrame) { + val data = chunk.data_.toByteArray() + if (chunk.offset == 0) { + rectBuffer = ByteArray(chunk.total_size) + rectFrameId = chunk.frame_id + rectReceived = 0 + } + if (!isAcceptableRectChunk(chunk, data.size)) { + rectBuffer = null + return + } + val buf = rectBuffer ?: return + data.copyInto(buf, chunk.offset) + rectReceived += data.size + if (rectReceived == chunk.total_size) { + compositeRect(chunk, buf) + rectBuffer = null + } + } + + private fun isAcceptableRectChunk(chunk: DisplayFrame, dataSize: Int): Boolean { + val bytes = chunk.rect_width * chunk.rect_height * 2 + val withinPanel = + chunk.width in 1..MAX_PANEL_EDGE && + chunk.height in 1..MAX_PANEL_EDGE && + chunk.rect_x + chunk.rect_width <= chunk.width && + chunk.rect_y + chunk.rect_height <= chunk.height + val inSequence = rectBuffer != null && chunk.frame_id == rectFrameId && chunk.offset == rectReceived + val acceptable = + withinPanel && inSequence && bytes == chunk.total_size && chunk.offset + dataSize <= chunk.total_size + if (!acceptable) { + Logger.w { + "DisplayMirror: dropping bad rect ${chunk.rect_width}x${chunk.rect_height} total=${chunk.total_size}" + } + } + return acceptable + } + + private fun compositeRect(chunk: DisplayFrame, rect: ByteArray) { + if (canvas == null || canvasWidth != chunk.width || canvasHeight != chunk.height) { + canvas = ByteArray(chunk.width * chunk.height * 2) + canvasWidth = chunk.width + canvasHeight = chunk.height + } + val target = canvas ?: return + val rowBytes = chunk.rect_width * 2 + for (row in 0 until chunk.rect_height) { + val src = row * rowBytes + val dst = ((chunk.rect_y + row) * canvasWidth + chunk.rect_x) * 2 + rect.copyInto(target, dst, src, src + rowBytes) + } + // One emission per completed rect: rects are already flush-coalesced + // device-side, and StateFlow conflates under a slow collector. + emitCanvas(chunk.frame_id) + } + + private fun emitCanvas(frameId: Int) { + val target = canvas ?: return + _frame.value = + MirrorFrame( + width = canvasWidth, + height = canvasHeight, + frameId = frameId, + paletteSignature = 0, + pixels = target.copyOf(), + format = MirrorFormat.RGB565, + ) + } + override fun reset() { buffer = null received = 0 + canvas = null + rectBuffer = null + rectReceived = 0 paletteRegions = mutableListOf() paletteReceived = 0 paletteSignature = 0 @@ -181,6 +273,9 @@ class DisplayMirrorManagerImpl : DisplayMirrorManager { // The firmware's region table caps at 48; anything past this is a bug or an attack. const val MAX_PALETTE_REGIONS = 512 + // Sanity bound for RGB565 panels (largest realistic is 800x480). + const val MAX_PANEL_EDGE = 1024 + // MONO_VLSB packs 8 vertically adjacent pixels per byte (one "page" row). const val PIXELS_PER_PAGE = 8 } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DisplayMirrorManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DisplayMirrorManager.kt index 7f075df293..cdfd971b71 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DisplayMirrorManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DisplayMirrorManager.kt @@ -58,24 +58,30 @@ data class MirrorPalette( val regions: List, ) -/** - * 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`. - */ +/** Pixel encodings a [MirrorFrame] can carry. */ +enum class MirrorFormat { + /** 1 bit per pixel, vertical LSB-first pages — byte index `x + (y / 8) * width`, bit index `y % 8`. */ + MONO_VLSB, + + /** 16 bits per pixel, little-endian RGB565, rows tightly packed. */ + RGB565, +} + +/** One complete device framebuffer snapshot in [format]. */ data class MirrorFrame( val width: Int, val height: Int, val frameId: Int, val paletteSignature: Int, val pixels: ByteArray, + val format: MirrorFormat = MirrorFormat.MONO_VLSB, ) { override fun equals(other: Any?): Boolean = other is MirrorFrame && other.width == width && other.height == height && other.frameId == frameId && other.paletteSignature == paletteSignature && + other.format == format && other.pixels.contentEquals(pixels) override fun hashCode(): Int { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/MirrorFrameRenderer.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/MirrorFrameRenderer.kt index 14272d637d..0c69d608f1 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/MirrorFrameRenderer.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/MirrorFrameRenderer.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.repository.MirrorFormat import org.meshtastic.core.repository.MirrorFrame import org.meshtastic.core.repository.MirrorPalette import org.meshtastic.core.resources.Res @@ -76,6 +77,11 @@ internal class ResolvedRegion( val off: Color, ) +/** Reads one little-endian RGB565 value from a packed pixel row. */ +@Suppress("MagicNumber") +private fun leRgb565At(pixels: ByteArray, index: Int): Int = + (pixels[index].toInt() and 0xFF) or ((pixels[index + 1].toInt() and 0xFF) shl 8) + /** Expands RGB565 to 8-bit channels by bit replication (the canonical expansion; plain scaling floors). */ @Suppress("MagicNumber") internal fun rgb565ToColor(v: Int): Color { @@ -114,7 +120,46 @@ internal fun MirrorPalette.resolveRegions(): List = regions.map ) } -private fun renderFrame(frame: MirrorFrame, palette: MirrorPalette?): ImageBitmap { +private fun renderFrame(frame: MirrorFrame, palette: MirrorPalette?): ImageBitmap = when (frame.format) { + MirrorFormat.RGB565 -> renderRgb565Frame(frame) + MirrorFormat.MONO_VLSB -> renderMonoFrame(frame, palette) +} + +/** True-color frames (LVGL/MUI devices): little-endian RGB565, drawn as horizontal same-color runs. */ +private fun renderRgb565Frame(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) + for (y in 0 until frame.height) { + val rowBase = y * frame.width * 2 + var runStart = 0 + var runColor: Color? = null + fun flush(endExclusive: Int) { + val color = runColor + if (color != null && color != Color.Black) { + drawRect( + color = color, + topLeft = Offset(runStart.toFloat(), y.toFloat()), + size = Size((endExclusive - runStart).toFloat(), 1f), + ) + } + } + for (x in 0 until frame.width) { + val color = rgb565ToColor(leRgb565At(frame.pixels, rowBase + x * 2)) + if (color != runColor) { + flush(x) + runStart = x + runColor = color + } + } + flush(frame.width) + } + } + return bitmap +} + +private fun renderMonoFrame(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?.resolveRegions().orEmpty()