diff --git a/protobufs b/protobufs
index 7b2464c9b8c..575536388c0 160000
--- a/protobufs
+++ b/protobufs
@@ -1 +1 @@
-Subproject commit 7b2464c9b8c1521f93852261e4123826e5b25e11
+Subproject commit 575536388c04966249db9faee7b1b486b1f35aa3
diff --git a/src/configuration.h b/src/configuration.h
index 0c99b6631fc..aca028095ce 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -632,6 +632,24 @@ along with this program. If not, see .
#define HAS_SCREEN 0
#endif
+// Display mirroring to the client (FromRadio.display_frame) rides the screen
+// and can be excluded independently with MESHTASTIC_EXCLUDE_SCREEN_MIRROR.
+#if HAS_SCREEN && !defined(MESHTASTIC_EXCLUDE_SCREEN_MIRROR)
+#define HAS_SCREEN_MIRROR 1
+#else
+#define HAS_SCREEN_MIRROR 0
+#endif
+
+// Mirroring MUI (device-ui/LVGL) streams RGB565 dirty rects instead of the
+// 1bpp framebuffer, and needs a device-ui carrying the flush observer. Until
+// that lands upstream the whole path - queue, pool and input seam - compiles
+// out, so a color build that does not opt in carries none of it.
+#if HAS_SCREEN_MIRROR && HAS_TFT && defined(MESHTASTIC_MUI_MIRROR)
+#define HAS_MUI_MIRROR 1
+#else
+#define HAS_MUI_MIRROR 0
+#endif
+
#ifndef USE_ETHERNET_DEFAULT
#define USE_ETHERNET_DEFAULT 0
#endif
diff --git a/src/graphics/HUB75Display.cpp b/src/graphics/HUB75Display.cpp
index f1b0528f1f5..d6e27a484ba 100644
--- a/src/graphics/HUB75Display.cpp
+++ b/src/graphics/HUB75Display.cpp
@@ -3,6 +3,7 @@
#if defined(USE_HUB75)
#include "HUB75Display.h"
+#include "ScreenMirror.h"
#include "TFTColorRegions.h"
#include "TFTPalette.h"
#include
@@ -121,6 +122,9 @@ void HUB75Display::display()
firstFrame = false;
#if GRAPHICS_TFT_COLORING_ENABLED
lastColorSig = colorSig;
+#if HAS_SCREEN_MIRROR
+ graphics::screenMirror.capturePalette(colorSig, onBe, offBe, graphics::colorRegions, graphics::getTFTColorRegionCount());
+#endif
// Regions are re-registered every frame by the renderers; clear so they
// don't accumulate across frames.
graphics::clearTFTColorRegions();
diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index 689004dff85..dcbebd868de 100644
--- a/src/graphics/Screen.cpp
+++ b/src/graphics/Screen.cpp
@@ -23,6 +23,7 @@ along with this program. If not, see .
#include "Screen.h"
#include "NodeDB.h"
#include "PowerMon.h"
+#include "ScreenMirror.h"
#include "Throttle.h"
#include "configuration.h"
#include "meshUtils.h"
@@ -181,6 +182,18 @@ static void drawLockdownLockScreen(OLEDDisplay *display)
}
#endif
+// Give ScreenMirror a look at the committed framebuffer (no-op unless armed).
+// Deliberately scoped to updateUiFrame commits: the few direct display()
+// paths it bypasses (EInk re-commits, boot logo) immediately follow or
+// precede a mirrored frame.
+static inline void screenMirrorCapture()
+{
+#if HAS_SCREEN_MIRROR
+ if (screen)
+ screenMirror.onRendered(screen->getDisplayDevice());
+#endif
+}
+
static inline void updateUiFrame(OLEDDisplayUi *ui)
{
#ifdef MESHTASTIC_LOCKDOWN
@@ -208,6 +221,8 @@ static inline void updateUiFrame(OLEDDisplayUi *ui)
NotificationRenderer::drawBannercallback(display, ui->getUiState());
}
display->display();
+ // The mirror sees the LOCKED frame, matching the panel's redaction.
+ screenMirrorCapture();
return;
}
#endif
@@ -215,6 +230,7 @@ static inline void updateUiFrame(OLEDDisplayUi *ui)
prepareFrameColorRegions();
#endif
ui->update();
+ screenMirrorCapture();
}
// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization
diff --git a/src/graphics/ScreenMirror.cpp b/src/graphics/ScreenMirror.cpp
new file mode 100644
index 00000000000..f56274d4980
--- /dev/null
+++ b/src/graphics/ScreenMirror.cpp
@@ -0,0 +1,364 @@
+#include "ScreenMirror.h"
+
+#if HAS_SCREEN_MIRROR
+
+#include "DebugConfiguration.h"
+#include "concurrency/LockGuard.h"
+#include "memory/MemAudit.h"
+#include
+#include
+
+#include "TFTColorRegions.h"
+
+namespace graphics
+{
+
+ScreenMirror screenMirror;
+
+namespace
+{
+// Region colors arrive panel-byte-order (big-endian RGB565); the wire
+// carries logical bit layout.
+inline uint16_t swap16(uint16_t v)
+{
+ return (uint16_t)((v >> 8) | (v << 8));
+}
+} // namespace
+
+void ScreenMirror::freeSnapshotLocked()
+{
+ if (snapshot) {
+ memaudit::add("display", -(int32_t)frameSize);
+ free(snapshot);
+ snapshot = nullptr;
+ }
+ frameSize = 0;
+ if (paletteRegions) {
+ memaudit::add("display", -(int32_t)(sizeof(PaletteRegion) * MAX_TFT_COLOR_REGIONS));
+ free(paletteRegions);
+ paletteRegions = nullptr;
+ }
+ paletteSig = 0;
+ paletteCount = 0;
+#if HAS_MUI_MIRROR
+ if (muiPool) {
+ memaudit::add("display", -(int32_t)MUI_POOL_BYTES);
+ free(muiPool);
+ muiPool = nullptr;
+ }
+ muiHead = muiCount = 0;
+ muiPoolUsed = 0;
+ muiRectSendOffset = 0;
+ muiOwner = nullptr;
+#endif
+}
+
+void ScreenMirror::setMirror(bool enabled)
+{
+ concurrency::LockGuard g(&lock);
+ // Cleanup must stay unconditional: a one-shot request leaves mirroring
+ // false, so an early return would strand the pool and the snapshot.
+ const bool changed = mirroring != enabled || oneShot;
+ mirroring = enabled;
+ if (enabled) {
+ // Force an immediate frame so the client doesn't wait for the next
+ // on-screen change.
+ oneShot = true;
+#if HAS_MUI_MIRROR
+ if (muiRefresh)
+ muiRefresh(); // MUI delivers "a frame" as a full-repaint rect burst
+#endif
+ } else {
+ oneShot = false;
+ freeSnapshotLocked(); // also releases the MUI pool and rect ownership
+ }
+ if (changed)
+ LOG_INFO("Screen mirror %s", enabled ? "enabled" : "disabled");
+}
+
+void ScreenMirror::requestFrame()
+{
+ concurrency::LockGuard g(&lock);
+ oneShot = true;
+#if HAS_MUI_MIRROR
+ if (muiRefresh)
+ muiRefresh();
+#endif
+}
+
+void ScreenMirror::onRendered(OLEDDisplay *display)
+{
+ uint32_t readyId = 0;
+ {
+ concurrency::LockGuard g(&lock);
+ if (!mirroring && !oneShot)
+ return;
+ if (!display || !display->buffer)
+ return;
+
+ uint16_t w = display->getWidth();
+ uint16_t h = display->getHeight();
+ uint32_t fullSize = (uint32_t)w * ((h + 7) / 8);
+ if (fullSize == 0)
+ return;
+ if (fullSize > UINT16_MAX) {
+ LOG_WARN("Screen mirror: %ux%u framebuffer too large to stream", w, h);
+ mirroring = oneShot = false;
+ return;
+ }
+ uint16_t size = (uint16_t)fullSize;
+
+ if (snapshot && size != frameSize)
+ freeSnapshotLocked(); // display geometry changed; start over
+
+ bool firstFrame = !snapshot;
+ if (firstFrame) {
+ snapshot = (uint8_t *)malloc(size);
+ if (!snapshot) {
+ LOG_ERROR("Screen mirror: no memory for %u byte snapshot", size);
+ mirroring = oneShot = false;
+ return;
+ }
+ memaudit::add("display", size);
+ frameSize = size;
+ width = w;
+ height = h;
+ }
+
+ // A palette-only change (theme recolor) is frame-worthy even when the
+ // mono bits are identical: the client keys colors off the frame's signature.
+ bool paletteChanged = snapshotPaletteSig != paletteSig;
+ if (!firstFrame && !oneShot && !paletteChanged && memcmp(display->buffer, snapshot, frameSize) == 0)
+ return;
+
+ memcpy(snapshot, display->buffer, frameSize);
+ snapshotPaletteSig = paletteSig;
+ frameId++;
+ oneShot = false;
+ readyId = frameId;
+ }
+ // Notify outside the lock: observers (PhoneAPI) may re-enter hasChunkFor.
+ frameReady.notifyObservers(readyId);
+}
+
+void ScreenMirror::capturePalette(uint32_t signature, uint16_t defaultOnBe, uint16_t defaultOffBe, const TFTColorRegion *regions,
+ uint8_t count)
+{
+ concurrency::LockGuard g(&lock);
+ // Cheap when nothing changed and when the mirror is idle: paint-time
+ // callers hit this every frame, but clients only exist while mirroring.
+ if (!mirroring && !oneShot && !snapshot)
+ return;
+ if (signature == paletteSig && paletteRegions)
+ return;
+ if (!paletteRegions) {
+ paletteRegions = (PaletteRegion *)malloc(sizeof(PaletteRegion) * MAX_TFT_COLOR_REGIONS);
+ if (!paletteRegions)
+ return; // frames still stream; clients render monochrome
+ memaudit::add("display", sizeof(PaletteRegion) * MAX_TFT_COLOR_REGIONS);
+ }
+ if (count > MAX_TFT_COLOR_REGIONS)
+ count = MAX_TFT_COLOR_REGIONS;
+ for (uint8_t i = 0; i < count; i++) {
+ const TFTColorRegion &r = regions[i];
+ paletteRegions[i] = {(uint16_t)r.x, (uint16_t)r.y, (uint16_t)r.width,
+ (uint16_t)r.height, swap16(r.onColorBe), swap16(r.offColorBe)};
+ }
+ paletteCount = count;
+ paletteDefaultOn = swap16(defaultOnBe);
+ paletteDefaultOff = swap16(defaultOffBe);
+ paletteSig = signature;
+}
+
+bool ScreenMirror::hasPaletteChunkFor(uint32_t clientPaletteSig, uint8_t clientRegionOffset)
+{
+ concurrency::LockGuard g(&lock);
+ if (!paletteRegions || !snapshot)
+ return false;
+ return clientPaletteSig != paletteSig || clientRegionOffset < paletteCount;
+}
+
+bool ScreenMirror::copyPaletteChunk(uint32_t &clientPaletteSig, uint8_t &clientRegionOffset, meshtastic_DisplayPalette &out)
+{
+ concurrency::LockGuard g(&lock);
+ if (!paletteRegions || !snapshot)
+ return false;
+ if (clientPaletteSig != paletteSig) {
+ clientPaletteSig = paletteSig;
+ clientRegionOffset = 0;
+ } else if (clientRegionOffset >= paletteCount) {
+ return false;
+ }
+
+ out.signature = paletteSig;
+ out.default_on_color = paletteDefaultOn;
+ out.default_off_color = paletteDefaultOff;
+ out.region_offset = clientRegionOffset;
+ out.region_total = paletteCount;
+ uint8_t n = 0;
+ while (n < (sizeof(out.regions) / sizeof(out.regions[0])) && clientRegionOffset + n < paletteCount) {
+ const PaletteRegion &r = paletteRegions[clientRegionOffset + n];
+ out.regions[n].x = r.x;
+ out.regions[n].y = r.y;
+ out.regions[n].width = r.w;
+ out.regions[n].height = r.h;
+ out.regions[n].on_color = r.onColor;
+ out.regions[n].off_color = r.offColor;
+ n++;
+ }
+ out.regions_count = n;
+ clientRegionOffset += n;
+ return true;
+}
+
+#if HAS_MUI_MIRROR
+void ScreenMirror::onMuiRect(int16_t x, int16_t y, uint16_t w, uint16_t h, const uint16_t *pixels)
+{
+ uint32_t readyId = 0;
+ {
+ concurrency::LockGuard g(&lock);
+ if (!mirroring && !oneShot)
+ return;
+ // Panel size comes from LVGL at registration, so frames always carry the
+ // full display dimensions the wire contract promises.
+ if (x < 0 || y < 0 || w == 0 || h == 0 || muiPanelW == 0)
+ return;
+
+ uint32_t bytes = (uint32_t)w * h * 2;
+ if (!muiPool) {
+ bool psram = false;
+#ifdef ESP32
+ muiPool = (uint8_t *)ps_malloc(MUI_POOL_BYTES); // PSRAM first; this is a big buffer
+ psram = muiPool != nullptr;
+#endif
+ if (!muiPool)
+ muiPool = (uint8_t *)malloc(MUI_POOL_BYTES);
+ if (!muiPool) {
+ LOG_ERROR("Screen mirror: no memory for the %u byte rect pool", (unsigned)MUI_POOL_BYTES);
+ mirroring = oneShot = false;
+ return;
+ }
+ if (!psram)
+ memaudit::add("display", MUI_POOL_BYTES); // PSRAM is not the constrained budget
+ }
+ if (bytes > MUI_POOL_BYTES)
+ return; // a rect larger than the whole pool can never be sent
+ if (muiCount >= MUI_MAX_RECTS || muiPoolUsed + bytes > MUI_POOL_BYTES) {
+ // The consumer is behind. Drop the backlog rather than the newest
+ // pixels and repaint once: stale history has no value, and dropping
+ // only the incoming rect wedges the pool until the queue empties.
+ muiHead = muiCount = 0;
+ muiPoolUsed = 0;
+ muiRectSendOffset = 0;
+ if (muiRefresh)
+ muiRefresh();
+ return;
+ }
+ memcpy(muiPool + muiPoolUsed, pixels, bytes);
+ MuiRect &r = muiRects[(muiHead + muiCount) % MUI_MAX_RECTS];
+ r = {(uint16_t)x, (uint16_t)y, w, h, bytes, muiPoolUsed, ++frameId};
+ muiPoolUsed += bytes;
+ muiCount++;
+ // Only the empty->non-empty transition needs a wakeup; available()
+ // keeps the client draining, and a notify per flush is a storm.
+ if (muiCount == 1)
+ readyId = frameId;
+ }
+ if (readyId)
+ frameReady.notifyObservers(readyId);
+}
+
+// Fills one chunk of the oldest queued rect; pops it when fully drained.
+bool ScreenMirror::copyMuiChunkLocked(meshtastic_DisplayFrame &out)
+{
+ MuiRect &r = muiRects[muiHead];
+ uint32_t len = r.bytes - muiRectSendOffset;
+ if (len > sizeof(out.data.bytes))
+ len = sizeof(out.data.bytes);
+
+ out.width = muiPanelW;
+ out.height = muiPanelH;
+ out.format = meshtastic_DisplayFrame_Format_RGB565;
+ out.palette_signature = 0;
+ out.frame_id = r.id;
+ out.rect_x = r.x;
+ out.rect_y = r.y;
+ out.rect_width = r.w;
+ out.rect_height = r.h;
+ out.offset = muiRectSendOffset;
+ out.total_size = r.bytes;
+ out.data.size = len;
+ memcpy(out.data.bytes, muiPool + r.poolOffset + muiRectSendOffset, len);
+ muiRectSendOffset += len;
+
+ if (muiRectSendOffset >= r.bytes) {
+ muiHead = (muiHead + 1) % MUI_MAX_RECTS;
+ muiCount--;
+ muiRectSendOffset = 0;
+ if (muiCount == 0) {
+ muiPoolUsed = 0;
+ if (!mirroring)
+ oneShot = false; // one-shot fully delivered
+ }
+ }
+ return true;
+}
+#endif
+
+bool ScreenMirror::hasChunkFor(const void *client, uint32_t clientFrameId, uint16_t clientOffset)
+{
+ concurrency::LockGuard g(&lock);
+#if HAS_MUI_MIRROR
+ // One shared rect cursor means one consumer; another connection simply
+ // sees no rects rather than stealing half of each frame.
+ if (muiCount && (muiOwner == nullptr || muiOwner == client))
+ return true;
+#endif
+ return snapshot && (clientFrameId != frameId || clientOffset < frameSize);
+}
+
+bool ScreenMirror::copyChunk(const void *client, uint32_t &clientFrameId, uint16_t &clientOffset, meshtastic_DisplayFrame &out)
+{
+ concurrency::LockGuard g(&lock);
+#if HAS_MUI_MIRROR
+ // MUI rects drain ahead of (and on MUI builds, instead of) mono snapshots.
+ // Spike scope: the rect queue has a single consumer, not per-client cursors.
+ if (muiCount) {
+ if (muiOwner == nullptr)
+ muiOwner = client; // first drainer claims the stream
+ if (muiOwner != client)
+ return false;
+ return copyMuiChunkLocked(out);
+ }
+#endif
+ if (!snapshot)
+ return false;
+ if (clientFrameId != frameId) {
+ clientFrameId = frameId;
+ clientOffset = 0;
+ }
+ if (clientOffset >= frameSize)
+ return false;
+
+ uint16_t len = frameSize - clientOffset;
+ if (len > sizeof(out.data.bytes))
+ len = sizeof(out.data.bytes);
+
+ out.width = width;
+ out.height = height;
+ out.format = meshtastic_DisplayFrame_Format_MONO_VLSB;
+ // The signature captured WITH this snapshot, not the live one: a drain can
+ // span a display() that already advanced paletteSig for the next frame.
+ out.palette_signature = snapshotPaletteSig;
+ out.frame_id = frameId;
+ out.offset = clientOffset;
+ out.total_size = frameSize;
+ out.data.size = len;
+ memcpy(out.data.bytes, snapshot + clientOffset, len);
+ clientOffset += len;
+ return true;
+}
+
+} // namespace graphics
+
+#endif
diff --git a/src/graphics/ScreenMirror.h b/src/graphics/ScreenMirror.h
new file mode 100644
index 00000000000..73d42a3aa94
--- /dev/null
+++ b/src/graphics/ScreenMirror.h
@@ -0,0 +1,149 @@
+#pragma once
+#include "configuration.h"
+
+#if HAS_SCREEN_MIRROR
+
+#include "Observer.h"
+#include "concurrency/Lock.h"
+#include "mesh/generated/meshtastic/mesh.pb.h"
+
+class OLEDDisplay;
+
+namespace graphics
+{
+
+struct TFTColorRegion;
+
+/**
+ * Streams 1bpp framebuffer snapshots to local clients as
+ * FromRadio.display_frame chunks. Armed via AdminMessage
+ * set_display_mirror (continuous) or get_display_frame_request (one-shot).
+ *
+ * Holds only the latest captured frame; each PhoneAPI instance keeps its own
+ * (frameId, offset) drain cursor and pulls chunks via copyChunk, so multiple
+ * clients receive complete frames independently.
+ */
+class ScreenMirror
+{
+ public:
+ /// Fired when a new frame is ready to drain; PhoneAPI observes this.
+ Observable frameReady;
+
+ void setMirror(bool enabled);
+ void requestFrame();
+
+ /// Called by Screen after each frame commit. Snapshots the framebuffer
+ /// when armed and the contents changed since the last captured frame.
+ void onRendered(OLEDDisplay *display);
+
+ /// True while this client has undelivered bytes of the current frame.
+ /// `client` identifies the connection (its PhoneAPI instance).
+ bool hasChunkFor(const void *client, uint32_t clientFrameId, uint16_t clientOffset);
+
+ /// Fills the next chunk for a client cursor, advancing it; false when the
+ /// client is fully caught up (or no frame exists). A frame captured while
+ /// the client was mid-drain restarts it at offset 0 of the new frame.
+ bool copyChunk(const void *client, uint32_t &clientFrameId, uint16_t &clientOffset, meshtastic_DisplayFrame &out);
+
+ /// Called by the color display drivers at paint time, before they clear
+ /// the per-frame region table: stores the palette the frame was painted
+ /// with. Colors arrive panel-byte-order (big-endian RGB565).
+ void capturePalette(uint32_t signature, uint16_t defaultOnBe, uint16_t defaultOffBe, const TFTColorRegion *regions,
+ uint8_t count);
+
+ /// True while the client cursor lacks regions of the current color palette.
+ bool hasPaletteChunkFor(uint32_t clientPaletteSig, uint8_t clientRegionOffset);
+
+ /// Fills the next palette chunk for a client cursor, advancing it; false
+ /// when the client holds the full current palette (or coloring is off).
+ bool copyPaletteChunk(uint32_t &clientPaletteSig, uint8_t &clientRegionOffset, meshtastic_DisplayPalette &out);
+
+#if HAS_MUI_MIRROR
+ /// MUI path: queues one LVGL dirty rect (native little-endian RGB565).
+ /// Called on the LVGL thread via the device-ui flush observer; copies and returns.
+ void onMuiRect(int16_t x, int16_t y, uint16_t w, uint16_t h, const uint16_t *pixels);
+
+ /// Registers device-ui's thread-safe full-repaint request plus the panel
+ /// size, so streamed frames carry the full display dimensions from the
+ /// first rect rather than growing into them.
+ using FullRefreshFn = void (*)();
+ void setMuiSource(FullRefreshFn fn, uint16_t panelWidth, uint16_t panelHeight)
+ {
+ muiRefresh = fn;
+ muiPanelW = panelWidth;
+ muiPanelH = panelHeight;
+ }
+#endif
+
+ private:
+ void freeSnapshotLocked();
+
+ concurrency::Lock lock;
+ bool mirroring = false;
+ bool oneShot = false;
+ // Latest captured frame; doubles as the change-detection baseline.
+ uint8_t *snapshot = nullptr;
+ uint16_t frameSize = 0;
+ uint16_t width = 0;
+ uint16_t height = 0;
+ uint32_t frameId = 0;
+ // Signature of the palette the current snapshot was painted with; frames
+ // carry this, not the live paletteSig, so mid-drain captures stay coherent.
+ uint32_t snapshotPaletteSig = 0;
+ // Color-region palette captured at paint time (TFT/HUB75 builds only).
+ uint32_t paletteSig = 0;
+ uint8_t paletteCount = 0;
+ struct PaletteRegion {
+ uint16_t x, y, w, h;
+ uint16_t onColor, offColor; // logical RGB565
+ };
+ PaletteRegion *paletteRegions = nullptr;
+ uint16_t paletteDefaultOn = 0;
+ uint16_t paletteDefaultOff = 0;
+
+#if HAS_MUI_MIRROR
+ // MUI dirty-rect queue: FIFO rect headers over a linear pixel pool,
+ // compacted whenever it drains. Spike scope: single consumer.
+ struct MuiRect {
+ uint16_t x, y, w, h;
+ uint32_t bytes;
+ uint32_t poolOffset;
+ uint32_t id;
+ };
+ static constexpr uint8_t MUI_MAX_RECTS = 64;
+ // Must hold one full repaint (320x240 RGB565 = 150 KB) plus concurrent
+ // incremental rects, or arming can never deliver a complete first frame.
+ static constexpr uint32_t MUI_POOL_BYTES = 192 * 1024;
+ MuiRect muiRects[MUI_MAX_RECTS];
+ uint8_t muiHead = 0;
+ uint8_t muiCount = 0;
+ uint8_t *muiPool = nullptr;
+ uint32_t muiPoolUsed = 0;
+ uint32_t muiRectSendOffset = 0;
+ uint16_t muiPanelW = 0;
+ uint16_t muiPanelH = 0;
+ FullRefreshFn muiRefresh = nullptr;
+ const void *muiOwner = nullptr; // connection currently draining rects
+
+ bool copyMuiChunkLocked(meshtastic_DisplayFrame &out);
+#endif
+};
+
+extern ScreenMirror screenMirror;
+
+#if HAS_MUI_MIRROR
+/**
+ * Routes a remote input event straight into device-ui's injection seam.
+ * MUI builds never construct an InputBroker (Modules.cpp skips it when
+ * displaymode is COLOR), so admin input cannot travel the usual path.
+ * Returns false when MUI is not the active UI.
+ */
+bool muiInjectInputEvent(uint32_t eventCode, uint32_t kbChar, uint32_t touchX, uint32_t touchY);
+
+/** Fills MUI's panel geometry for DeviceMetadata; false when MUI is not active. */
+bool muiDisplayInfo(uint16_t &width, uint16_t &height, bool &hasTouch);
+#endif
+
+} // namespace graphics
+
+#endif
diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp
index 07bb7e6350d..2d83e3a641e 100644
--- a/src/graphics/TFTDisplay.cpp
+++ b/src/graphics/TFTDisplay.cpp
@@ -1455,6 +1455,7 @@ static LGFX *tft = nullptr;
#endif
#include "SPILock.h"
+#include "ScreenMirror.h"
#include "TFTColorRegions.h"
#include "TFTDisplay.h"
#include "TFTPalette.h"
@@ -1620,6 +1621,11 @@ void TFTDisplay::display(bool fromBlank)
haveLastDefaults = true;
lastDefaultOnColor = defaultOnColor;
lastDefaultOffColor = defaultOffColor;
+#if HAS_SCREEN_MIRROR
+ // Regions are cleared below; hand the mirror the palette this frame was painted with.
+ graphics::screenMirror.capturePalette(colorFrameSignature, defaultOnColor, defaultOffColor, graphics::colorRegions,
+ graphics::getTFTColorRegionCount());
+#endif
graphics::clearTFTColorRegions();
return;
}
@@ -1798,6 +1804,10 @@ void TFTDisplay::display(bool fromBlank)
haveLastDefaults = true;
lastDefaultOnColor = defaultOnColor;
lastDefaultOffColor = defaultOffColor;
+#if HAS_SCREEN_MIRROR && GRAPHICS_TFT_COLORING_ENABLED
+ graphics::screenMirror.capturePalette(colorFrameSignature, defaultOnColor, defaultOffColor, graphics::colorRegions,
+ graphics::getTFTColorRegionCount());
+#endif
graphics::clearTFTColorRegions();
}
diff --git a/src/graphics/tftSetup.cpp b/src/graphics/tftSetup.cpp
index 8e971cb60b1..b761458a2bc 100644
--- a/src/graphics/tftSetup.cpp
+++ b/src/graphics/tftSetup.cpp
@@ -3,11 +3,18 @@
#include "SPILock.h"
#include "sleep.h"
+#include "NodeDB.h" // config
#include "api/PacketAPI.h"
#include "comms/PacketClient.h"
#include "comms/PacketServer.h"
#include "graphics/DeviceScreen.h"
+#include "graphics/ScreenMirror.h"
+#include "graphics/driver/DisplayDriver.h"
#include "graphics/driver/DisplayDriverConfig.h"
+#include "input/InputBroker.h"
+#if HAS_MUI_MIRROR
+#include "input/InputDriver.h"
+#endif
#include "util/ISpiLock.h"
#ifdef ARCH_PORTDUINO
@@ -311,6 +318,79 @@ class ReentrantSpiLock : public ISpiLock
static ReentrantSpiLock reentrantSpiLock;
+#if HAS_MUI_MIRROR
+namespace graphics
+{
+// Reports MUI's logical panel geometry for DeviceMetadata.display. The BaseUI
+// `screen` object does not exist on MUI builds, so the dimensions come from
+// LVGL itself (already rotated to the logical orientation).
+bool muiDisplayInfo(uint16_t &width, uint16_t &height, bool &hasTouch)
+{
+ if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR)
+ return false;
+ lv_display_t *disp = lv_display_get_default();
+ if (!disp)
+ return false;
+ width = (uint16_t)lv_display_get_horizontal_resolution(disp);
+ height = (uint16_t)lv_display_get_vertical_resolution(disp);
+#if HAS_TOUCHSCREEN
+ hasTouch = true;
+#else
+ hasTouch = false;
+#endif
+ return width > 0 && height > 0;
+}
+
+// Maps a remote input event onto device-ui's virtual LVGL devices. Note the
+// LEFT/RIGHT cross-map: the broker's codes were modeled on LVGL keys but
+// those two are swapped.
+bool muiInjectInputEvent(uint32_t eventCode, uint32_t kbChar, uint32_t touchX, uint32_t touchY)
+{
+ if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR)
+ return false;
+
+ constexpr uint16_t longPressHoldMs = 600;
+ // Mirrors the trackball driver's semantics (EncoderInputDriver, type 3):
+ // vertical is encoder rotation, which is what actually moves focus in a
+ // group; horizontal becomes the slider keys, deliberately inverted there.
+ switch (eventCode) {
+ case INPUT_BROKER_UP:
+ InputDriver::injectEncoder(-1);
+ break;
+ case INPUT_BROKER_DOWN:
+ InputDriver::injectEncoder(1);
+ break;
+ case INPUT_BROKER_LEFT:
+ InputDriver::injectKey(LV_KEY_DOWN);
+ break;
+ case INPUT_BROKER_RIGHT:
+ InputDriver::injectKey(LV_KEY_UP);
+ break;
+ case INPUT_BROKER_SELECT:
+ if (touchX || touchY)
+ InputDriver::injectTouch(touchX, touchY, longPressHoldMs);
+ else
+ InputDriver::injectKey(LV_KEY_ENTER);
+ break;
+ case INPUT_BROKER_USER_PRESS:
+ InputDriver::injectTouch(touchX, touchY);
+ break;
+ case INPUT_BROKER_BACK:
+ case INPUT_BROKER_CANCEL:
+ InputDriver::injectKey(LV_KEY_ESC);
+ break;
+ default:
+ if (kbChar)
+ InputDriver::injectKey(kbChar);
+ else
+ return false;
+ break;
+ }
+ return true;
+}
+} // namespace graphics
+#endif
+
void tft_task_handler(void *param = nullptr)
{
while (true) {
@@ -329,9 +409,28 @@ void tftSetup(void)
I2CKeyboardScanner::setSecondaryBus(i2cProxy);
#endif
#ifndef ARCH_PORTDUINO
+#if HAS_MUI_MIRROR
+ // Must precede DeviceScreen::init: device-ui only builds its virtual input
+ // devices (and the default focus group they need) when injection is asked for.
+ InputDriver::enableInjection();
+#endif
deviceScreen = &DeviceScreen::create(reentrantSpiLock);
PacketAPI::create(PacketServer::init());
deviceScreen->init(new PacketClient);
+#if HAS_MUI_MIRROR
+ // Stream MUI's dirty rects to local clients (see graphics::ScreenMirror).
+ // Gated on MESHTASTIC_MUI_MIRROR until the device-ui flush observer merges
+ // (jamesarich/device-ui screen-mirror-poc); the vendored pin lacks it.
+ {
+ lv_display_t *disp = lv_display_get_default();
+ graphics::screenMirror.setMuiSource([]() { DisplayDriver::requestFullRefresh(); },
+ disp ? (uint16_t)lv_display_get_horizontal_resolution(disp) : 0,
+ disp ? (uint16_t)lv_display_get_vertical_resolution(disp) : 0);
+ }
+ DisplayDriver::setFlushObserver([](int16_t x, int16_t y, uint16_t w, uint16_t h, const uint16_t *px) {
+ graphics::screenMirror.onMuiRect(x, y, w, h, px);
+ });
+#endif
#else
if (portduino_config.displayPanel != no_screen) {
DisplayDriverConfig displayConfig;
diff --git a/src/main.cpp b/src/main.cpp
index 80b8e4f4a93..81406600b27 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -44,6 +44,7 @@
#endif
#include "detect/einkScan.h"
#include "graphics/Screen.h"
+#include "graphics/ScreenMirror.h"
#include "main.h"
#include "memory/MemAudit.h"
#include "mesh/generated/meshtastic/config.pb.h"
@@ -1358,6 +1359,52 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
deviceMetadata.has_xeddsa = true;
#endif
+
+#if HAS_MUI_MIRROR
+ // MUI owns the panel and leaves `screen` null, so ask LVGL instead.
+ uint16_t muiW = 0, muiH = 0;
+ bool muiTouch = false;
+ if (graphics::muiDisplayInfo(muiW, muiH, muiTouch)) {
+ deviceMetadata.has_display = true;
+ deviceMetadata.display.width = muiW;
+ deviceMetadata.display.height = muiH;
+ deviceMetadata.display.format = meshtastic_DisplayFrame_Format_RGB565;
+ deviceMetadata.display.panel_class = meshtastic_DisplayInfo_PanelClass_TFT;
+ deviceMetadata.display.has_touch = muiTouch;
+ } else
+#endif
+#if HAS_SCREEN
+ if (screen) {
+ OLEDDisplay *dispdev = screen->getDisplayDevice();
+ if (dispdev && dispdev->getWidth() > 0) {
+ deviceMetadata.has_display = true;
+ deviceMetadata.display.width = dispdev->getWidth();
+ deviceMetadata.display.height = dispdev->getHeight();
+ deviceMetadata.display.format = meshtastic_DisplayFrame_Format_MONO_VLSB;
+#if defined(ARCH_PORTDUINO)
+ // The native panel is selected at runtime; UNSPECIFIED is honest until derived from portduino_config.
+ deviceMetadata.display.panel_class = meshtastic_DisplayInfo_PanelClass_PANEL_CLASS_UNSPECIFIED;
+#elif defined(USE_EINK)
+ deviceMetadata.display.panel_class = meshtastic_DisplayInfo_PanelClass_EINK;
+#elif defined(USE_HUB75) || defined(HAS_HUB75_NATIVE)
+ deviceMetadata.display.panel_class = meshtastic_DisplayInfo_PanelClass_HUB75;
+// USE_TFTDISPLAY is value-tested, not defined()-tested: configuration.h
+// defaults it to 0, so it is always defined and every screen build would
+// otherwise report itself as a TFT.
+#elif USE_TFTDISPLAY || defined(HAS_SPI_TFT) || defined(USE_ST7789) || defined(USE_ST7796) || defined(ILI9341_DRIVER) || \
+ defined(ILI9342_DRIVER)
+ deviceMetadata.display.panel_class = meshtastic_DisplayInfo_PanelClass_TFT;
+#elif defined(USE_ST7567)
+ deviceMetadata.display.panel_class = meshtastic_DisplayInfo_PanelClass_LCD;
+#else
+ deviceMetadata.display.panel_class = meshtastic_DisplayInfo_PanelClass_OLED;
+#endif
+#if HAS_TOUCHSCREEN
+ deviceMetadata.display.has_touch = true;
+#endif
+ }
+ }
+#endif
return deviceMetadata;
}
diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp
index f6757206c5f..1c2518d2661 100644
--- a/src/mesh/PhoneAPI.cpp
+++ b/src/mesh/PhoneAPI.cpp
@@ -23,6 +23,7 @@
#include "SPILock.h"
#include "TypeConversions.h"
#include "concurrency/LockGuard.h"
+#include "graphics/ScreenMirror.h"
#include "main.h"
#include "modules/NodeInfoModule.h"
#include "xmodem.h"
@@ -273,6 +274,9 @@ void PhoneAPI::handleStartConfig()
#ifdef FSCom
observe(&xModem.packetReady);
#endif
+#if HAS_SCREEN_MIRROR
+ observe(&graphics::screenMirror.frameReady);
+#endif
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
// New physical connection: clear this PhoneAPI's auth slot so the new
// client must present a passphrase or PKC admin signature before
@@ -376,6 +380,17 @@ void PhoneAPI::close()
unobserve(&service->fromNumChanged);
#ifdef FSCom
unobserve(&xModem.packetReady);
+#endif
+#if HAS_SCREEN_MIRROR
+ unobserve(&graphics::screenMirror.frameReady);
+ // This client is gone; PoC keeps one arming flag, so disarm and free
+ // the snapshot rather than stream to nobody. A surviving client
+ // re-arms with another set_display_mirror.
+ graphics::screenMirror.setMirror(false);
+ mirrorFrameId = 0;
+ mirrorOffset = 0;
+ mirrorPaletteSig = 0;
+ mirrorPaletteOffset = 0;
#endif
releasePhonePacket(); // Don't leak phone packets on shutdown
releaseQueueStatusPhonePacket();
@@ -1094,6 +1109,16 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_packet_tag;
fromRadioScratch.packet = replayPkt;
}
+#if HAS_SCREEN_MIRROR
+ } else if (screenMirrorAuthorized() && graphics::screenMirror.copyPaletteChunk(mirrorPaletteSig, mirrorPaletteOffset,
+ fromRadioScratch.display_palette)) {
+ // Palette before frames so the client can colorize the first frame it renders.
+ fromRadioScratch.which_payload_variant = meshtastic_FromRadio_display_palette_tag;
+ } else if (screenMirrorAuthorized() &&
+ graphics::screenMirror.copyChunk(this, mirrorFrameId, mirrorOffset, fromRadioScratch.display_frame)) {
+ // Lowest priority: mesh traffic and notifications outrank pixels.
+ fromRadioScratch.which_payload_variant = meshtastic_FromRadio_display_frame_tag;
+#endif
}
break;
@@ -1720,7 +1745,15 @@ bool PhoneAPI::available()
return true;
// Trailing replay drain - feeds cached satellite-DB packets alongside
// (lower priority than) live traffic.
- return replayPending();
+ if (replayPending())
+ return true;
+
+#if HAS_SCREEN_MIRROR
+ return screenMirrorAuthorized() && (graphics::screenMirror.hasPaletteChunkFor(mirrorPaletteSig, mirrorPaletteOffset) ||
+ graphics::screenMirror.hasChunkFor(this, mirrorFrameId, mirrorOffset));
+#else
+ return false;
+#endif
}
default:
LOG_ERROR("PhoneAPI::available unexpected state %d", state);
@@ -1904,7 +1937,9 @@ int PhoneAPI::onNotify(uint32_t newValue)
// doesn't call this from idle)
if (state == STATE_SEND_PACKETS) {
- LOG_INFO("Tell client new packets %u", newValue);
+ // TRACE, not INFO: with display mirroring active this fires once per
+ // captured frame per client, at screen-change rate.
+ LOG_TRACE("Tell client new packets %u", newValue);
onNowHasData(newValue);
} else {
LOG_DEBUG("Client not yet interested in packets (state=%d)", state);
diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h
index ab04c178b0b..730cc2b5ae8 100644
--- a/src/mesh/PhoneAPI.h
+++ b/src/mesh/PhoneAPI.h
@@ -89,6 +89,27 @@ class PhoneAPI
// file transfer packets destined for phone. Push it to the queue then free it.
meshtastic_XModem xmodemPacketForPhone = meshtastic_XModem_init_zero;
+#if HAS_SCREEN_MIRROR
+ // Per-connection drain cursors into ScreenMirror's current frame and
+ // color palette, so coexisting clients (BLE + serial + TCP) each receive
+ // complete frames and palettes.
+ uint32_t mirrorFrameId = 0;
+ uint16_t mirrorOffset = 0;
+ uint32_t mirrorPaletteSig = 0;
+ uint8_t mirrorPaletteOffset = 0;
+
+ // Screen pixels carry operator content; under access control only an
+ // authorized client may receive them (same rule as mesh packets).
+ bool screenMirrorAuthorized()
+ {
+#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
+ return getAdminAuthorized();
+#else
+ return true;
+#endif
+ }
+#endif
+
// Keep QueueStatus packet just as packetForPhone
meshtastic_QueueStatus *queueStatusPacketForPhone = NULL;
diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h
index ccf6f54c8bc..049ca7689b6 100644
--- a/src/mesh/generated/meshtastic/admin.pb.h
+++ b/src/mesh/generated/meshtastic/admin.pb.h
@@ -186,7 +186,7 @@ typedef struct _meshtastic_LockdownAuth {
token at unlock time: the client-supplied boots_remaining when
non-zero, otherwise the firmware default (TOKEN_DEFAULT_BOOTS).
Note that boots_remaining == 0 in this message means "use firmware
- default", NOT "zero boots" — a client computing the ceiling for
+ default", NOT "zero boots" - a client computing the ceiling for
display should mirror that resolution rather than multiplying the
raw request value.
@@ -196,7 +196,7 @@ typedef struct _meshtastic_LockdownAuth {
Uses millis() (CPU uptime), not wall-clock time, so the cap is
immune to GPS spoofing, RTC backup-battery removal, and Faraday
- cage isolation — none of those move the uptime counter. The only
+ cage isolation - none of those move the uptime counter. The only
way to reset the session clock is a reboot, which costs a boot
from the on-flash, HMAC-bound counter. */
uint32_t max_session_seconds;
@@ -213,7 +213,7 @@ typedef struct _meshtastic_LockdownAuth {
NOT reversed by this operation: APPROTECT. Once the debug port
lockout has been burned (on silicon where it is effective) it is
- permanent — disabling lockdown decrypts your data and removes the
+ permanent - disabling lockdown decrypts your data and removes the
access gates, but the SWD/JTAG port stays locked for the life of
the device (recoverable only via a full chip erase over a debug
probe, which destroys all data). Clients should make this
@@ -502,6 +502,21 @@ typedef struct _meshtastic_AdminMessage {
uint32_t remove_ignored_node;
/* Set specified node-num to be muted */
uint32_t toggle_muted_node;
+ /* Request a single frame of the device's display framebuffer.
+ The frame is delivered to the local client as FromRadio.display_frame
+ chunks (see DisplayFrame in mesh.proto) - there is no AdminMessage
+ response. Local connection only: a node receiving this over the mesh,
+ or a build without a display, ignores it. During active mirroring it
+ forces one frame on the next redraw even if the screen is unchanged. */
+ bool get_display_frame_request;
+ /* Enable (true) or disable (false) continuous mirroring of the device
+ display - unlike most bool verbs in this oneof, false is meaningful.
+ While enabled, the device sends a DisplayFrame after each screen
+ redraw that changed the framebuffer, as FromRadio.display_frame
+ chunks; the first frame arrives immediately and acts as the
+ acknowledgement. Local connection only (see get_display_frame_request)
+ and not persisted across reboot. */
+ bool set_display_mirror;
/* Begins an edit transaction for config, module config, owner, and channel settings changes
This will delay the standard *implicit* save to the file system and subsequent reboot behavior until committed (commit_edit_settings) */
bool begin_edit_settings;
@@ -738,6 +753,8 @@ extern "C" {
#define meshtastic_AdminMessage_set_ignored_node_tag 47
#define meshtastic_AdminMessage_remove_ignored_node_tag 48
#define meshtastic_AdminMessage_toggle_muted_node_tag 49
+#define meshtastic_AdminMessage_get_display_frame_request_tag 50
+#define meshtastic_AdminMessage_set_display_mirror_tag 51
#define meshtastic_AdminMessage_begin_edit_settings_tag 64
#define meshtastic_AdminMessage_commit_edit_settings_tag 65
#define meshtastic_AdminMessage_add_contact_tag 66
@@ -800,6 +817,8 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,store_ui_config,store_ui_con
X(a, STATIC, ONEOF, UINT32, (payload_variant,set_ignored_node,set_ignored_node), 47) \
X(a, STATIC, ONEOF, UINT32, (payload_variant,remove_ignored_node,remove_ignored_node), 48) \
X(a, STATIC, ONEOF, UINT32, (payload_variant,toggle_muted_node,toggle_muted_node), 49) \
+X(a, STATIC, ONEOF, BOOL, (payload_variant,get_display_frame_request,get_display_frame_request), 50) \
+X(a, STATIC, ONEOF, BOOL, (payload_variant,set_display_mirror,set_display_mirror), 51) \
X(a, STATIC, ONEOF, BOOL, (payload_variant,begin_edit_settings,begin_edit_settings), 64) \
X(a, STATIC, ONEOF, BOOL, (payload_variant,commit_edit_settings,commit_edit_settings), 65) \
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,add_contact,add_contact), 66) \
diff --git a/src/mesh/generated/meshtastic/mesh.pb.cpp b/src/mesh/generated/meshtastic/mesh.pb.cpp
index 4cf8e980cf2..e632dba9a77 100644
--- a/src/mesh/generated/meshtastic/mesh.pb.cpp
+++ b/src/mesh/generated/meshtastic/mesh.pb.cpp
@@ -60,6 +60,15 @@ PB_BIND(meshtastic_QueueStatus, meshtastic_QueueStatus, AUTO)
PB_BIND(meshtastic_FromRadio, meshtastic_FromRadio, 2)
+PB_BIND(meshtastic_DisplayFrame, meshtastic_DisplayFrame, 2)
+
+
+PB_BIND(meshtastic_DisplayPalette, meshtastic_DisplayPalette, 2)
+
+
+PB_BIND(meshtastic_DisplayPalette_ColorRegion, meshtastic_DisplayPalette_ColorRegion, AUTO)
+
+
PB_BIND(meshtastic_LockdownStatus, meshtastic_LockdownStatus, AUTO)
@@ -99,6 +108,9 @@ PB_BIND(meshtastic_Neighbor, meshtastic_Neighbor, AUTO)
PB_BIND(meshtastic_DeviceMetadata, meshtastic_DeviceMetadata, AUTO)
+PB_BIND(meshtastic_DisplayInfo, meshtastic_DisplayInfo, AUTO)
+
+
PB_BIND(meshtastic_LoRaPresetGroup, meshtastic_LoRaPresetGroup, AUTO)
@@ -149,6 +161,10 @@ PB_BIND(meshtastic_ChunkedPayloadResponse, meshtastic_ChunkedPayloadResponse, AU
+
+
+
+
diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h
index 375ff4861b6..5a751710143 100644
--- a/src/mesh/generated/meshtastic/mesh.pb.h
+++ b/src/mesh/generated/meshtastic/mesh.pb.h
@@ -670,6 +670,19 @@ typedef enum _meshtastic_LogRecord_Level {
meshtastic_LogRecord_Level_TRACE = 5
} meshtastic_LogRecord_Level;
+/* Pixel encodings of the framebuffer bytes. */
+typedef enum _meshtastic_DisplayFrame_Format {
+ /* Default; should not be sent. */
+ meshtastic_DisplayFrame_Format_FORMAT_UNSPECIFIED = 0,
+ /* 1 bit per pixel, vertical LSB-first pages (SSD1306/OLEDDisplay layout):
+ byte index = x + (y / 8) * width, bit index = y % 8. */
+ meshtastic_DisplayFrame_Format_MONO_VLSB = 1,
+ /* 16 bits per pixel, RGB565 in little-endian byte order, rows tightly
+ packed. Used with the partial-update rect fields: data covers only
+ the rectangle. Streamed by LVGL-based color UIs. */
+ meshtastic_DisplayFrame_Format_RGB565 = 2
+} meshtastic_DisplayFrame_Format;
+
typedef enum _meshtastic_LockdownStatus_State {
/* Default; should not be sent. */
meshtastic_LockdownStatus_State_STATE_UNSPECIFIED = 0,
@@ -696,6 +709,24 @@ typedef enum _meshtastic_LockdownStatus_State {
meshtastic_LockdownStatus_State_DISABLED = 5
} meshtastic_LockdownStatus_State;
+/* Physical panel technology, as a hint for client rendering and refresh
+ expectations. */
+typedef enum _meshtastic_DisplayInfo_PanelClass {
+ /* Default; should not be sent. */
+ meshtastic_DisplayInfo_PanelClass_PANEL_CLASS_UNSPECIFIED = 0,
+ /* Monochrome OLED (SSD1306/SH1106 family). */
+ meshtastic_DisplayInfo_PanelClass_OLED = 1,
+ /* Monochrome LCD (ST7567 family). */
+ meshtastic_DisplayInfo_PanelClass_LCD = 2,
+ /* Color TFT. */
+ meshtastic_DisplayInfo_PanelClass_TFT = 3,
+ /* E-ink panel: refresh is slow and full-screen; clients should prefer
+ one-shot frame requests over continuous mirroring. */
+ meshtastic_DisplayInfo_PanelClass_EINK = 4,
+ /* LED matrix (HUB75). */
+ meshtastic_DisplayInfo_PanelClass_HUB75 = 5
+} meshtastic_DisplayInfo_PanelClass;
+
/* Struct definitions */
/* A GPS Position */
typedef struct _meshtastic_Position {
@@ -1269,6 +1300,98 @@ typedef struct _meshtastic_QueueStatus {
uint32_t mesh_packet_id;
} meshtastic_QueueStatus;
+typedef PB_BYTES_ARRAY_T(384) meshtastic_DisplayFrame_data_t;
+/* A chunk of the device's display framebuffer, streamed to the local client
+ over BLE/serial/TCP. Frames larger than one chunk are split by byte offset;
+ a chunk with offset + data length == total_size completes the frame.
+ Chunks of one frame arrive contiguously (no other display_frame between
+ them; display_palette messages may interleave) and in offset order
+ (FromRadio is a reliable ordered stream), so clients may reassemble into
+ a single buffer without reordering. A frame whose streaming has begun is always drained to
+ completion, even if mirroring is disabled mid-frame. */
+typedef struct _meshtastic_DisplayFrame {
+ /* Display width in pixels. */
+ uint16_t width;
+ /* Display height in pixels. */
+ uint16_t height;
+ /* Pixel encoding of data. */
+ meshtastic_DisplayFrame_Format format;
+ /* Frame counter, constant across the chunks of one frame so the client
+ can detect interleaving or loss. Increments per captured frame, wraps
+ at uint32 range, and restarts from 1 on device reboot - treat any
+ change as "a new frame", not as an ordering guarantee. */
+ uint32_t frame_id;
+ /* Byte offset of this chunk within the full frame buffer. */
+ uint32_t offset;
+ /* Total size in bytes of the full frame buffer. */
+ uint32_t total_size;
+ /* The framebuffer bytes for this chunk. */
+ meshtastic_DisplayFrame_data_t data;
+ /* Optional partial-update rectangle (dirty-rect streaming from LVGL-based
+ color UIs, format RGB565). When rect_width > 0, data carries only the
+ rectangle's pixels and offset/total_size describe the rectangle's own
+ buffer; width/height above always remain the full display dimensions,
+ and each rectangle is an independently completed unit under its own
+ frame_id. MONO_VLSB frames never set these fields. */
+ uint16_t rect_x;
+ /* See rect_x. */
+ uint16_t rect_y;
+ /* See rect_x. */
+ uint16_t rect_width;
+ /* See rect_x. */
+ uint16_t rect_height;
+ /* Identity of the DisplayPalette that colorizes this frame, matching
+ DisplayPalette.signature. 0 when the device renders monochrome (no
+ color panel); clients without the referenced palette yet should render
+ monochrome until its chunks arrive. */
+ uint32_t palette_signature;
+} meshtastic_DisplayFrame;
+
+/* One colorized rectangle of the display. */
+typedef struct _meshtastic_DisplayPalette_ColorRegion {
+ /* Region origin and size in pixels. */
+ uint16_t x;
+ /* See x. */
+ uint16_t y;
+ /* See x. */
+ uint16_t width;
+ /* See x. */
+ uint16_t height;
+ /* RGB565 drawn for set (1) pixels inside this region. */
+ uint16_t on_color;
+ /* RGB565 drawn for clear (0) pixels inside this region. */
+ uint16_t off_color;
+} meshtastic_DisplayPalette_ColorRegion;
+
+/* Colorization palette for DisplayFrame streams from devices that paint the
+ 1bpp base UI onto a color panel. The panel applies per-region on/off
+ colors at flush time; streaming the same region table lets a client
+ render the mirror in the panel's true colors at 1bpp bandwidth.
+ Sent as FromRadio.display_palette, split by region index when the table
+ exceeds one message; re-sent only when the region layout or theme changes
+ (the signature changes with it). Regions are ordered by table index; a
+ region with a higher table index overrides lower-indexed ones where they
+ overlap, regardless of chunk boundaries. A client holding partial chunks
+ of a signature that no longer matches incoming chunks should discard
+ them. All colors are RGB565 in logical bit layout (RRRRRGGGGGGBBBBB). */
+typedef struct _meshtastic_DisplayPalette {
+ /* Identity of this palette; DisplayFrame.palette_signature references it.
+ Changes whenever the region table or theme changes. */
+ uint32_t signature;
+ /* RGB565 for set pixels outside all regions. */
+ uint32_t default_on_color;
+ /* RGB565 for clear pixels outside all regions. */
+ uint32_t default_off_color;
+ /* Table index of the first region in this chunk. */
+ uint8_t region_offset;
+ /* Total regions in the complete palette; region_offset + regions length
+ == region_total completes it. */
+ uint8_t region_total;
+ /* The regions of this chunk, in table order. */
+ pb_size_t regions_count;
+ meshtastic_DisplayPalette_ColorRegion regions[16];
+} meshtastic_DisplayPalette;
+
/* Lockdown state report from firmware to client (for hardened builds
with MESHTASTIC_LOCKDOWN). Sent immediately after config_complete_id
to inform a freshly-connected unauthorized client what it must do,
@@ -1395,6 +1518,22 @@ typedef struct _meshtastic_NeighborInfo {
meshtastic_Neighbor neighbors[10];
} meshtastic_NeighborInfo;
+/* Static description of a device's display, sent inside DeviceMetadata
+ during the connection handshake. */
+typedef struct _meshtastic_DisplayInfo {
+ /* Display width in pixels. */
+ uint16_t width;
+ /* Display height in pixels. */
+ uint16_t height;
+ /* Pixel encoding that DisplayFrame streams from this device will use. */
+ meshtastic_DisplayFrame_Format format;
+ /* Physical panel technology. */
+ meshtastic_DisplayInfo_PanelClass panel_class;
+ /* True when the panel accepts touch input; clients may map taps on a
+ mirrored frame to AdminMessage.send_input_event touch coordinates. */
+ bool has_touch;
+} meshtastic_DisplayInfo;
+
/* Device metadata response */
typedef struct _meshtastic_DeviceMetadata {
/* Device firmware version string */
@@ -1425,6 +1564,12 @@ typedef struct _meshtastic_DeviceMetadata {
/* Indicates whether this firmware build includes XEdDSA packet signature verification.
This is a read-only capability and must be false when XEdDSA is not compiled in. */
bool has_xeddsa;
+ /* Describes the device's screen when one is present; absent on display-less
+ builds. Lets clients gate display-mirroring UI (see DisplayFrame) and
+ adapt to the panel - e.g. expect slow refresh from EINK, or offer
+ tap-to-touch when has_touch is set. */
+ bool has_display;
+ meshtastic_DisplayInfo display;
} meshtastic_DeviceMetadata;
/* A distinct set of legal modem presets shared by one or more LoRa regions.
@@ -1536,6 +1681,13 @@ typedef struct _meshtastic_FromRadio {
illegal region+preset combination. A region that does not appear in
any group carries no constraint info and should not be restricted. */
meshtastic_LoRaRegionPresetMap region_presets;
+ /* One chunk of the device's display framebuffer, sent while display
+ mirroring is active (see AdminMessage.set_display_mirror and
+ AdminMessage.get_display_frame_request). */
+ meshtastic_DisplayFrame display_frame;
+ /* One chunk of the color palette referenced by display_frame's
+ palette_signature (see DisplayPalette). */
+ meshtastic_DisplayPalette display_palette;
};
} meshtastic_FromRadio;
@@ -1677,10 +1829,18 @@ extern "C" {
#define _meshtastic_LogRecord_Level_MAX meshtastic_LogRecord_Level_CRITICAL
#define _meshtastic_LogRecord_Level_ARRAYSIZE ((meshtastic_LogRecord_Level)(meshtastic_LogRecord_Level_CRITICAL+1))
+#define _meshtastic_DisplayFrame_Format_MIN meshtastic_DisplayFrame_Format_FORMAT_UNSPECIFIED
+#define _meshtastic_DisplayFrame_Format_MAX meshtastic_DisplayFrame_Format_RGB565
+#define _meshtastic_DisplayFrame_Format_ARRAYSIZE ((meshtastic_DisplayFrame_Format)(meshtastic_DisplayFrame_Format_RGB565+1))
+
#define _meshtastic_LockdownStatus_State_MIN meshtastic_LockdownStatus_State_STATE_UNSPECIFIED
#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_DISABLED
#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_DISABLED+1))
+#define _meshtastic_DisplayInfo_PanelClass_MIN meshtastic_DisplayInfo_PanelClass_PANEL_CLASS_UNSPECIFIED
+#define _meshtastic_DisplayInfo_PanelClass_MAX meshtastic_DisplayInfo_PanelClass_HUB75
+#define _meshtastic_DisplayInfo_PanelClass_ARRAYSIZE ((meshtastic_DisplayInfo_PanelClass)(meshtastic_DisplayInfo_PanelClass_HUB75+1))
+
#define meshtastic_Position_location_source_ENUMTYPE meshtastic_Position_LocSource
#define meshtastic_Position_altitude_source_ENUMTYPE meshtastic_Position_AltSource
@@ -1712,6 +1872,10 @@ extern "C" {
+#define meshtastic_DisplayFrame_format_ENUMTYPE meshtastic_DisplayFrame_Format
+
+
+
#define meshtastic_LockdownStatus_state_ENUMTYPE meshtastic_LockdownStatus_State
#define meshtastic_ClientNotification_level_ENUMTYPE meshtastic_LogRecord_Level
@@ -1730,6 +1894,9 @@ extern "C" {
#define meshtastic_DeviceMetadata_role_ENUMTYPE meshtastic_Config_DeviceConfig_Role
#define meshtastic_DeviceMetadata_hw_model_ENUMTYPE meshtastic_HardwareModel
+#define meshtastic_DisplayInfo_format_ENUMTYPE meshtastic_DisplayFrame_Format
+#define meshtastic_DisplayInfo_panel_class_ENUMTYPE meshtastic_DisplayInfo_PanelClass
+
#define meshtastic_LoRaPresetGroup_presets_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset
#define meshtastic_LoRaPresetGroup_default_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset
@@ -1761,6 +1928,9 @@ extern "C" {
#define meshtastic_LogRecord_init_default {"", 0, "", _meshtastic_LogRecord_Level_MIN}
#define meshtastic_QueueStatus_init_default {0, 0, 0, 0}
#define meshtastic_FromRadio_init_default {0, 0, {meshtastic_MeshPacket_init_default}}
+#define meshtastic_DisplayFrame_init_default {0, 0, _meshtastic_DisplayFrame_Format_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0, 0, 0}
+#define meshtastic_DisplayPalette_init_default {0, 0, 0, 0, 0, 0, {meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default, meshtastic_DisplayPalette_ColorRegion_init_default}}
+#define meshtastic_DisplayPalette_ColorRegion_init_default {0, 0, 0, 0, 0, 0}
#define meshtastic_LockdownStatus_init_default {_meshtastic_LockdownStatus_State_MIN, "", 0, 0, 0}
#define meshtastic_ClientNotification_init_default {false, 0, 0, _meshtastic_LogRecord_Level_MIN, "", 0, {meshtastic_KeyVerificationNumberInform_init_default}}
#define meshtastic_KeyVerificationNumberInform_init_default {0, "", 0}
@@ -1773,7 +1943,8 @@ extern "C" {
#define meshtastic_Compressed_init_default {_meshtastic_PortNum_MIN, {0, {0}}}
#define meshtastic_NeighborInfo_init_default {0, 0, 0, 0, {meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default}}
#define meshtastic_Neighbor_init_default {0, 0, 0, 0}
-#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0}
+#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0, false, meshtastic_DisplayInfo_init_default}
+#define meshtastic_DisplayInfo_init_default {0, 0, _meshtastic_DisplayFrame_Format_MIN, _meshtastic_DisplayInfo_PanelClass_MIN, 0}
#define meshtastic_LoRaPresetGroup_init_default {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0}
#define meshtastic_LoRaRegionPresets_init_default {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0}
#define meshtastic_LoRaRegionPresetMap_init_default {0, {meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default}, 0, {meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default}}
@@ -1800,6 +1971,9 @@ extern "C" {
#define meshtastic_LogRecord_init_zero {"", 0, "", _meshtastic_LogRecord_Level_MIN}
#define meshtastic_QueueStatus_init_zero {0, 0, 0, 0}
#define meshtastic_FromRadio_init_zero {0, 0, {meshtastic_MeshPacket_init_zero}}
+#define meshtastic_DisplayFrame_init_zero {0, 0, _meshtastic_DisplayFrame_Format_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0, 0, 0}
+#define meshtastic_DisplayPalette_init_zero {0, 0, 0, 0, 0, 0, {meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero, meshtastic_DisplayPalette_ColorRegion_init_zero}}
+#define meshtastic_DisplayPalette_ColorRegion_init_zero {0, 0, 0, 0, 0, 0}
#define meshtastic_LockdownStatus_init_zero {_meshtastic_LockdownStatus_State_MIN, "", 0, 0, 0}
#define meshtastic_ClientNotification_init_zero {false, 0, 0, _meshtastic_LogRecord_Level_MIN, "", 0, {meshtastic_KeyVerificationNumberInform_init_zero}}
#define meshtastic_KeyVerificationNumberInform_init_zero {0, "", 0}
@@ -1812,7 +1986,8 @@ extern "C" {
#define meshtastic_Compressed_init_zero {_meshtastic_PortNum_MIN, {0, {0}}}
#define meshtastic_NeighborInfo_init_zero {0, 0, 0, 0, {meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero}}
#define meshtastic_Neighbor_init_zero {0, 0, 0, 0}
-#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0}
+#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0, false, meshtastic_DisplayInfo_init_zero}
+#define meshtastic_DisplayInfo_init_zero {0, 0, _meshtastic_DisplayFrame_Format_MIN, _meshtastic_DisplayInfo_PanelClass_MIN, 0}
#define meshtastic_LoRaPresetGroup_init_zero {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0}
#define meshtastic_LoRaRegionPresets_init_zero {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0}
#define meshtastic_LoRaRegionPresetMap_init_zero {0, {meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero}, 0, {meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero}}
@@ -1968,6 +2143,30 @@ extern "C" {
#define meshtastic_QueueStatus_free_tag 2
#define meshtastic_QueueStatus_maxlen_tag 3
#define meshtastic_QueueStatus_mesh_packet_id_tag 4
+#define meshtastic_DisplayFrame_width_tag 1
+#define meshtastic_DisplayFrame_height_tag 2
+#define meshtastic_DisplayFrame_format_tag 3
+#define meshtastic_DisplayFrame_frame_id_tag 4
+#define meshtastic_DisplayFrame_offset_tag 5
+#define meshtastic_DisplayFrame_total_size_tag 6
+#define meshtastic_DisplayFrame_data_tag 7
+#define meshtastic_DisplayFrame_rect_x_tag 8
+#define meshtastic_DisplayFrame_rect_y_tag 9
+#define meshtastic_DisplayFrame_rect_width_tag 10
+#define meshtastic_DisplayFrame_rect_height_tag 11
+#define meshtastic_DisplayFrame_palette_signature_tag 12
+#define meshtastic_DisplayPalette_ColorRegion_x_tag 1
+#define meshtastic_DisplayPalette_ColorRegion_y_tag 2
+#define meshtastic_DisplayPalette_ColorRegion_width_tag 3
+#define meshtastic_DisplayPalette_ColorRegion_height_tag 4
+#define meshtastic_DisplayPalette_ColorRegion_on_color_tag 5
+#define meshtastic_DisplayPalette_ColorRegion_off_color_tag 6
+#define meshtastic_DisplayPalette_signature_tag 1
+#define meshtastic_DisplayPalette_default_on_color_tag 2
+#define meshtastic_DisplayPalette_default_off_color_tag 3
+#define meshtastic_DisplayPalette_region_offset_tag 4
+#define meshtastic_DisplayPalette_region_total_tag 5
+#define meshtastic_DisplayPalette_regions_tag 6
#define meshtastic_LockdownStatus_state_tag 1
#define meshtastic_LockdownStatus_lock_reason_tag 2
#define meshtastic_LockdownStatus_boots_remaining_tag 3
@@ -2003,6 +2202,11 @@ extern "C" {
#define meshtastic_NeighborInfo_last_sent_by_id_tag 2
#define meshtastic_NeighborInfo_node_broadcast_interval_secs_tag 3
#define meshtastic_NeighborInfo_neighbors_tag 4
+#define meshtastic_DisplayInfo_width_tag 1
+#define meshtastic_DisplayInfo_height_tag 2
+#define meshtastic_DisplayInfo_format_tag 3
+#define meshtastic_DisplayInfo_panel_class_tag 4
+#define meshtastic_DisplayInfo_has_touch_tag 5
#define meshtastic_DeviceMetadata_firmware_version_tag 1
#define meshtastic_DeviceMetadata_device_state_version_tag 2
#define meshtastic_DeviceMetadata_canShutdown_tag 3
@@ -2016,6 +2220,7 @@ extern "C" {
#define meshtastic_DeviceMetadata_hasPKC_tag 11
#define meshtastic_DeviceMetadata_excluded_modules_tag 12
#define meshtastic_DeviceMetadata_has_xeddsa_tag 14
+#define meshtastic_DeviceMetadata_display_tag 15
#define meshtastic_LoRaPresetGroup_presets_tag 1
#define meshtastic_LoRaPresetGroup_default_preset_tag 2
#define meshtastic_LoRaPresetGroup_licensed_only_tag 3
@@ -2042,6 +2247,8 @@ extern "C" {
#define meshtastic_FromRadio_deviceuiConfig_tag 17
#define meshtastic_FromRadio_lockdown_status_tag 18
#define meshtastic_FromRadio_region_presets_tag 19
+#define meshtastic_FromRadio_display_frame_tag 20
+#define meshtastic_FromRadio_display_palette_tag 21
#define meshtastic_Heartbeat_nonce_tag 1
#define meshtastic_ToRadio_packet_tag 1
#define meshtastic_ToRadio_want_config_id_tag 3
@@ -2301,7 +2508,9 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,fileInfo,fileInfo), 15) \
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,clientNotification,clientNotification), 16) \
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfig), 17) \
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_status), 18) \
-X(a, STATIC, ONEOF, MESSAGE, (payload_variant,region_presets,region_presets), 19)
+X(a, STATIC, ONEOF, MESSAGE, (payload_variant,region_presets,region_presets), 19) \
+X(a, STATIC, ONEOF, MESSAGE, (payload_variant,display_frame,display_frame), 20) \
+X(a, STATIC, ONEOF, MESSAGE, (payload_variant,display_palette,display_palette), 21)
#define meshtastic_FromRadio_CALLBACK NULL
#define meshtastic_FromRadio_DEFAULT NULL
#define meshtastic_FromRadio_payload_variant_packet_MSGTYPE meshtastic_MeshPacket
@@ -2320,6 +2529,45 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,region_presets,region_preset
#define meshtastic_FromRadio_payload_variant_deviceuiConfig_MSGTYPE meshtastic_DeviceUIConfig
#define meshtastic_FromRadio_payload_variant_lockdown_status_MSGTYPE meshtastic_LockdownStatus
#define meshtastic_FromRadio_payload_variant_region_presets_MSGTYPE meshtastic_LoRaRegionPresetMap
+#define meshtastic_FromRadio_payload_variant_display_frame_MSGTYPE meshtastic_DisplayFrame
+#define meshtastic_FromRadio_payload_variant_display_palette_MSGTYPE meshtastic_DisplayPalette
+
+#define meshtastic_DisplayFrame_FIELDLIST(X, a) \
+X(a, STATIC, SINGULAR, UINT32, width, 1) \
+X(a, STATIC, SINGULAR, UINT32, height, 2) \
+X(a, STATIC, SINGULAR, UENUM, format, 3) \
+X(a, STATIC, SINGULAR, UINT32, frame_id, 4) \
+X(a, STATIC, SINGULAR, UINT32, offset, 5) \
+X(a, STATIC, SINGULAR, UINT32, total_size, 6) \
+X(a, STATIC, SINGULAR, BYTES, data, 7) \
+X(a, STATIC, SINGULAR, UINT32, rect_x, 8) \
+X(a, STATIC, SINGULAR, UINT32, rect_y, 9) \
+X(a, STATIC, SINGULAR, UINT32, rect_width, 10) \
+X(a, STATIC, SINGULAR, UINT32, rect_height, 11) \
+X(a, STATIC, SINGULAR, UINT32, palette_signature, 12)
+#define meshtastic_DisplayFrame_CALLBACK NULL
+#define meshtastic_DisplayFrame_DEFAULT NULL
+
+#define meshtastic_DisplayPalette_FIELDLIST(X, a) \
+X(a, STATIC, SINGULAR, UINT32, signature, 1) \
+X(a, STATIC, SINGULAR, UINT32, default_on_color, 2) \
+X(a, STATIC, SINGULAR, UINT32, default_off_color, 3) \
+X(a, STATIC, SINGULAR, UINT32, region_offset, 4) \
+X(a, STATIC, SINGULAR, UINT32, region_total, 5) \
+X(a, STATIC, REPEATED, MESSAGE, regions, 6)
+#define meshtastic_DisplayPalette_CALLBACK NULL
+#define meshtastic_DisplayPalette_DEFAULT NULL
+#define meshtastic_DisplayPalette_regions_MSGTYPE meshtastic_DisplayPalette_ColorRegion
+
+#define meshtastic_DisplayPalette_ColorRegion_FIELDLIST(X, a) \
+X(a, STATIC, SINGULAR, UINT32, x, 1) \
+X(a, STATIC, SINGULAR, UINT32, y, 2) \
+X(a, STATIC, SINGULAR, UINT32, width, 3) \
+X(a, STATIC, SINGULAR, UINT32, height, 4) \
+X(a, STATIC, SINGULAR, UINT32, on_color, 5) \
+X(a, STATIC, SINGULAR, UINT32, off_color, 6)
+#define meshtastic_DisplayPalette_ColorRegion_CALLBACK NULL
+#define meshtastic_DisplayPalette_ColorRegion_DEFAULT NULL
#define meshtastic_LockdownStatus_FIELDLIST(X, a) \
X(a, STATIC, SINGULAR, UENUM, state, 1) \
@@ -2435,9 +2683,20 @@ X(a, STATIC, SINGULAR, UENUM, hw_model, 9) \
X(a, STATIC, SINGULAR, BOOL, hasRemoteHardware, 10) \
X(a, STATIC, SINGULAR, BOOL, hasPKC, 11) \
X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12) \
-X(a, STATIC, SINGULAR, BOOL, has_xeddsa, 14)
+X(a, STATIC, SINGULAR, BOOL, has_xeddsa, 14) \
+X(a, STATIC, OPTIONAL, MESSAGE, display, 15)
#define meshtastic_DeviceMetadata_CALLBACK NULL
#define meshtastic_DeviceMetadata_DEFAULT NULL
+#define meshtastic_DeviceMetadata_display_MSGTYPE meshtastic_DisplayInfo
+
+#define meshtastic_DisplayInfo_FIELDLIST(X, a) \
+X(a, STATIC, SINGULAR, UINT32, width, 1) \
+X(a, STATIC, SINGULAR, UINT32, height, 2) \
+X(a, STATIC, SINGULAR, UENUM, format, 3) \
+X(a, STATIC, SINGULAR, UENUM, panel_class, 4) \
+X(a, STATIC, SINGULAR, BOOL, has_touch, 5)
+#define meshtastic_DisplayInfo_CALLBACK NULL
+#define meshtastic_DisplayInfo_DEFAULT NULL
#define meshtastic_LoRaPresetGroup_FIELDLIST(X, a) \
X(a, STATIC, REPEATED, UENUM, presets, 1) \
@@ -2512,6 +2771,9 @@ extern const pb_msgdesc_t meshtastic_MyNodeInfo_msg;
extern const pb_msgdesc_t meshtastic_LogRecord_msg;
extern const pb_msgdesc_t meshtastic_QueueStatus_msg;
extern const pb_msgdesc_t meshtastic_FromRadio_msg;
+extern const pb_msgdesc_t meshtastic_DisplayFrame_msg;
+extern const pb_msgdesc_t meshtastic_DisplayPalette_msg;
+extern const pb_msgdesc_t meshtastic_DisplayPalette_ColorRegion_msg;
extern const pb_msgdesc_t meshtastic_LockdownStatus_msg;
extern const pb_msgdesc_t meshtastic_ClientNotification_msg;
extern const pb_msgdesc_t meshtastic_KeyVerificationNumberInform_msg;
@@ -2525,6 +2787,7 @@ extern const pb_msgdesc_t meshtastic_Compressed_msg;
extern const pb_msgdesc_t meshtastic_NeighborInfo_msg;
extern const pb_msgdesc_t meshtastic_Neighbor_msg;
extern const pb_msgdesc_t meshtastic_DeviceMetadata_msg;
+extern const pb_msgdesc_t meshtastic_DisplayInfo_msg;
extern const pb_msgdesc_t meshtastic_LoRaPresetGroup_msg;
extern const pb_msgdesc_t meshtastic_LoRaRegionPresets_msg;
extern const pb_msgdesc_t meshtastic_LoRaRegionPresetMap_msg;
@@ -2553,6 +2816,9 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg;
#define meshtastic_LogRecord_fields &meshtastic_LogRecord_msg
#define meshtastic_QueueStatus_fields &meshtastic_QueueStatus_msg
#define meshtastic_FromRadio_fields &meshtastic_FromRadio_msg
+#define meshtastic_DisplayFrame_fields &meshtastic_DisplayFrame_msg
+#define meshtastic_DisplayPalette_fields &meshtastic_DisplayPalette_msg
+#define meshtastic_DisplayPalette_ColorRegion_fields &meshtastic_DisplayPalette_ColorRegion_msg
#define meshtastic_LockdownStatus_fields &meshtastic_LockdownStatus_msg
#define meshtastic_ClientNotification_fields &meshtastic_ClientNotification_msg
#define meshtastic_KeyVerificationNumberInform_fields &meshtastic_KeyVerificationNumberInform_msg
@@ -2566,6 +2832,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg;
#define meshtastic_NeighborInfo_fields &meshtastic_NeighborInfo_msg
#define meshtastic_Neighbor_fields &meshtastic_Neighbor_msg
#define meshtastic_DeviceMetadata_fields &meshtastic_DeviceMetadata_msg
+#define meshtastic_DisplayInfo_fields &meshtastic_DisplayInfo_msg
#define meshtastic_LoRaPresetGroup_fields &meshtastic_LoRaPresetGroup_msg
#define meshtastic_LoRaRegionPresets_fields &meshtastic_LoRaRegionPresets_msg
#define meshtastic_LoRaRegionPresetMap_fields &meshtastic_LoRaRegionPresetMap_msg
@@ -2584,7 +2851,11 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg;
#define meshtastic_ClientNotification_size 482
#define meshtastic_Compressed_size 239
#define meshtastic_Data_size 335
-#define meshtastic_DeviceMetadata_size 56
+#define meshtastic_DeviceMetadata_size 72
+#define meshtastic_DisplayFrame_size 437
+#define meshtastic_DisplayInfo_size 14
+#define meshtastic_DisplayPalette_ColorRegion_size 24
+#define meshtastic_DisplayPalette_size 440
#define meshtastic_DuplicatedPublicKey_size 0
#define meshtastic_FileInfo_size 236
#define meshtastic_FromRadio_size 510
diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp
index 07d55b74838..7a268281729 100644
--- a/src/modules/AdminModule.cpp
+++ b/src/modules/AdminModule.cpp
@@ -9,6 +9,7 @@
#include "PowerFSM.h"
#include "SPILock.h"
#include "gps/RTC.h"
+#include "graphics/ScreenMirror.h"
#include "input/InputBroker.h"
#include "meshUtils.h"
#include
@@ -679,6 +680,25 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
handleSendInputEvent(r->send_input_event);
break;
}
+#if HAS_SCREEN_MIRROR
+ // Both verbs are documented local-connection-only: frames ride FromRadio,
+ // which never crosses the mesh, so honoring a remote arm request would
+ // stream the screen to whatever local client happens to be attached.
+ case meshtastic_AdminMessage_get_display_frame_request_tag: {
+ if (mp.from != 0)
+ break;
+ LOG_INFO("Client requests display frame");
+ graphics::screenMirror.requestFrame();
+ break;
+ }
+ case meshtastic_AdminMessage_set_display_mirror_tag: {
+ if (mp.from != 0)
+ break;
+ LOG_INFO("Client sets display mirror: %d", r->set_display_mirror);
+ graphics::screenMirror.setMirror(r->set_display_mirror);
+ break;
+ }
+#endif
#ifdef ARCH_PORTDUINO
case meshtastic_AdminMessage_exit_simulator_tag:
LOG_INFO("Exiting simulator");
@@ -2152,7 +2172,8 @@ bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r)
r->which_payload_variant == meshtastic_AdminMessage_get_ringtone_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_device_connection_status_request_tag ||
r->which_payload_variant == meshtastic_AdminMessage_get_node_remote_hardware_pins_request_tag ||
- r->which_payload_variant == meshtastic_AdminMessage_get_ui_config_request_tag)
+ r->which_payload_variant == meshtastic_AdminMessage_get_ui_config_request_tag ||
+ r->which_payload_variant == meshtastic_AdminMessage_get_display_frame_request_tag)
return true;
else
return false;
@@ -2182,6 +2203,14 @@ void AdminModule::handleSendInputEvent(const meshtastic_AdminMessage_InputEvent
// Wake the device if asleep
powerFSM.trigger(EVENT_INPUT);
+
+#if HAS_MUI_MIRROR
+ // MUI builds never construct an InputBroker (Modules.cpp skips it when
+ // displaymode is COLOR), so remote input reaches the LVGL UI directly.
+ if (graphics::muiInjectInputEvent(inputEvent.event_code, inputEvent.kb_char, inputEvent.touch_x, inputEvent.touch_y))
+ return;
+#endif
+
#if !defined(MESHTASTIC_EXCLUDE_INPUTBROKER)
// Inject the event through InputBroker
if (inputBroker) {