Skip to content

Implement partial refreshing protocol#21

Open
LordMike wants to merge 33 commits intoOpenDisplay:mainfrom
LordMike:feat/partial-rendering
Open

Implement partial refreshing protocol#21
LordMike wants to merge 33 commits intoOpenDisplay:mainfrom
LordMike:feat/partial-rendering

Conversation

@LordMike
Copy link
Copy Markdown

@LordMike LordMike commented Apr 28, 2026

Introduces partial image sending to OpenDisplay. The code has been written by a mix of Claude Code and OpenAI Codex, at my direction.

TODOs

  • Decide on whether refresh_mode should be deprecated, now that there are two init packets, each intended for full or partial. There is "fast" refresh, which could continue to have a purpose. This is probably mostly a client thing, ie. for py-opendisplay. It makes sense for me, that the initializer (0x70 / 0x76 sets the refresh mode, and 0x72 doesn't)
  • Decide if uncompressed_bytes should remain in 0x76
  • Consider bitpacking in 0x76 - it has a lot of fields, but it is unlikely all bits are used in those fields. Like, will we ever see an x at 40k? A few bytes could be saved by packing it a bit more, maybe 14 bits per x/y/w/h (14 bits => 16k limit. 12 bits => 4k limit). The 4 numbers could save a byte in total, if they were reduced by 2 bits each.
  • I chose to concatenate old+new image into one big 0x71 stream. This overflows and reverts to full images if the diff is too large. I think this is ok, because if we in the future (hopefully) make streamed decompression, it just works. However, work could be done to send the old+new as two separate 0x71 streams, which would halve their memory requirements as the code is right now. Do we split? Wait for streamed zlib decompress?
  • In retrospect, I added a lot of error codes for specific errors. Should I collapse those to "an error occurred"?
  • I added new_etag to 0x72 - but in retrospect, it might make more sense to add it to 0x76. Then we open up for completely removing 0x72, and we keep etag out of the normal full image upload scenario; come to think of it - the reason was that 0x70 uploads also need to set the etag, so it can't solely be in 0x76 :/.. maybe there is a way to get rid of 0x72 regardless - we could optionally set the new_etag both in 0x76 (so 0x72 isn't strictly required, opening up for 0x71 just finishing the image) and 0x72 in case we're in the full image flow.

The process

Roughly, a full image is done by sending 0x70 to start new imagery, and then following it up with 0 to many 0x71's with chunks of (possibly) compressed data. Once done, a single 0x72 is sent with mode=full, which triggers the refresh.

A partial refresh however, is done by sending both old and new pixels to the display panel. The display controller then picks the appropriate update waveforms based on what the diff is. As our MCU deep sleeps between updates, and also turns the panel effectively off (for SSD16xx variants which do hold the current image in memory), we need to re-send old+new images on each partial update. The protocol is extended to allow a partial update to start, and then to re-use the 0x71 image data packets to send both the old and new images over the wire. Finally, the 0x72 is reused to complete the image upload, and do the partial refresh.

The etag

To help avoid state desynchronizations, because the client is now responsible for sending the old image along, which should be truthful to what is currently displayed, in order for the waveforms picked to be correct (to avoid force pushing a pixel beyond its acceptable range for example), an etag is added. This is a nonce, a tag, attached to the image shown, and is just a 32 bit integer. If the client presents the same etag that is currently shown, the client is trusted to pass in the proper old image so the updates are correct.

There are immediately two scenarios in which a client would be wrong about the "old image" currently displayed:

  • There are two clients in play, with each their own images to display
  • The display is rebooted unknownst to the client, so it no longer shows what the client thinks it shows

The etag is a quick way for the client to discover that the panel has drifted from its state, and to then perform a full refresh to reset it to a new known good state. It is up to the client to determine how to persist the etag and old image, if it needs to. The firmware only stores the new etag after a successful refresh; rejected or failed partial updates clear it, so the next update naturally falls back to a full refresh.

On ESP32, the etag is stored in RTC memory so it can survive deep sleep. On other targets it is a regular global and resets on reboot, which is fine; the next partial will fail the etag check and the client can recover with a full update.

Protocol shape

Full image uploads keep the existing shape:

0x70 DIRECT_WRITE_START
0x71 DATA...
0x72 DIRECT_WRITE_END + refresh

Compressed full-image uploads are buffered as compressed bytes while 0x71 packets arrive. They are decompressed during 0x72, in chunks, and those decompressed chunks are streamed to the display panel. We do not buffer a decompressed full image in memory.

Partial image uploads add a new start command:

0x76 PARTIAL_WRITE_START
0x71 DATA...
0x72 DIRECT_WRITE_END + partial refresh

The 0x76 payload is:

[flags:1]
[old_etag:4 BE]
[x:2 BE][y:2 BE][width:2 BE][height:2 BE]
[uncompressed_size:3 BE]
[optional initial stream bytes...]

Flags:

0x01: stream is zlib-compressed
all other bits: reserved, must be 0

