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 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Fable 5 committed 2026-09-09 11:19:43 -05:00
1 parent 21bc10affb
commit ebcba96042
3 files changed
+153 -7

No files matched your search

@@ -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
}
@@ -58,24 +58,30 @@ data class MirrorPalette(
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`.
*/
/** 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 {
@@ -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<ResolvedRegion> = 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()