Skip to content

feat(screen): stream the display framebuffer to local clients (DisplayFrame mirroring) - #11681

Draft
jamesarich wants to merge 15 commits into
developfrom
screen-mirror-poc
Draft

feat(screen): stream the display framebuffer to local clients (DisplayFrame mirroring)#11681
jamesarich wants to merge 15 commits into
developfrom
screen-mirror-poc

Conversation

@jamesarich

@jamesarich jamesarich commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Streams the device's screen to its locally connected client and completes the remote-control story started by send_input_event (meshtastic/protobufs#702). Prior art: meshtastic/web#224. Draft until meshtastic/protobufs#1054 merges; the submodule pointer will move to protobufs master after. Android client: meshtastic/Meshtastic-Android#6987.

How it works

  • graphics::ScreenMirror snapshots the 1bpp OLEDDisplay framebuffer after each committed frame (single hook at updateUiFrame's exits, so the lockdown build mirrors the redacted LOCKED frame, never operator content). A memcmp diff gates capture: a static screen streams nothing.
  • Chunks drain through PhoneAPI's existing getFromRadio chain at lowest priority — mesh packets, notifications and the satellite replay always outrank pixels — with a per-client cursor (frameId, offset), so coexisting BLE/serial/TCP clients each receive complete frames.
  • Color: TFTDisplay/HUB75Display hand the mirror their color-region table at paint time (immediately before clearTFTColorRegions()), byte-swapped to logical RGB565. Palettes stream as display_palette chunks keyed by the existing region-table signature and re-send only when layout/theme changes; frames carry the signature of the palette they were painted with (coherent even across a mid-drain theme change, which also counts as frame-worthy so recolors propagate without pixel churn).
  • DeviceMetadata.display (DisplayInfo) reports geometry, PanelClass and touch during the handshake.
  • Admin verbs are local-only (mp.from != 0 is dropped): frames ride FromRadio and can never cross the mesh.
Condition Behavior
Screen unchanged, mirroring on nothing sent (diff-gated)
Theme recolor, pixels unchanged new frame + palette sent (signature change is frame-worthy)
Client disconnects mirror disarmed, snapshot + palette freed (memaudit-tracked)
Unauthorized client under MESHTASTIC_PHONEAPI_ACCESS_CONTROL never receives frames/palettes (same getAdminAuthorized bar as mesh packets)
Display-less / MESHTASTIC_EXCLUDE_SCREEN_MIRROR build compiles out (HAS_SCREEN_MIRROR), verbs ignored
E-ink / HUB75 same 1bpp path (all BaseUI targets share the buffer)

Cost

Zero allocation until armed: snapshot (1 KB on 128×64, 9.6 KB on 320×240 1bpp) + 576 B palette table on arm, freed on disarm/disconnect, memaudit-tracked. ~2 KB flash when enabled; excluded builds pay only the nanopb FromRadio union growth from the new oneof members (encoded FromRadio_size stays 510 ≤ 512).

Known limitations (deliberate for this round)

  • Single global arm flag: any local client's disconnect disarms the stream, and under access control an unauthorized local client can arm/disarm (control-plane only — it can never read frames). Per-client refcounted arming is the natural follow-up; the per-client drain cursors it needs already exist.
  • MUI/LVGL (RGB565) devices aren't mirrored yet; DisplayFrame's reserved rect fields are the path.
  • test_screen_mirror native suite (chunk reassembly, mid-drain restart, palette signature changes, memaudit balance) is sketched and queued as a follow-up commit.

🤝 Attestations

  • I have tested that my proposed changes behave as described.
  • Devices this was regression-tested on:
    • Heltec V3 (builds; not flashed)
    • T-Deck — live color mirror + remote input, plain t-deck (BaseUI) env
    • T-Beam
    • RAK 4631 — live mono mirror + remote input (WisMesh Pocket)
    • T-1000E
    • Other: native-macos, heltec-mesh-node-t114 (ST7789), heltec-v3 — build verification incl. the coloring-disabled TFT combination

🤖 Generated with Claude Code

jamesarich and others added 5 commits August 31, 2026 11:15
…rame chunks

ScreenMirror snapshots the 1bpp OLEDDisplay buffer after each committed
frame (updateUiFrame), diffing against the last sent snapshot so only
changed frames stream. Armed via the new AdminMessage set_display_mirror
(continuous) / get_display_frame_request (one-shot); chunks drain through
PhoneAPI's STATE_SEND_PACKETS chain like XModem, so BLE, serial and TCP
clients all receive them. The lockdown path mirrors the LOCKED frame,
preserving display redaction. Excludable with MESHTASTIC_EXCLUDE_SCREEN_MIRROR.

Bumps the protobufs submodule to the DisplayFrame PoC commit and
regenerates nanopb sources (FromRadio_size stays 510 <= 512).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gate frame delivery on getAdminAuthorized under access control (screen
pixels are operator content, same rule as mesh packets); move the drain
to lowest getFromRadio/available priority behind the replay drain; give
each PhoneAPI its own (frameId, offset) cursor via ScreenMirror::copyChunk
so coexisting BLE/serial/TCP clients each receive complete frames; disarm
and free the snapshot on client close and on setMirror(false), with
memaudit accounting and a geometry-change realloc guard; honor the
local-connection-only contract for both admin verbs (mp.from == 0);
demote the per-frame onNotify log to TRACE; hoist the guard into a
derived HAS_SCREEN_MIRROR in configuration.h.

Regenerates protos for the reviewed contract (FORMAT_UNSPECIFIED = 0,
MONO_VLSB = 1, width/height int_size:16).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…frame

TFTDisplay/HUB75 consume and then clear the per-frame color-region table
inside display(), so the post-update capture always photographed an empty
table and mirrors rendered monochrome. The drivers now hand ScreenMirror
the palette (signature, defaults, regions — all panel-byte-order, swapped
to logical RGB565 in the store) right before clearing, and the mirror
stamps frames with the signature of the palette they were painted with.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Guard the diff-path palette capture with GRAPHICS_TFT_COLORING_ENABLED too
(env:native defines USE_TFTDISPLAY without coloring, so the site referenced
an undeclared identifier and broke the CI/test env); test HAS_TOUCHSCREEN
by value, not definedness — it defaults to 0, so every screened device
reported touch; stamp frames with the signature captured WITH the snapshot
and treat a palette-only change as frame-worthy, so theme recolors reach
the client without pixel churn and mid-drain captures stay coherent;
compute the frame size in 32 bits and refuse panels past the uint16 chunk
cursors; make setMirror idempotent (no disconnect log spam); report
PanelClass UNSPECIFIED on portduino where the panel is runtime-selected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jamesarich

Copy link
Copy Markdown
Contributor Author

Cross-platform feature spec (client-agnostic wire contract, reassembly rules, input mapping, UX and device requirements): meshtastic/design#142

@github-actions

Copy link
Copy Markdown
Contributor

⚡ Try this PR in the Web Flasher

Note

Building this pull request… the flash button, badges and supported-board
list will appear here automatically once CI finishes.

@jamesarich jamesarich added the enhancement New feature or request label Aug 31, 2026
jamesarich and others added 10 commits August 31, 2026 16:02
On HAS_TFT builds, tftSetup registers device-ui's new flush observer:
every LVGL dirty rect arrives pre-byte-swap (native RGB565) and queues
into ScreenMirror's bounded rect pool (96 KB, 32 rects), draining through
the existing display_frame chunker with the rect fields and per-rect
frame_ids. Arming or a one-shot triggers a full repaint through device-ui
so a new client synchronizes the whole screen; queue overflow drops rects
and requests one resync repaint when drained. Spike scope: the rect queue
is single-consumer (per-client cursors cover mono frames only).

Requires the device-ui flush-observer branch; the vendored pin is
overridden locally and intentionally left uncommitted until device-ui
merges. Regenerates protos for Format.RGB565.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 320x240 RGB565 sync is ~150 KB; the 96 KB pool overflowed mid-repaint
and the resync loop could never deliver a complete first frame — clients
composited incremental rects over black. 192 KB (PSRAM-first on ESP32)
holds a full repaint plus concurrent updates, and 64 rect headers cover
icon-sized bursts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…UI_MIRROR

The vendored device-ui pin predates the flush observer; without the gate
this branch cannot build t-deck-tft in CI. The define is set locally
alongside the device-ui override until the device-ui change merges and the
pin moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A lazily attached observer (inputBroker is created after tftSetup) maps
remote send_input_event traffic onto device-ui's injection seam: broker
directions become LV_KEY_* (note the deliberate LEFT/RIGHT cross-map — the
broker codes were modeled on LVGL keys but those two are swapped), SELECT
with coordinates synthesizes a long press, USER_PRESS taps, BACK/CANCEL
map to ESC, and kb_char passes through. Gated with the MUI mirror define
until the device-ui side merges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tBroker

MUI builds never construct an InputBroker — Modules.cpp skips it (along
with SystemCommands and buzzer feedback) whenever displaymode is COLOR —
so AdminMessage.send_input_event hit AdminModule's null-broker guard and
died: remote control was silently inert on every MUI device. AdminModule
now offers the event to graphics::muiInjectInputEvent first, which maps it
onto device-ui's virtual LVGL devices and reports whether it consumed it,
falling through to the broker on BaseUI builds.

Verified on a T-Deck with a headless mirror client: injecting USER_PRESS
at (35,105) navigates MUI to Group Channels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getDeviceMetadata derived display geometry from the BaseUI screen object,
which MUI builds never create — so color-UI devices advertised no display
at all, and clients could not tell they were touch-capable. Ask LVGL for
the logical resolution instead (already rotated) and report RGB565 / TFT /
has_touch. Verified on a T-Deck: 320x240, RGB565, TFT, touch true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Vertical becomes encoder rotation (what actually moves focus in an LVGL
group) and horizontal becomes the slider keys, deliberately inverted, so
remote control matches what the device's own trackball driver emits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four defects from review. The idempotence guard added with the wake work
skipped cleanup when a one-shot request disarmed, stranding the 192 KB
rect pool and leaving the mirror armed for a departed client — cleanup is
now unconditional and only the log is guarded. The rect FIFO has a single
shared cursor, so it now records the connection that claims it rather than
splitting each frame between coexisting BLE and serial clients. Panel
dimensions come from LVGL at registration instead of growing as a running
max of rect extents, which contradicted both the wire contract and
DeviceMetadata. And on overflow the backlog is dropped and a repaint
requested immediately, because dropping only the newest rect wedged the
pool until the queue drained and thrashed the resync — the reason a fresh
arm often never delivered a full frame.

Also: notify only on the empty-to-non-empty transition rather than per
LVGL flush, log a failed pool allocation, keep PSRAM out of the internal
memaudit budget, and enable device-ui injection explicitly at setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit swept in two working-tree files that exist only to
build the MUI mirror against a local device-ui checkout: a
symlink:// lib_deps pointing at a path on one machine, and the
MESHTASTIC_MUI_MIRROR define on t-deck-tft. Neither resolves anywhere
else, so t-deck-tft could not build. Restores the pinned device-ui
archive and the stock variant flags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setMirror cleared muiOwner outside any guard, so every board without a
TFT failed to compile — the member only exists on the MUI path. The
assignment was redundant anyway: freeSnapshotLocked() releases the pool
and the ownership claim together.

The guards were also drawn on HAS_TFT while the code they protect needs
a device-ui carrying the flush observer, so a color build that never
opts in still carried the rect queue in .bss. Replaces the ad-hoc
HAS_SCREEN_MIRROR && defined(MESHTASTIC_MUI_MIRROR) pairs with a single
derived HAS_MUI_MIRROR next to HAS_SCREEN_MIRROR, and gates the queue,
the pool, the input seam and their declarations on it.

Verified on all three build shapes: t-deck-tft with the opt-in,
picomputer-s3 (TFT, opted out) and heltec-v3 (no TFT).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant