feat(display): capture the device's own screen with capture_display - #78
Draft
jamesarich wants to merge 7 commits into
Draft
feat(display): capture the device's own screen with capture_display#78jamesarich wants to merge 7 commits into
jamesarich wants to merge 7 commits into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Reads the framebuffer the firmware is rendering, rather than photographing the panel with a webcam. Same question as capture_screen, exact pixels — which also makes OCR reliable in a way a webcam photo never is. Handles both wire formats: BaseUI's 1bpp MONO_VLSB frames and MUI (device-ui/LVGL) RGB565 dirty rects composited onto a persistent canvas. PNG encoding is pure stdlib, so the core stays dependency-light. Uses the one-shot get_display_frame_request verb rather than arming continuous mirroring: the firmware clears the one-shot only once the whole frame has drained, so a call that dies partway cannot leave the device streaming or holding its rect pool. Collection continues until the stream goes quiet, because a full repaint can arrive as several rects and stopping at the first would capture a half-drawn screen. Everything on this wire is device-controlled, so every field is validated before anything is allocated: geometry is latched at offset 0 so a later chunk cannot transpose an in-flight blit, continuations must match the buffer actually allocated rather than their own total_size claim, and bounds use the subtraction form because rect_x + rect_width overflows for hostile values. A blank capture is reported as such alongside display.screen_on_secs — a timed-out panel mirrors as one flat colour and otherwise reads as a mirroring failure. Blankness is tested as "all one colour", not "all black", since an inverted or e-ink panel blanks to white. Annotated open-world: the device's screen renders mesh-sourced text, so a capture can carry a prompt-injection payload exactly like android_screenshot. The admin verb and the DisplayFrame payload are hand-encoded and hand-parsed — both are new in protobufs#1054 and the released meshtastic package's generated bindings do not carry them yet. Signed-off-by: James Rich <james.a.rich@gmail.com>
The camera path stays the fallback for firmware without screen-mirror support, and a blank capture means the panel timed out rather than the capture failing — both are easy to misread as a broken tool. Signed-off-by: James Rich <james.a.rich@gmail.com>
A device that paints the 1bpp base UI onto a colour panel applies per-region on/off colours at flush time and streams the same table as FromRadio.display_palette. Applying it here renders those devices in their true colours at 1bpp bandwidth, instead of the black-and-white they got before — MUI was already full colour via RGB565 rects, so colour BaseUI was the one gap. Region precedence follows the proto: a higher table index overrides lower-indexed regions where they overlap, and a pixel outside every region takes the palette defaults. Mono rendering is now deferred to the end of a capture rather than done per chunk. The palette may arrive after the frame it colours — the proto allows display_palette to interleave, and the firmware happens to send it first — and rendering eagerly would freeze the frame as monochrome. `colorized` reports whether a palette was actually matched and applied. A frame naming a signature we do not hold, or none at all, renders monochrome, which is what the proto asks for; a half-received region table does too, rather than colouring some regions and not others. Signed-off-by: James Rich <james.a.rich@gmail.com>
Two bugs, both found on hardware and neither visible to the unit tests. connect() yields the moment the interface is constructed, so the admin messages went out into the tail of the config handshake and were silently dropped. Nothing arrived and the capture timed out. Settle before speaking, and re-send if nothing has come back. The one-shot get_display_frame_request also does not reliably produce a frame on the MUI (LVGL) path — arming first and requesting returns a rect every time where request-alone times out on the same device, minutes apart. The one-shot looked preferable from the firmware source because it self-clears once the frame drains, but that governs when the flag is cleared, not whether a repaint is ever produced. Arm, request, and disarm in a finally; PhoneAPI::close() disarms again when the connection drops, so an abandoned call cannot leave the device armed. The retry re-sends both messages, not just the request: arming is the half that matters, and retrying the request alone reproduces exactly the sequence that does not work. Verified on a T-Deck in MUI mode: three consecutive captures where all three previously timed out, and a woken panel returns the live screen composited from nine rects. Signed-off-by: James Rich <james.a.rich@gmail.com>
Capturing a walkthrough was impossible: every call overwrote one scratch path, so ten screens left one image. `out_path` names the file and creates its parent, so a doc set can be captured screen by screen. `scale` enlarges by an integer factor, nearest-neighbour and capped at 8. A 128x64 OLED is unreadable at native size on a page, and smoothing a device UI reads as a blurry photo of a screen rather than a screenshot, so the pixel grid stays hard. The result reports the written image as width/height and the device as panel_width/panel_height, since those now differ. Verified on a T-Deck: 320x240 panel out at 640x480 into a nested path that did not exist. Signed-off-by: James Rich <james.a.rich@gmail.com>
Names the two arguments that exist for this case and the wake/capture/ advance loop, with the per-call cost so an agent does not interleave other port work. Also the two things that bite: content drifts between shots (node counts, battery, clock), which push_fake_nodedb and set_owner pin; and a real screen leaks real node names, positions and message text into anything destined for a public page — seed a fake node DB before capturing rather than editing PNGs afterwards. Signed-off-by: James Rich <james.a.rich@gmail.com>
jamesarich
force-pushed
the
screen-mirror-tools
branch
from
September 8, 2026 21:03
087dce9 to
7c8b9d3
Compare
display_mirror.py hand-encodes and hand-parses these messages, because the released meshtastic package's bindings do not carry them yet, so the FromRadio payload_variant field numbers are literals here rather than generated constants. protobufs#1054 moved display_frame 20 -> 21 and display_palette 21 -> 22 to vacate tag 20 for #980. Nothing would have caught the mismatch: the tool would simply have stopped recognising every frame. DisplayFrame's and DisplayPalette's own field numbers are unchanged.
This was referenced Sep 8, 2026
Draft
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
capture_display— a screenshot of the device's own screen, read from the framebuffer the firmware is rendering instead of photographed off the panel with a webcam.This is the on-device counterpart to
capture_screen. The pixels are exact rather than a camera's view of a lit LCD, which also makes OCR reliable, and it needs no camera rig — the bench's webcam becomes optional for "what is on the screen?" questions.Draft: needs unreleased firmware
The firmware side is meshtastic/firmware#11681 and the wire contract is meshtastic/protobufs#1054, neither merged. Older firmware ignores the
get_display_frame_requestadmin verb silently, so the tool times out with a message naming the requirement rather than looking like a hung device. Marking ready once those land.Same reason the admin verb and the
DisplayFramepayload are hand-encoded and hand-parsed: the releasedmeshtasticpackage's generated bindings do not carry these fields yet.display_mirror.pyis written so those helpers can be swapped for the generated messages with no change to the tool surface.What it does
Handles both wire formats:
PNG encoding is pure stdlib, so the core stays dependency-light — no Pillow.
Arm, request, disarm. The one-shot
get_display_frame_requestlooked preferable — the firmware clears it once the frame drains, so it cannot leave the device streaming — but on the MUI (LVGL) path it does not reliably produce a frame. On a T-Deck, arm-plus-request returns a rect every time where request-alone times out, minutes apart on the same device. The firmware source says when the one-shot flag is cleared, not whether a repaint is ever produced. So this arms, requests, and disarms in afinally;PhoneAPI::close()disarms again when the connection drops, so an abandoned call cannot leave the device armed.Two related details, both found only on hardware:
connect()yields the instant the interface is constructed, and an admin message sent into the tail of the config handshake is silently dropped — so the capture settles before speaking, and re-sends if nothing arrives. The retry re-sends both messages, since retrying the request alone reproduces exactly the sequence that does not work.Collection continues until the stream goes quiet, because a full repaint arrives as several rects — nine for the T-Deck home screen — and stopping at the first completion captures a fragment.
Blank captures are labelled. A panel that has hit
display.screen_on_secsmirrors as one flat colour, which otherwise reads as a mirroring failure. The result carriesblankplus the device's ownscreen_on_secsso the caller knows to wake it withsend_input_eventand capture again. Blankness is tested as "all one colour", not "all black" — an inverted or e-ink panel blanks to white.Validating device-controlled input
total_sizeand the rect geometry come off the wire, so every field is checked before anything is allocated:offset == 0, so a later chunk cannot transpose an in-flight blit;total_sizeclaim;rect_x <= width - rect_width) because the addition overflows for hostile values;rect_width/rect_heightof 0 means "the whole panel in that axis", not "empty";(h + 7) // 8), and the validator and renderer agree on it.Each of these is pinned by a unit test.
Annotations
openWorldHint— the device's screen renders mesh-sourced text (a remote node'slong_name, a received message), so a capture can carry a prompt-injection payload exactly likeandroid_screenshot.destructiveHintmatchesandroid_screenshottoo: it drives the device and overwrites the PNG at its fixed path.Testing
ruff check,ruff format --check,mypy(no new ignores),pytest tests/unitat 802 passed / 54 skipped.displaymode = COLOR) at firmware2.8.0.07cfb8f: 320×240 RGB565, full colour, correct screen. The blank path was verified too — a timed-out panel returnedblank: truewithscreen_on_secs: 30, and the same capture after asend_input_eventwake returned the live screen.displaymode = DEFAULT(BaseUI on its colour panel) returnscolorized: trueon aMONO_VLSBframe, and the PNG carries 4 distinct colours with 75.8% non-greyscale pixels — a monochrome render would be exactly 2 colours and 0%.Colour on both UIs
MUI streams RGB565 rects, so it is full colour directly. A colour BaseUI device instead streams its 1bpp frame plus the region palette its panel paints with (
DisplayPalette, FromRadio tag 21), and that is now applied here — those devices render in their true colours at 1bpp bandwidth rather than black-and-white.Region precedence follows the proto: a higher table index overrides lower-indexed regions where they overlap, and a pixel outside every region takes the palette defaults.
colorizedin the result reports whether a palette was actually matched and applied — a frame naming a signature we do not hold, or none at all, renders monochrome, as does a half-received region table (colouring some regions and not others would be worse than not colouring at all).Mono rendering is deferred to the end of a capture rather than done per chunk, because the palette may arrive after the frame it colours — the proto allows
display_paletteto interleave.Feature spec and cross-platform discussion: meshtastic/design#142.
Feature stack
Five repos, and the release order runs top to bottom. Nothing below can ship until the piece above it lands.
DisplayFrame/DisplayPalette/DisplayInfo+ the two admin verbscapture_displaytoolingdevice-ui#394 is the current blocker: without the flush observer the MUI/colour path compiles out of every committed firmware configuration, leaving only the 1bpp mono path.