mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-08 19:41:30 -04:00
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:
1 parent
21bc10affb
commit
ebcba96042
3 files changed
+153
-7
No files matched your search
+95
@@ -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
|
||||
}
|
||||
|
||||
+12
-6
@@ -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 {
|
||||
|
||||
+46
-1
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user