Implement partial refreshing protocol#21
Draft
LordMike wants to merge 23 commits intoOpenDisplay:mainfrom
Draft
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
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.
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 partial stream contains two controller-plane images, concatenated:
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 * 2.Raw partial
0x71bytes are streamed directly into the panel driver. 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 old/new plane 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).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"0x71is changed to also support sending two images in one stream, this has no protocol changeReferences: