From 1ed930ec2db9bd0346464f8ba05b384175cade68 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:02:18 -0500 Subject: [PATCH] feat(screen): stream the TFT color-region palette for color-accurate mirroring ScreenMirror captures the color-region table with each snapshot when the frame signature changes (regions byte-swapped from panel order to logical RGB565, defaults = White / theme body background) and streams it as FromRadio.display_palette chunks ahead of frames, with per-client (signature, region) cursors like the frame drain. Frames reference the palette via palette_signature; monochrome-only builds send 0 and no palettes. Also classify USE_TFTDISPLAY/HAS_SPI_TFT panels (T-Deck) as TFT in DeviceMetadata.display. --- src/graphics/ScreenMirror.cpp | 92 +++++++++++++++++++++++++++++++++++ src/graphics/ScreenMirror.h | 18 +++++++ src/main.cpp | 27 ++++++++++ src/mesh/PhoneAPI.cpp | 9 +++- src/mesh/PhoneAPI.h | 7 ++- 5 files changed, 150 insertions(+), 3 deletions(-) diff --git a/src/graphics/ScreenMirror.cpp b/src/graphics/ScreenMirror.cpp index 4ab4e6cb52..1764e875d5 100644 --- a/src/graphics/ScreenMirror.cpp +++ b/src/graphics/ScreenMirror.cpp @@ -8,11 +8,26 @@ #include #include +#include "TFTColorRegions.h" // self-defines GRAPHICS_TFT_COLORING_ENABLED; API is safe when coloring is compiled out +#include "TFTPalette.h" + namespace graphics { ScreenMirror screenMirror; +#if GRAPHICS_TFT_COLORING_ENABLED +namespace +{ +// Region colors are stored 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 +#endif + void ScreenMirror::freeSnapshotLocked() { if (snapshot) { @@ -21,6 +36,13 @@ void ScreenMirror::freeSnapshotLocked() 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; } void ScreenMirror::setMirror(bool enabled) @@ -83,12 +105,81 @@ void ScreenMirror::onRendered(OLEDDisplay *display) memcpy(snapshot, display->buffer, frameSize); frameId++; oneShot = false; + capturePaletteLocked(); readyId = frameId; } // Notify outside the lock: observers (PhoneAPI) may re-enter hasChunkFor. frameReady.notifyObservers(readyId); } +void ScreenMirror::capturePaletteLocked() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + uint32_t sig = getTFTColorFrameSignature(); + if (sig == 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); + } + uint8_t count = getTFTColorRegionCount(); + if (count > MAX_TFT_COLOR_REGIONS) + count = MAX_TFT_COLOR_REGIONS; + for (uint8_t i = 0; i < count; i++) { + const TFTColorRegion &r = colorRegions[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 = TFTPalette::White; + paletteDefaultOff = getThemeBodyBg(); + paletteSig = sig; +#endif +} + +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; +} + bool ScreenMirror::hasChunkFor(uint32_t clientFrameId, uint16_t clientOffset) { concurrency::LockGuard g(&lock); @@ -114,6 +205,7 @@ bool ScreenMirror::copyChunk(uint32_t &clientFrameId, uint16_t &clientOffset, me out.width = width; out.height = height; out.format = meshtastic_DisplayFrame_Format_MONO_VLSB; + out.palette_signature = paletteSig; // 0 on monochrome-only builds out.frame_id = frameId; out.offset = clientOffset; out.total_size = frameSize; diff --git a/src/graphics/ScreenMirror.h b/src/graphics/ScreenMirror.h index 203e910bfd..e7a36d4716 100644 --- a/src/graphics/ScreenMirror.h +++ b/src/graphics/ScreenMirror.h @@ -42,8 +42,16 @@ class ScreenMirror /// the client was mid-drain restarts it at offset 0 of the new frame. bool copyChunk(uint32_t &clientFrameId, uint16_t &clientOffset, meshtastic_DisplayFrame &out); + /// 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); + private: void freeSnapshotLocked(); + void capturePaletteLocked(); concurrency::Lock lock; bool mirroring = false; @@ -54,6 +62,16 @@ class ScreenMirror uint16_t width = 0; uint16_t height = 0; uint32_t frameId = 0; + // Color-region palette captured with the frame (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; }; extern ScreenMirror screenMirror; diff --git a/src/main.cpp b/src/main.cpp index 33aed0ca4d..0d8585acfd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1392,6 +1392,33 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) deviceMetadata.has_xeddsa = true; #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(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; +#elif defined(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 +#ifdef HAS_TOUCHSCREEN + deviceMetadata.display.has_touch = true; +#endif + } + } +#endif return deviceMetadata; } diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 4023478230..6ae3198a17 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -390,6 +390,8 @@ void PhoneAPI::close() graphics::screenMirror.setMirror(false); mirrorFrameId = 0; mirrorOffset = 0; + mirrorPaletteSig = 0; + mirrorPaletteOffset = 0; #endif releasePhonePacket(); // Don't leak phone packets on shutdown releaseQueueStatusPhonePacket(); @@ -1111,6 +1113,10 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) 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(mirrorFrameId, mirrorOffset, fromRadioScratch.display_frame)) { // Lowest priority: mesh traffic and notifications outrank pixels. @@ -1764,7 +1770,8 @@ bool PhoneAPI::available() return true; #if HAS_SCREEN_MIRROR - return screenMirrorAuthorized() && graphics::screenMirror.hasChunkFor(mirrorFrameId, mirrorOffset); + return screenMirrorAuthorized() && (graphics::screenMirror.hasPaletteChunkFor(mirrorPaletteSig, mirrorPaletteOffset) || + graphics::screenMirror.hasChunkFor(mirrorFrameId, mirrorOffset)); #else return false; #endif diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index 15176963c6..d700d437d0 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -91,10 +91,13 @@ class PhoneAPI meshtastic_XModem xmodemPacketForPhone = meshtastic_XModem_init_zero; #if HAS_SCREEN_MIRROR - // Per-connection drain cursor into ScreenMirror's current frame, so - // coexisting clients (BLE + serial + TCP) each receive complete frames. + // 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).