Implement partial refreshing protocol#21
Open
LordMike wants to merge 33 commits intoOpenDisplay:mainfrom
Open
Conversation
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
2 tasks
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.
Introduces partial image sending to OpenDisplay. The code has been written by a mix of Claude Code and OpenAI Codex, at my direction.
TODOs
refresh_modeshould 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 / 0x76sets the refresh mode, and0x72doesn't)uncompressed_bytesshould remain in0x760x76- it has a lot of fields, but it is unlikely all bits are used in those fields. Like, will we ever see anxat 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.0x71stream. 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?new_etagto0x72- but in retrospect, it might make more sense to add it to0x76. Then we open up for completely removing0x72, and we keepetagout of the normal full image upload scenario; come to think of it - the reason was that0x70uploads also need to set theetag, so it can't solely be in0x76:/.. maybe there is a way to get rid of0x72regardless - we could optionally set thenew_etagboth in0x76(so0x72isn't strictly required, opening up for0x71just finishing the image) and0x72in case we're in the full image flow.The process
Roughly, a full image is done by sending
0x70to start new imagery, and then following it up with 0 to many0x71's with chunks of (possibly) compressed data. Once done, a single0x72is 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
0x71image data packets to send both the old and new images over the wire. Finally, the0x72is 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
etagis 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:
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:
Compressed full-image uploads are buffered as compressed bytes while
0x71packets arrive. They are decompressed during0x72, 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:
The
0x76payload is:Flags:
The
old_etagfield is always present. A value of0x00000000means the partial update is not tied to a known previously displayed image; the stream then contains only the new rectangle data and0x72does not carry a new etag. A nonzeroold_etagmust match the currently displayed etag; the stream then contains both old and new rectangle data, and0x72carries the new etag to store after a successful refresh.The etag-backed partial stream contains two controller-plane images, concatenated:
Without an etag, the partial stream contains only the new controller-plane image:
This is controller RAM data, not a higher-level drawing command. For the supported 1bpp path, each controller plane is
ceil(width / 8) * heightbytes, souncompressed_sizemust beplane_bytes * 2for etag-backed partials, orplane_byteswhenold_etagis0x00000000.Raw partial
0x71bytes 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:0x71buffers compressed bytes, and0x72inflates them in chunks through that same partial image sink.The
0x72payload is:For partial streams, the default refresh mode is partial. A
refresh_modeof1requests 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:
There is also still the older generic direct-write error:
That is used in a few allocation/buffer cases where the old direct-write code already behaved that way.
0x70direct write startPossible responses:
0x76partial write startPossible responses:
0x71dataPossible responses:
For raw full-image uploads, a final
0x71can also complete the image and make the firmware enter the same end/refresh path as0x72.0x72direct write endPossible responses:
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 the0x72packet, 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 a0x71, 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
0x76to do partials, which basically implies that the final0x72will be a partial refresh. If you submit a0x72withfullafter 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 (0x70or0x76).Remove or deprecate uncompressed size - I've added
uncompressed_sizeto the0x76, because0x70had 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 -
0x70and0x76already take some chunked compressed data in their packets, which is great, because packet latency is the absolute killer in the BLE connection. The0x72could 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 last0x71- 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, and0x72can be deprecated entirely (note: also benefits from disregardingrefresh_modein0x72)Caveats
0x76packet is rejected if the panel is >1bpp.xandwidthmust 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:
Changes
This PR adds these protocol changes:
0x76is added to start a new partial image (much like0x70)0x72is amended, to accept a "new etag" when completing an etag-backed partial update0x71is changed to also support sending one or two image planes in one stream, this has no protocol changeReferences: