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.
This commit is contained in:
James Rich committed 2026-11-03 07:39:17 -06:00
1 parent 270bf00f6c
commit 1ed930ec2d
5 files changed
+150 -3

No files matched your search

+92
View File
@@ -8,11 +8,26 @@
#include <OLEDDisplay.h>
#include <cstring>
#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;
+18
View File
@@ -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;
+27
View File
@@ -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;
}
+8 -1
View File
@@ -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
+5 -2
View File
@@ -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).