The old_etag field is always present. A value of 0x00000000 means the partial update is not tied to a known previously displayed image; the stream then contains only the new rectangle data and 0x72 does not carry a new etag. A nonzero old_etag must match the currently displayed etag; the stream then contains both old and new rectangle data, and 0x72 carries the new etag to store after a successful refresh.

The etag-backed partial stream contains two controller-plane images, concatenated:

old rectangle bytes for PLANE_1
new rectangle bytes for PLANE_0

Without an etag, the partial stream contains only the new controller-plane image:

new rectangle bytes for PLANE_0

This is controller RAM data, not a higher-level drawing command. For the supported 1bpp path, each controller plane is ceil(width / 8) * height bytes, so uncompressed_size must be plane_bytes * 2 for etag-backed partials, or plane_bytes when old_etag is 0x00000000.

Raw partial 0x71 bytes are streamed directly into the panel driver. For etag-backed partials, once the old-image byte count is reached, the firmware opens the same rectangle on the new plane and continues writing there. Compressed partial data follows the same pattern as compressed full images: 0x71 buffers compressed bytes, and 0x72 inflates them in chunks through that same partial image sink.

The 0x72 payload is:

[refresh_mode:1]
[new_etag:4 BE]  only when the 0x76 old_etag was nonzero

For partial streams, the default refresh mode is partial. A refresh_mode of 1 requests fast refresh instead. A full-refresh request is deliberately treated as partial for partial streams.

Return messages and errors

Responses keep the existing direct-write shape:

ACK:  [0x00, opcode]
NACK: [0xFF, opcode, error, 0x00]

There is also still the older generic direct-write error:

[0xFF, 0xFF]

That is used in a few allocation/buffer cases where the old direct-write code already behaved that way.

0x70 direct write start

Possible responses:

[0x00, 0x70]                 start accepted
[0xFF, 0x70, 0x02, 0x00]     mixed full/partial data
[0xFF, 0xFF]                 compressed buffer unavailable or compressed start data exceeds capacity

0x76 partial write start

Possible responses:

[0x00, 0x76]                 start accepted
[0xFF, 0x76, 0x01, 0x00]     etag mismatch
[0xFF, 0x76, 0x03, 0x00]     rectangle out of bounds
[0xFF, 0x76, 0x04, 0x00]     rectangle x or width is not byte-aligned
[0xFF, 0x76, 0x05, 0x00]     unsupported/reserved flags, or currently unsupported backend
[0xFF, 0x76, 0x06, 0x00]     too-short start payload or uncompressed_size does not match rectangle geometry
[0xFF, 0x76, 0x07, 0x00]     stream byte count or stream content error
[0xFF, 0x76, 0x08, 0x00]     partial update unsupported for this panel mode
[0xFF, 0xFF]                 compressed buffer unavailable or compressed start data exceeds capacity

0x71 data

Possible responses:

[0x00, 0x71]                 data accepted
[0xFF, 0x71, 0x07, 0x00]     partial stream byte count or content error
[0xFF, 0xFF]                 compressed buffer unavailable

For raw full-image uploads, a final 0x71 can also complete the image and make the firmware enter the same end/refresh path as 0x72.

0x72 direct write end

Possible responses:

[0x00, 0x72]                 end accepted, refresh has been started
[0xFF, 0x72, 0x07, 0x00]     partial stream byte count/content error, decompression error, or missing new etag for an etag-backed partial
[0x00, 0x73]                 refresh completed successfully
[0x00, 0x74]                 refresh timed out

For both full and partial uploads, a successful refresh stores the new etag if one was supplied in the end payload. If refresh fails, the etag is cleared.

Future work

Decompression on the fly - While developing this, it occurs to me that in compressed imagery, the way the compression library (uzlib) is used means that the full image (compressed) is stored in MCU memory. Once done, it is decompressed during the 0x72 packet, and then streamed to the display panel. Cursory searching leads to other libraries, like miniz and tinf which should be two mini/tiny zlib inflaters. We don't need compression support, so thats a win. What I really want is a small inflater API that can receive a packet like a 0x71, decompress whatever it can, and then suspend while keeping the sliding dictionary state until the next packet arrives. This way, we can avoid buffering anything, besides the mandatory decompression dictionary.

Confusion about refresh modes - I've added 0x76 to do partials, which basically implies that the final 0x72 will be a partial refresh. If you submit a 0x72 with full after doing partial data, you'll get a garbled image out - I think. This also bleeds into the python library, where refresh mode is a parameter on the CLI, and on other tools, but you should not set these. It may be that future work should remove/deprecate the refresh mode from all packets, and let it be implicit based on the starting packet (0x70 or 0x76).

Remove or deprecate uncompressed size - I've added uncompressed_size to the 0x76, because 0x70 had it. I'm not entirely sure why, because we know what the size is, given that we know the bytes per pixel / width / height. If we're on to bad clients, they could lie in this value. I suggest deprecating the value in 0x70 - and removing it from this PR (before it goes in).

Add compressed image data to 0x72 / remove 0x72 entirely - 0x70 and 0x76 already take some chunked compressed data in their packets, which is great, because packet latency is the absolute killer in the BLE connection. The 0x72 could also carry image data, and thus save a packet. Alternatively, the MCU knows how many packets will arrive, or how many bytes will arrive. So it could also just complete the image on the last 0x71 - when compressed, its tricky to know when we're done, but this becomes implicit if we do the streamed decompression, then we know when we're done, and 0x72 can be deprecated entirely (note: also benefits from disregarding refresh_mode in 0x72)

Caveats

  • Only 1 bit per pixel panels are supported - during work, I found that bb_epaper apparently has no panels that have both 2 bits per pixel (color support) whilst also having a partial refresh initialization code. It may be that no color panels support partial refreshing. The current protocol design should be robust enough that its not an issue, in that we're sending images over. The 0x76 packet is rejected if the panel is >1bpp.
  • Pixel alignment - as the updates are actually byte aligned, updates must be aligned to 8-pixels. That means both x and width must be multiples of 8, ie. x=0/8/16/24/...

Avenues checked and disregarded

I've been through a few designs. The commit history reveals this, but notable variants are:

  • Sending very small dirty segments - I initially thought that less data at all costs is a benefit. So I had a protocol that send small segments, series of x/y/w/h+data so the MCU could write just those bits. It worked, but it was very slow, as compression was non-existent, which lead to many packets. And latency is the largest bottleneck of this system. Even adding compression did not fix it, because the overhead per segment was just too large, and that compression never really took off due to the small segments it worked on.
  • Interleaving old & new images - I moved to reusing 0x71 to just send one big dirty rectangle to the device, and then use compression to win anything lost by the larger images. I then thought that the larger rectangle might hold more unchanged areas (what would previously be saved by not sending those segments), and that these unchanged areas would appear as series of bits that are identical, but across two images. So if we interleaved the images, like 256 bytes of A, 256 bytes of B, an so on, compression would be better. Empirical testing showed no change across a series of tests, so it was removed in favour of just flat out sending image A and then image B.

Changes

This PR adds these protocol changes:

  • 0x76 is added to start a new partial image (much like 0x70)
  • 0x72 is amended, to accept a "new etag" when completing an etag-backed partial update
  • 0x71 is changed to also support sending one or two image planes in one stream, this has no protocol change

References:

LordMike and others added 14 commits April 24, 2026 13:27
Maps protocol ID 0x0049 to the new GDEM133T91_960x680 bb_epaper panel
type for the 13.3" 960x680 Waveshare e-paper display (SSD1677).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds error codes (ERR_ETAG_MISMATCH, ERR_MIXED_DATA, ERR_SEGMENT_OOB) and
data-kind tracking constants (DATA_KIND_NONE/FULL/PARTIAL) per §1 of
partial-rendering-plan.md. Declares framebuffer_etag in RTC RAM so the
device can validate client diffs against the panel's current state across
deepsleep cycles, and adds RESP_PARTIAL_WRITE_DATA_ACK for the 0x76
acknowledgment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
handlePartialWriteData parses multi-segment payloads (x/y/w/h/flags +
pixels per segment), validates bounds against the active transfer's
W/H from 0x70, masks reserved flag bits, dispatches to PLANE_0/PLANE_1
via bbepSetAddrWindow + bbepStartWrite + bbepWriteData per segment, and
ACKs with {0x00, 0x76} per accepted packet.

handleDirectWriteStart accepts an optional old_etag at the tail of the
payload (presence by length, no flag byte). Mismatched etag NACKs with
ERR_ETAG_MISMATCH and clears the stored etag, forcing the client to
fall back to a full transfer.

handleDirectWriteEnd accepts an optional new_etag and stores it in RTC
RAM on successful refresh; clears on failure or when no new_etag is
supplied.

Mixed-data guard: a transfer that has accepted 0x71 rejects subsequent
0x76 (and vice versa) with ERR_MIXED_DATA, aborting and clearing etag.

writeSerial logs cover every new branch (etag accepted/rejected, OOB,
mixed-data, partial path entered, etag stored/cleared) for manual
on-device verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the 0x0076 case to the BLE command handler so partial-image-data
packets are routed to handlePartialWriteData. Placement preserves the
existing 0x0073 (LED activate) handling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- display_service.cpp: replace 50-line etag/compressed-size disambiguation
  essay with a 4-line rule (len==0 none, len==4 etag, len>=5 compressed).
  Drop verbose writeSerial chatter from etag and 0x76 paths. Fix latent
  bug where len==4 (uncompressed+etag) was misrouted as compressed.
- main.h: revert whitespace re-alignment of RESP_* defines, shorten
  framebuffer_etag comment.
- structs.h: drop section banner and per-line tutorial comments on the
  new constants.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Provide a no-op RTC_DATA_ATTR shim on non-ESP32 targets so the variable
can be declared once unguarded. On nRF52 the value resets on every boot,
which transparently degrades partial-rendering to a full upload — the
safe default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This reverts commit aab3986.

Was not needed in the end
@LordMike LordMike marked this pull request as ready for review April 29, 2026 20:25
@LordMike LordMike requested a review from jonasniesner as a code owner April 29, 2026 20:25
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.

1 participant