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;