Skip to content

Implement partial refreshing protocol#42

Merged
g4bri3lDev merged 19 commits into
OpenDisplay:mainfrom
LordMike:feat/partial-rendering
May 19, 2026
Merged

Implement partial refreshing protocol#42
g4bri3lDev merged 19 commits into
OpenDisplay:mainfrom
LordMike:feat/partial-rendering

Conversation

@LordMike

@LordMike LordMike commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Adds client-side support for partial image uploads to OpenDisplay. The protocol-level specifics are covered in the firmware PR; this PR is about making the Python client able to track display state, compute a partial region, send it, and recover back to full uploads when needed. The code has been written by a mix of Claude Code and OpenAI Codex, at my direction.

TODOs

  • Decide whether refresh_mode should remain a normal user-facing option for partial uploads. Right now the client accepts RefreshMode.PARTIAL, but when partial state is present the fallback path forces a full refresh to re-establish known state. In my opinion, the CLI should not expose this to the caller, except possibly to choose between "fast" and "full" - but not "partial". The library should possibly not accept "PARTIAL" - doing so will lead to invalid states if the "old" image is incorrect. Maybe we need to split this to two api methods, where one (the normal) can take a mode like FULL or FAST, but the other (partial) does not take a mode.. or, it takes a mode, but its a fallback_mode, in case partial wasn't possible, and still only FULL or FAST.
  • Decide what the longer-term state persistence API should look like. The CLI has --state-file, and the library takes a caller-owned PartialState, but applications may want a higher-level helper around this.

The process

The normal upload path is still the same from a caller's perspective: prepare or pass an image, then upload it. If no partial state is supplied, the client uses the existing full-image upload behavior.

Partial uploads are opt-in by passing a PartialState to upload_image or upload_prepared_image. The first upload with an empty state falls back to a full update, assigns a new etag, and stores the processed palette image in the state. Later uploads compare the new processed image against the stored one, compute a bounding rectangle for changed pixels, align it to the packed-byte boundary, and send only that rectangle through the partial path.

If nothing changed, the upload is skipped. If the device rejects the old etag, or if the display/configuration cannot support partial updates, the client clears the stale state and performs a full upload once to recover to a known good image.

The state

PartialState is caller-owned on purpose. The library mutates it after successful full or partial uploads, but the application decides whether and where to persist it.

The state contains the last accepted etag, the processed palette bytes for the image believed to be on the panel, and the image dimensions. It can be serialized with to_bytes() and restored with from_bytes(). The CLI uses this through --state-file, writing the file atomically after each successful upload.

Client shape

The main library additions are:

  • RefreshMode.PARTIAL for requesting true partial updates from the client API
  • PartialState plus helpers for serialization, etag generation, NACK parsing, bounding-box diffing, byte-boundary alignment, and old/new rectangle stream construction
  • partial-aware upload_image and upload_prepared_image flows that try partial first and fall back to full uploads when state is missing, stale, or unsupported
  • new protocol command builders for the partial start command and etag-bearing upload completion command
  • CLI upload --state-file support for persistent partial-rendering state
  • an examples/animate.py helper that sends the first frame as a full update and subsequent frames through the partial path

Caveats

  • Color support is conservative - the current client only attempts the partial path for the simple mono case and avoids color schemes that need more complicated bitplane handling. This matches the firmware side, where no color panels are known to support partial updates.

Changes

This PR adds the client side for the firmware partial-rendering work:

  • Adds partial-rendering state management and serialization
  • Adds partial diff/rectangle helpers and stream construction
  • Extends upload flows to attempt partial updates and recover with full updates
  • Adds the partial refresh mode and protocol command builders needed by the client
  • Adds CLI state-file support for repeated partial uploads
  • Adds an animation example using full-first, partial-after uploads
  • Adds unit tests for partial state, stream builders, NACK handling, and upload fallback behavior

References:

@codecov

codecov Bot commented Apr 28, 2026

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment

Thanks for integrating Codecov - We've got you covered ☂️

@LordMike LordMike marked this pull request as ready for review April 30, 2026 14:00
@LordMike LordMike requested a review from g4bri3lDev as a code owner April 30, 2026 14:00
# Conflicts:
#	src/opendisplay/cli.py
#	src/opendisplay/device.py
Comment thread examples/animate.py Outdated
Comment thread examples/animate.py Outdated
send_ms = (send_end[0] - t_upload_start) * 1000
refresh_ms = (t_upload_end - send_end[0]) * 1000
kb = bytes_transferred[0] / 1024
stats = f"prep {prep_ms:.0f}ms · send {send_ms:.0f}ms ({kb:.1f} KB) · refresh {refresh_ms:.0f}ms"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
stats = f"prep {prep_ms:.0f}ms · send {send_ms:.0f}ms ({kb:.1f} KB) · refresh {refresh_ms:.0f}ms"
stats = (
f"prep {prep_ms:.0f}ms · send {send_ms:.0f}ms "
f"({kb:.1f} KB) · refresh {refresh_ms:.0f}ms"
)

Comment thread tests/unit/test_device_partial.py Outdated
Comment thread tests/unit/test_device_partial.py Outdated
@g4bri3lDev

Copy link
Copy Markdown
Member

The pylint failures in _maybe_upload_partial need a bit of structural work. I verified the following approach locally and all 287 tests pass cleanly with ruff and pylint both happy afterwards.

device.py — three extractions

  1. _compute_partial_region(self, processed_image, state) — extract the precondition checks and diff computation (everything from the config/display checks down through align_rect). Return a string ("fallback_full" / "no_change") or a tuple of the computed values (display, color_scheme, width, height, palette_image, new_palette, old_palette, bbox, rx, ry, rw, rh). This collapses 7 early returns into one and removes ~14 locals from the parent function.

  2. _send_partial_chunks(self, remaining, stream_bytes, state, progress_callback) — extract the while offset < len(remaining) DATA chunk loop. Removes another 6 locals from the parent.

  3. Remove refresh_mode from the signature — it's passed in by both callers but never used inside the function (the END command hardcodes RefreshMode.PARTIAL).

partial.py — module line count

Move _encode_segment_wire to partial.py as a module-level function (it doesn't use self and fits naturally alongside align_rect, compute_bounding_rect etc.). This brings device.py under the 1500-line limit. You can also pull the zlib/flags stream-building logic into a build_partial_stream() helper there.

partial.py — two small ones

  • align_rect: rename display_height_display_height (unused argument)
  • compute_bounding_rect: replace the four if x < min_x: min_x = x blocks with min_x = min(min_x, x) etc.

animate.py and tests/unit/test_device_partial.py — four lines over the 120-char ruff limit, all straightforward wrapping.

@LordMike

Copy link
Copy Markdown
Contributor Author

I believe all was handled.

@g4bri3lDev g4bri3lDev merged commit bff2fe5 into OpenDisplay:main May 19, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants