diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index 0350405e08..8159c3fc0c 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 "UptimeClock.h"
#include "configuration.h"
@@ -182,6 +183,15 @@ static void drawLockdownLockScreen(OLEDDisplay *display)
}
#endif
+// Give ScreenMirror a look at the committed framebuffer (no-op unless armed).
+static inline void screenMirrorCapture()
+{
+#if HAS_SCREEN && !defined(MESHTASTIC_EXCLUDE_SCREEN_MIRROR)
+ if (screen)
+ screenMirror.onRendered(screen->getDisplayDevice());
+#endif
+}
+
static inline void updateUiFrame(OLEDDisplayUi *ui)
{
#ifdef MESHTASTIC_LOCKDOWN
@@ -209,6 +219,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
@@ -216,6 +228,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 0000000000..af5e420be9
--- /dev/null
+++ b/src/graphics/ScreenMirror.cpp
@@ -0,0 +1,112 @@
+#include "ScreenMirror.h"
+
+#if HAS_SCREEN && !defined(MESHTASTIC_EXCLUDE_SCREEN_MIRROR)
+
+#include "DebugConfiguration.h"
+#include "concurrency/LockGuard.h"
+#include
+#include
+
+namespace graphics
+{
+
+ScreenMirror screenMirror;
+
+void ScreenMirror::setMirror(bool enabled)
+{
+ concurrency::LockGuard g(&lock);
+ mirroring = enabled;
+ // Force an immediate frame on enable so the client doesn't wait for the
+ // next on-screen change.
+ if (enabled)
+ oneShot = true;
+ LOG_INFO("Screen mirror %s", enabled ? "enabled" : "disabled");
+}
+
+void ScreenMirror::requestFrame()
+{
+ concurrency::LockGuard g(&lock);
+ oneShot = true;
+}
+
+void ScreenMirror::onRendered(OLEDDisplay *display)
+{
+ uint32_t readyId = 0;
+ {
+ concurrency::LockGuard g(&lock);
+ if (!mirroring && !oneShot)
+ return;
+ if (!display || !display->buffer)
+ return;
+
+ // Don't overwrite a frame the client is still draining; the next
+ // render picks up the latest contents.
+ if (snapshot && sendOffset < frameSize)
+ return;
+
+ uint16_t w = display->getWidth();
+ uint16_t h = display->getHeight();
+ uint16_t size = w * ((h + 7) / 8);
+ if (size == 0)
+ return;
+
+ if (!snapshot) {
+ snapshot = (uint8_t *)malloc(size);
+ if (!snapshot) {
+ LOG_ERROR("Screen mirror: no memory for %u byte snapshot", size);
+ mirroring = oneShot = false;
+ return;
+ }
+ frameSize = size;
+ sendOffset = size; // drained; nothing captured yet
+ width = w;
+ height = h;
+ // Invalidate the change-detect baseline so the first frame always sends
+ memset(snapshot, 0xA5, size);
+ }
+
+ bool changed = memcmp(display->buffer, snapshot, frameSize) != 0;
+ if (!changed && !oneShot)
+ return;
+
+ memcpy(snapshot, display->buffer, frameSize);
+ sendOffset = 0;
+ frameId++;
+ oneShot = false;
+ readyId = frameId;
+ }
+ // Notify outside the lock: observers (PhoneAPI) may re-enter hasChunkForPhone.
+ frameReady.notifyObservers(readyId);
+}
+
+bool ScreenMirror::hasChunkForPhone()
+{
+ concurrency::LockGuard g(&lock);
+ return snapshot && sendOffset < frameSize;
+}
+
+bool ScreenMirror::getChunkForPhone(meshtastic_DisplayFrame &out)
+{
+ concurrency::LockGuard g(&lock);
+ if (!snapshot || sendOffset >= frameSize)
+ return false;
+
+ uint16_t len = frameSize - sendOffset;
+ if (len > sizeof(out.data.bytes))
+ len = sizeof(out.data.bytes);
+
+ out.width = width;
+ out.height = height;
+ out.format = meshtastic_DisplayFrame_Format_MONO_VLSB;
+ out.frame_id = frameId;
+ out.offset = sendOffset;
+ out.total_size = frameSize;
+ out.data.size = len;
+ memcpy(out.data.bytes, snapshot + sendOffset, len);
+ sendOffset += len;
+ return true;
+}
+
+} // namespace graphics
+
+#endif
diff --git a/src/graphics/ScreenMirror.h b/src/graphics/ScreenMirror.h
new file mode 100644
index 0000000000..1ced20f2b5
--- /dev/null
+++ b/src/graphics/ScreenMirror.h
@@ -0,0 +1,54 @@
+#pragma once
+#include "configuration.h"
+
+#if HAS_SCREEN && !defined(MESHTASTIC_EXCLUDE_SCREEN_MIRROR)
+
+#include "Observer.h"
+#include "concurrency/Lock.h"
+#include "mesh/generated/meshtastic/mesh.pb.h"
+
+class OLEDDisplay;
+
+namespace graphics
+{
+
+/**
+ * Streams 1bpp framebuffer snapshots to the local client as
+ * FromRadio.display_frame chunks. Armed via AdminMessage
+ * set_display_mirror (continuous) or get_display_frame_request (one-shot).
+ */
+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);
+
+ /// Fills the next pending chunk for the phone; false when drained.
+ bool getChunkForPhone(meshtastic_DisplayFrame &out);
+ bool hasChunkForPhone();
+
+ private:
+ concurrency::Lock lock;
+ bool mirroring = false;
+ bool oneShot = false;
+ // Last captured frame; doubles as the change-detection baseline.
+ uint8_t *snapshot = nullptr;
+ uint16_t frameSize = 0;
+ uint16_t sendOffset = 0; // == frameSize when drained
+ uint16_t width = 0;
+ uint16_t height = 0;
+ uint32_t frameId = 0;
+};
+
+extern ScreenMirror screenMirror;
+
+} // namespace graphics
+
+#endif
diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp
index c38147fe29..645a6d65c1 100644
--- a/src/mesh/PhoneAPI.cpp
+++ b/src/mesh/PhoneAPI.cpp
@@ -24,6 +24,7 @@
#include "TypeConversions.h"
#include "UptimeClock.h"
#include "concurrency/LockGuard.h"
+#include "graphics/ScreenMirror.h"
#include "main.h"
#include "modules/NodeInfoModule.h"
#include "xmodem.h"
@@ -274,6 +275,9 @@ void PhoneAPI::handleStartConfig()
#ifdef FSCom
observe(&xModem.packetReady);
#endif
+#if HAS_SCREEN && !defined(MESHTASTIC_EXCLUDE_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
@@ -377,6 +381,9 @@ void PhoneAPI::close()
unobserve(&service->fromNumChanged);
#ifdef FSCom
unobserve(&xModem.packetReady);
+#endif
+#if HAS_SCREEN && !defined(MESHTASTIC_EXCLUDE_SCREEN_MIRROR)
+ unobserve(&graphics::screenMirror.frameReady);
#endif
releasePhonePacket(); // Don't leak phone packets on shutdown
releaseQueueStatusPhonePacket();
@@ -1056,6 +1063,10 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
fromRadioScratch.xmodemPacket = xmodemPacketForPhone;
xmodemPacketForPhone = meshtastic_XModem_init_zero;
}
+#if HAS_SCREEN && !defined(MESHTASTIC_EXCLUDE_SCREEN_MIRROR)
+ } else if (graphics::screenMirror.getChunkForPhone(fromRadioScratch.display_frame)) {
+ fromRadioScratch.which_payload_variant = meshtastic_FromRadio_display_frame_tag;
+#endif
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
} else if (hasPendingLockdownStatus()) {
concurrency::LockGuard guard(&g_authSlotsMutex);
@@ -1726,6 +1737,11 @@ bool PhoneAPI::available()
}
#endif
+#if HAS_SCREEN && !defined(MESHTASTIC_EXCLUDE_SCREEN_MIRROR)
+ if (graphics::screenMirror.hasChunkForPhone())
+ return true;
+#endif
+
#ifdef ARCH_ESP32
#if !MESHTASTIC_EXCLUDE_STOREFORWARD
// Check if StoreForward has packets stored for us.
diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp
index 0e2ded0564..b9c58e2ae8 100644
--- a/src/modules/AdminModule.cpp
+++ b/src/modules/AdminModule.cpp
@@ -10,6 +10,7 @@
#include "SPILock.h"
#include "UptimeClock.h"
#include "gps/RTC.h"
+#include "graphics/ScreenMirror.h"
#include "input/InputBroker.h"
#include "meshUtils.h"
#include
@@ -695,6 +696,18 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
handleSendInputEvent(r->send_input_event);
break;
}
+#if HAS_SCREEN && !defined(MESHTASTIC_EXCLUDE_SCREEN_MIRROR)
+ case meshtastic_AdminMessage_get_display_frame_request_tag: {
+ LOG_INFO("Client requests display frame");
+ graphics::screenMirror.requestFrame();
+ break;
+ }
+ case meshtastic_AdminMessage_set_display_mirror_tag: {
+ 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");
@@ -2134,7 +2147,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;