From 320d7fb252b52e419ca466b47d895bfb96c76b2a Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Fri, 24 Apr 2026 13:27:50 +0200 Subject: [PATCH 01/32] Add panel mapping for GDEM133T91 960x680 (panel_ic_type 0x0049) 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 --- src/display_service.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/display_service.cpp b/src/display_service.cpp index 5224920..57f2082 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -186,6 +186,7 @@ int mapEpd(int id){ case 0x003F: return EP31_240x320; case 0x0040: return EP75YR_800x480; case 0x0041: return EP_PANEL_UNDEFINED; + case 0x0049: return GDEM133T91_960x680; default: return EP_PANEL_UNDEFINED; } } From e245c80333ce5a2baffc09b1dbab9e48dd85169a Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Fri, 24 Apr 2026 13:34:25 +0200 Subject: [PATCH 02/32] Fix GDEM133T91 panel_ic_type to 0x0042 Co-Authored-By: Claude Sonnet 4.6 --- src/display_service.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index 57f2082..465baf1 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -186,7 +186,7 @@ int mapEpd(int id){ case 0x003F: return EP31_240x320; case 0x0040: return EP75YR_800x480; case 0x0041: return EP_PANEL_UNDEFINED; - case 0x0049: return GDEM133T91_960x680; + case 0x0042: return GDEM133T91_960x680; default: return EP_PANEL_UNDEFINED; } } From e29e3834966ccc494586658e9eafc6d5cdd3f551 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sat, 25 Apr 2026 17:35:57 +0200 Subject: [PATCH 03/32] Add partial-rendering protocol constants and etag RTC storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main.h | 30 ++++++++++++++++++++---------- src/structs.h | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/main.h b/src/main.h index 264999f..2de8c28 100644 --- a/src/main.h +++ b/src/main.h @@ -49,16 +49,18 @@ using namespace Adafruit_LittleFS_Namespace; #define MAX_RESPONSE_DATA_SIZE 100 // Maximum data size in response buffer // BLE response codes (second byte only, first byte is always 0x00 for success, 0xFF for error) -#define RESP_DIRECT_WRITE_START_ACK 0x70 // Direct write start acknowledgment -#define RESP_DIRECT_WRITE_DATA_ACK 0x71 // Direct write data acknowledgment -#define RESP_DIRECT_WRITE_END_ACK 0x72 // Direct write end acknowledgment +#define RESP_DIRECT_WRITE_START_ACK 0x70 // Direct write start acknowledgment +#define RESP_DIRECT_WRITE_DATA_ACK 0x71 // Direct write data acknowledgment +#define RESP_DIRECT_WRITE_END_ACK 0x72 // Direct write end acknowledgment #define RESP_DIRECT_WRITE_REFRESH_SUCCESS 0x73 // Display refresh completed successfully #define RESP_DIRECT_WRITE_REFRESH_TIMEOUT 0x74 // Display refresh timed out -#define RESP_DIRECT_WRITE_ERROR 0xFF // Direct write error response -#define RESP_CONFIG_READ 0x40 // Config read response -#define RESP_CONFIG_WRITE 0x41 // Config write response -#define RESP_CONFIG_CHUNK 0x42 // Config chunk response -#define RESP_MSD_READ 0x44 // MSD (Manufacturer Specific Data) read response +#define RESP_PARTIAL_WRITE_DATA_ACK 0x76 // Partial image data acknowledgment +#define RESP_DIRECT_WRITE_ERROR 0xFF // Direct write error response +#define RESP_CONFIG_READ 0x40 // Config read response +#define RESP_CONFIG_WRITE 0x41 // Config write response +#define RESP_CONFIG_CHUNK 0x42 // Config chunk response +#define RESP_MSD_READ 0x44 // MSD (Manufacturer Specific Data) read response + // Communication mode bit definitions (for system_config.communication_modes) #define COMM_MODE_BLE (1 << 0) // Bit 0: BLE transfer supported @@ -186,10 +188,11 @@ bool directWriteBitplanes = false; // True if using bitplanes (BWR/BWY - 2 plan bool directWritePlane2 = false; // True when writing plane 2 (R/Y) for bitplanes uint32_t directWriteBytesWritten = 0; // Total bytes written to current plane uint32_t directWriteDecompressedTotal = 0; // Expected decompressed size -uint16_t directWriteWidth = 0; // Display width in pixels -uint16_t directWriteHeight = 0; // Display height in pixels +uint16_t directWriteWidth = 0; // Display width in pixels (authoritative for bounds-checking) +uint16_t directWriteHeight = 0; // Display height in pixels (authoritative for bounds-checking) uint32_t directWriteTotalBytes = 0; // Total bytes expected per plane (for bitplanes) or total (for others) uint8_t directWriteRefreshMode = 0; // 0 = FULL (default), 1 = FAST/PARTIAL (if supported) +uint8_t directWriteDataKind = DATA_KIND_NONE; // DATA_KIND_NONE/FULL/PARTIAL for this transfer // Direct write compressed mode: use same buffer as regular image upload uint32_t directWriteCompressedSize = 0; // Total compressed size expected @@ -261,6 +264,7 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len); void handleDirectWriteData(uint8_t* data, uint16_t len); void handleDirectWriteEnd(uint8_t* data = nullptr, uint16_t len = 0); void handleDirectWriteCompressedData(uint8_t* data, uint16_t len); +void handlePartialWriteData(uint8_t* data, uint16_t len); int mapEpd(int id); uint8_t getFirmwareMajor(); uint8_t getFirmwareMinor(); @@ -306,6 +310,12 @@ bool encryptionInitialized = false; RTC_DATA_ATTR bool woke_from_deep_sleep = false; RTC_DATA_ATTR uint32_t deep_sleep_count = 0; +// Framebuffer etag: 32-bit opaque identifier for the content currently on the +// display. Persists across deep sleep. 0x00000000 means "not set". +// Written by handleDirectWriteEnd() on success when the client supplies a +// new_etag; cleared on transfer abort or when no new_etag is provided. +RTC_DATA_ATTR uint32_t framebuffer_etag = 0; + // Advertising timeout state variables bool advertising_timeout_active = false; uint32_t advertising_start_time = 0; diff --git a/src/structs.h b/src/structs.h index 45f165e..ec9662f 100644 --- a/src/structs.h +++ b/src/structs.h @@ -280,4 +280,20 @@ struct SecurityConfig { #define SECURITY_FLAG_RESET_PIN_PULLUP (1 << 4) #define SECURITY_FLAG_RESET_PIN_PULLDOWN (1 << 5) +// --------------------------------------------------------------------------- +// Partial-rendering protocol constants (§1 of partial-rendering-plan.md) +// --------------------------------------------------------------------------- + +// Error codes for partial-rendering NACK responses: {0xFF, opcode, error, 0x00}. +// Mirror these values in py-opendisplay so the client can distinguish cases. +#define ERR_ETAG_MISMATCH 0x01u // 0x70: old_etag supplied but does not match stored etag +#define ERR_MIXED_DATA 0x02u // 0x76/0x71: mixed partial/full data in same transfer +#define ERR_SEGMENT_OOB 0x03u // 0x76: segment x+w > W or y+h > H + +// Transfer data-kind: tracks whether the current transfer has received full +// (0x71) or partial (0x76) data. Lives only for the duration of one transfer. +#define DATA_KIND_NONE 0u // No data received yet +#define DATA_KIND_FULL 1u // Full-image data (0x71) in progress +#define DATA_KIND_PARTIAL 2u // Partial-segment data (0x76) in progress + #endif \ No newline at end of file From 8f573d83206c55b01bbef504bfdee8b36f2a87b6 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sat, 25 Apr 2026 17:36:14 +0200 Subject: [PATCH 04/32] Implement 0x76 partial image data handler and etag flow on 0x70/0x72 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 --- src/display_service.cpp | 284 +++++++++++++++++++++++++++++++++++++++- src/display_service.h | 1 + 2 files changed, 281 insertions(+), 4 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index 465baf1..d3641a1 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -50,10 +50,15 @@ extern bool directWritePlane2; extern bool directWriteBitplanes; extern bool directWriteCompressed; extern bool directWriteActive; +extern uint8_t directWriteDataKind; extern uint8_t* compressedDataBuffer; extern uint8_t dictionaryBuffer[]; extern uint8_t decompressionChunk[]; +#ifdef TARGET_ESP32 +extern uint32_t framebuffer_etag; +#endif + uint32_t max_compressed_image_rx_bytes(uint8_t tm) { if ((tm & TRANSMISSION_MODE_ZIP) == 0) return 0; if ((tm & TRANSMISSION_MODE_ZIPXL) != 0 && @@ -1157,6 +1162,7 @@ void cleanupDirectWriteState(bool refreshDisplay) { directWriteTotalBytes = 0; directWriteRefreshMode = 0; directWriteStartTime = 0; + directWriteDataKind = DATA_KIND_NONE; if (refreshDisplay && displayPowerState) { #if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) if (seeed_driver_used()) { @@ -1174,6 +1180,105 @@ void cleanupDirectWriteState(bool refreshDisplay) { void handleDirectWriteStart(uint8_t* data, uint16_t len) { if (directWriteActive) cleanupDirectWriteState(false); + + // Optional old_etag: if payload is at least 4 bytes longer than the base + // message (which for 0x70 has no required payload), the last 4 bytes are + // treated as a big-endian old_etag. Presence is determined purely by + // payload length; there is no flag byte or length prefix. + // Base 0x70 payload (uncompressed): 0 required bytes before etag. + // Base 0x70 payload (compressed): 4 bytes (decompressed total size). + // We check for old_etag at offset len-4 when len >= 4 AND the payload does + // not look like just a compressed-size prefix (compression uses exactly + // 4 bytes that are not an etag). We distinguish them: if len == 4 the + // payload IS the compressed size prefix (not an etag); if len > 4 and the + // last 4 bytes are after the compressed prefix, they are the etag. If len + // == 4 for an uncompressed transfer it means old_etag is present. + // Simpler: old_etag presence is signalled by having exactly 4 extra bytes + // beyond the base payload. Base payload for uncompressed = 0 bytes; + // base for compressed = 4 bytes. We detect compressed by checking if + // the display config has ZIP mode. But that logic is fragile. + // + // Decision: follow the plan literally — the only rule is "if the BLE-level + // payload length covers the etag bytes, it is present." We treat any + // payload of length >= 4 that is NOT just-4-bytes-in-a-compressed-transfer + // as possibly having an etag. The simplest and most robust rule: the etag + // occupies bytes [len-4 .. len-1] when len >= 4 AND the caller set + // directWriteCompressed = (len >= 4) — but we haven't computed that yet. + // + // Practical encoding per plan §1.4: + // Uncompressed, no etag: len == 0 + // Uncompressed, etag: len == 4 + // Compressed, no etag: len == 4 (decompressed size only) + // Compressed, etag: len == 8 (decompressed size + etag) + // Compressed + inline: len > 4 (decompressed size + data [+ etag]) + // + // To resolve the ambiguity at len==4 we need to know if this is a + // compressed transfer. The compressed flag is signalled by len >= 4 in the + // original code. We keep that heuristic and treat old_etag as present only + // when: (a) uncompressed and len == 4, or (b) compressed and len == 8, or + // (c) compressed-with-inline and the last 4 bytes beyond the inline data + // would not be interpreted as pixel data (not detectable here). + // + // Simplest correct rule for the cases py-opendisplay actually sends: + // - If len < 4: no etag, uncompressed. + // - If len == 4: could be uncompressed+etag OR compressed+size only. + // We cannot distinguish without a flag. However py-opendisplay will + // always send the etag at the END, so: + // compressed transfers: py sends {size[4], ...data..., etag[4]} + // → len > 4 and last 4 bytes are etag when total = 4 + data + 4. + // We detect compressed as len >= 4 (existing code). So at len == 4 + // exactly, it's either: uncompressed+etag OR compressed+size(no etag). + // + // Resolution: the spec says etag presence = payload length > base. + // The compressed "base" is 4 bytes. So: if len > 4, last 4 bytes = etag + // (regardless of compression). If len == 4, this is either + // uncompressed+etag (base=0, has etag) OR compressed+size (base=4, no etag). + // We use len > 4 as the etag-present condition for compressed transfers and + // len == 4 as etag-present for uncompressed. + // Distinguish: we check whether the config supports ZIP — same test the old + // code uses. If ZIP mode AND len >= 4, the first 4 bytes are the compressed + // size. Etag then present only if len >= 8, at bytes [4..7]. + // If no ZIP AND len >= 4, etag present at bytes [0..3]. + + bool hasOldEtag = false; + uint32_t oldEtag = 0; + bool isCompressedStart = (len >= 4); // mirror the existing heuristic + + if (isCompressedStart) { + // Compressed transfer: base payload = 4-byte decompressed size. + // Etag (if present) appended after: bytes [4..7]. + if (len >= 8) { + hasOldEtag = true; + oldEtag = ((uint32_t)data[4] << 24) | ((uint32_t)data[5] << 16) | + ((uint32_t)data[6] << 8) | ((uint32_t)data[7]); + } + } else { + // Uncompressed transfer: base payload = 0 bytes. + // Etag (if present) = exactly 4 bytes. + if (len == 4) { + hasOldEtag = true; + oldEtag = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) | + ((uint32_t)data[2] << 8) | ((uint32_t)data[3]); + } + } + +#ifdef TARGET_ESP32 + if (hasOldEtag) { + writeSerial("Direct write start: old_etag present = 0x" + String(oldEtag, HEX), true); + if (framebuffer_etag == 0 || framebuffer_etag != oldEtag) { + writeSerial("ERROR: Etag mismatch (stored=0x" + String(framebuffer_etag, HEX) + + ", received=0x" + String(oldEtag, HEX) + ") — aborting transfer", true); + framebuffer_etag = 0; + uint8_t errResponse[] = {0xFF, 0x70, ERR_ETAG_MISMATCH, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + writeSerial("Etag accepted (0x" + String(oldEtag, HEX) + ")", true); + } else { + writeSerial("Direct write start: no old_etag — full transfer path", true); + } +#endif + #if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) if (seeed_driver_used()) { seeed_gfx_prepare_hardware(); @@ -1182,7 +1287,8 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { uint8_t colorScheme = globalConfig.displays[0].color_scheme; directWriteBitplanes = (colorScheme == 1 || colorScheme == 2); directWritePlane2 = false; - directWriteCompressed = (len >= 4); + directWriteCompressed = isCompressedStart; + directWriteDataKind = DATA_KIND_NONE; directWriteWidth = globalConfig.displays[0].pixel_width; directWriteHeight = globalConfig.displays[0].pixel_height; uint32_t pixels = (uint32_t)directWriteWidth * (uint32_t)directWriteHeight; @@ -1204,8 +1310,13 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { directWriteCompressedBuffer = compressedDataBuffer; directWriteCompressedSize = 0; directWriteCompressedReceived = 0; - if (len > 4) { - uint32_t compressedDataLen = len - 4; + // Inline compressed data starts at byte 4. When old_etag is present + // (len >= 8), the last 4 bytes are the etag and must not be treated as + // compressed pixel data. Etag was already parsed above; exclude it here. + uint16_t inlineStart = 4; + uint16_t inlineEnd = hasOldEtag ? (len - 4) : len; + if (inlineEnd > inlineStart) { + uint32_t compressedDataLen = inlineEnd - inlineStart; uint32_t cap = max_compressed_image_rx_bytes(globalConfig.displays[0].transmission_modes); if (compressedDataLen > cap) { cleanupDirectWriteState(false); @@ -1213,7 +1324,7 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { sendResponse(errorResponse, sizeof(errorResponse)); return; } - memcpy(directWriteCompressedBuffer, data + 4, compressedDataLen); + memcpy(directWriteCompressedBuffer, data + inlineStart, compressedDataLen); directWriteCompressedReceived = compressedDataLen; } } @@ -1243,6 +1354,19 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { void handleDirectWriteData(uint8_t* data, uint16_t len) { if (!directWriteActive || len == 0) return; + // Validate that we are not mixing full-image (0x71) data with a partial + // transfer (0x76) that has already started. + if (directWriteDataKind == DATA_KIND_PARTIAL) { + writeSerial("ERROR: 0x71 full-image data received after 0x76 partial data — mixed transfer, aborting", true); +#ifdef TARGET_ESP32 + framebuffer_etag = 0; +#endif + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x71, ERR_MIXED_DATA, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + directWriteDataKind = DATA_KIND_FULL; if (directWriteCompressed) { handleDirectWriteCompressedData(data, len); return; @@ -1272,6 +1396,20 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { if (!directWriteActive) return; directWriteStartTime = 0; if (directWriteCompressed && directWriteCompressedReceived > 0) decompressDirectWriteData(); + + // Optional new_etag: present when payload is at least 5 bytes + // (1 byte refresh mode + 4 bytes etag, big-endian). + // If absent (len < 5), the stored etag is cleared (forces next transfer full). + bool hasNewEtag = (data != nullptr && len >= 5); + uint32_t newEtag = 0; + if (hasNewEtag) { + newEtag = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) | + ((uint32_t)data[3] << 8) | ((uint32_t)data[4]); + writeSerial("Direct write end: new_etag = 0x" + String(newEtag, HEX), true); + } else { + writeSerial("Direct write end: no new_etag — clearing stored etag", true); + } + int refreshMode = REFRESH_FULL; if (data != nullptr && len >= 1 && data[0] == 1) refreshMode = REFRESH_FAST; writeSerial(String("EPD refresh: ") + (refreshMode == REFRESH_FAST ? "FAST" : "FULL") + " (mode=" + String(refreshMode) + @@ -1296,10 +1434,148 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { delay(50); cleanupDirectWriteState(false); if (refreshSuccess) { + // Store or clear the framebuffer etag. On success, the display shows the + // content just uploaded; update the etag so the next partial transfer can + // verify it. If the client did not supply a new_etag, clear the stored + // value to force the next transfer to be a full image. +#ifdef TARGET_ESP32 + framebuffer_etag = hasNewEtag ? newEtag : 0; + if (hasNewEtag) { + writeSerial("Framebuffer etag updated to 0x" + String(framebuffer_etag, HEX), true); + } else { + writeSerial("Framebuffer etag cleared (no new_etag supplied)", true); + } +#endif uint8_t refreshResponse[] = {0x00, 0x73}; sendResponse(refreshResponse, sizeof(refreshResponse)); } else { + // Refresh failed or timed out — content on display is unknown; clear etag. +#ifdef TARGET_ESP32 + framebuffer_etag = 0; + writeSerial("Framebuffer etag cleared (refresh failed/timeout)", true); +#endif uint8_t timeoutResponse[] = {0x00, 0x74}; sendResponse(timeoutResponse, sizeof(timeoutResponse)); } } + +// --------------------------------------------------------------------------- +// 0x76 — Partial image data handler +// +// Payload: one or more segments, each with the wire format: +// x : uint16 BE +// y : uint16 BE +// width : uint16 BE +// height : uint16 BE +// flags : uint8 (bit 0: plane select; bits 1-7 reserved, masked off) +// pixels : width * height * bytes_per_pixel bytes +// +// Validation: +// - Mixed data (0x71 already received this transfer) → abort, ERR_MIXED_DATA +// - x + width > W or y + height > H → abort, ERR_SEGMENT_OOB +// +// ACK: echoes {0x00, 0x76} after every accepted packet (same as 0x71 → 0x71). +// There is no auto-end: the transfer always requires an explicit 0x72. +// --------------------------------------------------------------------------- +void handlePartialWriteData(uint8_t* data, uint16_t len) { + if (!directWriteActive) { + writeSerial("ERROR: 0x76 received but no transfer active (missing 0x70)", true); + return; + } + if (len == 0) { + writeSerial("ERROR: 0x76 received with empty payload — ignoring", true); + return; + } + + // Reject if full-image data was already sent in this transfer. + if (directWriteDataKind == DATA_KIND_FULL) { + writeSerial("ERROR: 0x76 partial data received after 0x71 full data — mixed transfer, aborting", true); +#ifdef TARGET_ESP32 + framebuffer_etag = 0; +#endif + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x76, ERR_MIXED_DATA, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + directWriteDataKind = DATA_KIND_PARTIAL; + + writeSerial("=== PARTIAL WRITE DATA (0x76): " + String(len) + " bytes ===", true); + + int bitsPerPixel = getBitsPerPixel(); + uint16_t offset = 0; + + while (offset < len) { + // Minimum segment header = 9 bytes (x[2] + y[2] + w[2] + h[2] + flags[1]) + if ((uint16_t)(len - offset) < 9) { + writeSerial("ERROR: Incomplete segment header at offset " + String(offset) + + " (remaining=" + String(len - offset) + ") — aborting", true); +#ifdef TARGET_ESP32 + framebuffer_etag = 0; +#endif + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x76, ERR_SEGMENT_OOB, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + uint16_t segX = ((uint16_t)data[offset + 0] << 8) | data[offset + 1]; + uint16_t segY = ((uint16_t)data[offset + 2] << 8) | data[offset + 3]; + uint16_t segW = ((uint16_t)data[offset + 4] << 8) | data[offset + 5]; + uint16_t segH = ((uint16_t)data[offset + 6] << 8) | data[offset + 7]; + uint8_t flags = data[offset + 8] & 0x01; // mask reserved bits 1-7 + offset += 9; + + writeSerial("Segment: x=" + String(segX) + " y=" + String(segY) + + " w=" + String(segW) + " h=" + String(segH) + + " plane=" + String(flags & 0x01 ? 1 : 0), true); + + // Bounds check + if ((uint32_t)segX + segW > directWriteWidth || + (uint32_t)segY + segH > directWriteHeight) { + writeSerial("ERROR: Segment OOB (x=" + String(segX) + "+w=" + String(segW) + + " > W=" + String(directWriteWidth) + " or y=" + String(segY) + + "+h=" + String(segH) + " > H=" + String(directWriteHeight) + + ") — aborting", true); +#ifdef TARGET_ESP32 + framebuffer_etag = 0; +#endif + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x76, ERR_SEGMENT_OOB, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + // Calculate pixel data length for this segment + uint32_t segPixels = (uint32_t)segW * (uint32_t)segH; + uint32_t segBytes; + if (bitsPerPixel == 4) segBytes = (segPixels + 1) / 2; + else if (bitsPerPixel == 2) segBytes = (segPixels + 3) / 4; + else segBytes = (segPixels + 7) / 8; + + if ((uint32_t)(len - offset) < segBytes) { + writeSerial("ERROR: Segment pixel data truncated (need=" + String(segBytes) + + " have=" + String(len - offset) + ") — aborting", true); +#ifdef TARGET_ESP32 + framebuffer_etag = 0; +#endif + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x76, ERR_SEGMENT_OOB, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + int plane = (flags & 0x01) ? PLANE_1 : PLANE_0; + writeSerial("Writing segment to plane " + String(plane), true); + + bbepSetAddrWindow(&bbep, segX, segY, segW, segH); + bbepStartWrite(&bbep, plane); + bbepWriteData(&bbep, data + offset, (int)segBytes); + + offset += (uint16_t)segBytes; + } + + writeSerial("Partial write data accepted (" + String(offset) + " bytes consumed)", true); + uint8_t ackResponse[] = {0x00, 0x76}; + sendResponse(ackResponse, sizeof(ackResponse)); +} diff --git a/src/display_service.h b/src/display_service.h index 98fbd0d..a09252e 100644 --- a/src/display_service.h +++ b/src/display_service.h @@ -29,6 +29,7 @@ void handleDirectWriteCompressedData(uint8_t* data, uint16_t len); void decompressDirectWriteData(); void cleanupDirectWriteState(bool refreshDisplay); void handleDirectWriteEnd(uint8_t* data, uint16_t len); +void handlePartialWriteData(uint8_t* data, uint16_t len); int getplane(); int getBitsPerPixel(); From 976facd7ec45f77f5f312a794cf1669f3566cb7e Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sat, 25 Apr 2026 17:36:20 +0200 Subject: [PATCH 05/32] Wire 0x76 partial image data into BLE command dispatch 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 --- src/communication.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/communication.cpp b/src/communication.cpp index 69d9f7e..2488d41 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -562,6 +562,9 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin writeSerial("=== DIRECT WRITE END COMMAND (0x0072) ==="); handleDirectWriteEnd(data + 2, len - 2); break; + case 0x0076: + handlePartialWriteData(data + 2, len - 2); + break; case 0x0073: writeSerial("=== LED ACTIVATE COMMAND (0x0073) ==="); handleLedActivate(data + 2, len - 2); From e8608020030007e3fe195e1b2c1e47f4eff1fd1c Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sat, 25 Apr 2026 23:24:34 +0200 Subject: [PATCH 06/32] Trim partial-rendering changeset: drop chatter and whitespace churn - 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 --- src/display_service.cpp | 185 +++++----------------------------------- src/main.h | 30 +++---- src/structs.h | 22 ++--- 3 files changed, 40 insertions(+), 197 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index d3641a1..1fbc9ff 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -1181,101 +1181,23 @@ void cleanupDirectWriteState(bool refreshDisplay) { void handleDirectWriteStart(uint8_t* data, uint16_t len) { if (directWriteActive) cleanupDirectWriteState(false); - // Optional old_etag: if payload is at least 4 bytes longer than the base - // message (which for 0x70 has no required payload), the last 4 bytes are - // treated as a big-endian old_etag. Presence is determined purely by - // payload length; there is no flag byte or length prefix. - // Base 0x70 payload (uncompressed): 0 required bytes before etag. - // Base 0x70 payload (compressed): 4 bytes (decompressed total size). - // We check for old_etag at offset len-4 when len >= 4 AND the payload does - // not look like just a compressed-size prefix (compression uses exactly - // 4 bytes that are not an etag). We distinguish them: if len == 4 the - // payload IS the compressed size prefix (not an etag); if len > 4 and the - // last 4 bytes are after the compressed prefix, they are the etag. If len - // == 4 for an uncompressed transfer it means old_etag is present. - // Simpler: old_etag presence is signalled by having exactly 4 extra bytes - // beyond the base payload. Base payload for uncompressed = 0 bytes; - // base for compressed = 4 bytes. We detect compressed by checking if - // the display config has ZIP mode. But that logic is fragile. - // - // Decision: follow the plan literally — the only rule is "if the BLE-level - // payload length covers the etag bytes, it is present." We treat any - // payload of length >= 4 that is NOT just-4-bytes-in-a-compressed-transfer - // as possibly having an etag. The simplest and most robust rule: the etag - // occupies bytes [len-4 .. len-1] when len >= 4 AND the caller set - // directWriteCompressed = (len >= 4) — but we haven't computed that yet. - // - // Practical encoding per plan §1.4: - // Uncompressed, no etag: len == 0 - // Uncompressed, etag: len == 4 - // Compressed, no etag: len == 4 (decompressed size only) - // Compressed, etag: len == 8 (decompressed size + etag) - // Compressed + inline: len > 4 (decompressed size + data [+ etag]) - // - // To resolve the ambiguity at len==4 we need to know if this is a - // compressed transfer. The compressed flag is signalled by len >= 4 in the - // original code. We keep that heuristic and treat old_etag as present only - // when: (a) uncompressed and len == 4, or (b) compressed and len == 8, or - // (c) compressed-with-inline and the last 4 bytes beyond the inline data - // would not be interpreted as pixel data (not detectable here). - // - // Simplest correct rule for the cases py-opendisplay actually sends: - // - If len < 4: no etag, uncompressed. - // - If len == 4: could be uncompressed+etag OR compressed+size only. - // We cannot distinguish without a flag. However py-opendisplay will - // always send the etag at the END, so: - // compressed transfers: py sends {size[4], ...data..., etag[4]} - // → len > 4 and last 4 bytes are etag when total = 4 + data + 4. - // We detect compressed as len >= 4 (existing code). So at len == 4 - // exactly, it's either: uncompressed+etag OR compressed+size(no etag). - // - // Resolution: the spec says etag presence = payload length > base. - // The compressed "base" is 4 bytes. So: if len > 4, last 4 bytes = etag - // (regardless of compression). If len == 4, this is either - // uncompressed+etag (base=0, has etag) OR compressed+size (base=4, no etag). - // We use len > 4 as the etag-present condition for compressed transfers and - // len == 4 as etag-present for uncompressed. - // Distinguish: we check whether the config supports ZIP — same test the old - // code uses. If ZIP mode AND len >= 4, the first 4 bytes are the compressed - // size. Etag then present only if len >= 8, at bytes [4..7]. - // If no ZIP AND len >= 4, etag present at bytes [0..3]. - - bool hasOldEtag = false; - uint32_t oldEtag = 0; - bool isCompressedStart = (len >= 4); // mirror the existing heuristic - - if (isCompressedStart) { - // Compressed transfer: base payload = 4-byte decompressed size. - // Etag (if present) appended after: bytes [4..7]. - if (len >= 8) { - hasOldEtag = true; - oldEtag = ((uint32_t)data[4] << 24) | ((uint32_t)data[5] << 16) | - ((uint32_t)data[6] << 8) | ((uint32_t)data[7]); - } - } else { - // Uncompressed transfer: base payload = 0 bytes. - // Etag (if present) = exactly 4 bytes. - if (len == 4) { - hasOldEtag = true; - oldEtag = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) | - ((uint32_t)data[2] << 8) | ((uint32_t)data[3]); - } - } + // Payload disambiguation (§1.4): partial path is uncompressed only, so + // len == 0 → uncompressed, no etag + // len == 4 → uncompressed with old_etag (BE) + // len >= 5 → compressed (4-byte size + data; no etag tail) + bool isCompressedStart = (len >= 5); + bool hasOldEtag = (len == 4); #ifdef TARGET_ESP32 if (hasOldEtag) { - writeSerial("Direct write start: old_etag present = 0x" + String(oldEtag, HEX), true); + uint32_t oldEtag = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) | + ((uint32_t)data[2] << 8) | (uint32_t)data[3]; if (framebuffer_etag == 0 || framebuffer_etag != oldEtag) { - writeSerial("ERROR: Etag mismatch (stored=0x" + String(framebuffer_etag, HEX) + - ", received=0x" + String(oldEtag, HEX) + ") — aborting transfer", true); framebuffer_etag = 0; uint8_t errResponse[] = {0xFF, 0x70, ERR_ETAG_MISMATCH, 0x00}; sendResponse(errResponse, sizeof(errResponse)); return; } - writeSerial("Etag accepted (0x" + String(oldEtag, HEX) + ")", true); - } else { - writeSerial("Direct write start: no old_etag — full transfer path", true); } #endif @@ -1288,7 +1210,6 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { directWriteBitplanes = (colorScheme == 1 || colorScheme == 2); directWritePlane2 = false; directWriteCompressed = isCompressedStart; - directWriteDataKind = DATA_KIND_NONE; directWriteWidth = globalConfig.displays[0].pixel_width; directWriteHeight = globalConfig.displays[0].pixel_height; uint32_t pixels = (uint32_t)directWriteWidth * (uint32_t)directWriteHeight; @@ -1310,13 +1231,8 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { directWriteCompressedBuffer = compressedDataBuffer; directWriteCompressedSize = 0; directWriteCompressedReceived = 0; - // Inline compressed data starts at byte 4. When old_etag is present - // (len >= 8), the last 4 bytes are the etag and must not be treated as - // compressed pixel data. Etag was already parsed above; exclude it here. - uint16_t inlineStart = 4; - uint16_t inlineEnd = hasOldEtag ? (len - 4) : len; - if (inlineEnd > inlineStart) { - uint32_t compressedDataLen = inlineEnd - inlineStart; + if (len > 4) { + uint32_t compressedDataLen = len - 4; uint32_t cap = max_compressed_image_rx_bytes(globalConfig.displays[0].transmission_modes); if (compressedDataLen > cap) { cleanupDirectWriteState(false); @@ -1324,7 +1240,7 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { sendResponse(errorResponse, sizeof(errorResponse)); return; } - memcpy(directWriteCompressedBuffer, data + inlineStart, compressedDataLen); + memcpy(directWriteCompressedBuffer, data + 4, compressedDataLen); directWriteCompressedReceived = compressedDataLen; } } @@ -1354,10 +1270,7 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { void handleDirectWriteData(uint8_t* data, uint16_t len) { if (!directWriteActive || len == 0) return; - // Validate that we are not mixing full-image (0x71) data with a partial - // transfer (0x76) that has already started. if (directWriteDataKind == DATA_KIND_PARTIAL) { - writeSerial("ERROR: 0x71 full-image data received after 0x76 partial data — mixed transfer, aborting", true); #ifdef TARGET_ESP32 framebuffer_etag = 0; #endif @@ -1397,17 +1310,12 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { directWriteStartTime = 0; if (directWriteCompressed && directWriteCompressedReceived > 0) decompressDirectWriteData(); - // Optional new_etag: present when payload is at least 5 bytes - // (1 byte refresh mode + 4 bytes etag, big-endian). - // If absent (len < 5), the stored etag is cleared (forces next transfer full). + // Optional new_etag tail: refresh_mode(1) + new_etag(4 BE) when len >= 5. bool hasNewEtag = (data != nullptr && len >= 5); uint32_t newEtag = 0; if (hasNewEtag) { newEtag = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) | - ((uint32_t)data[3] << 8) | ((uint32_t)data[4]); - writeSerial("Direct write end: new_etag = 0x" + String(newEtag, HEX), true); - } else { - writeSerial("Direct write end: no new_etag — clearing stored etag", true); + ((uint32_t)data[3] << 8) | (uint32_t)data[4]; } int refreshMode = REFRESH_FULL; @@ -1434,62 +1342,27 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { delay(50); cleanupDirectWriteState(false); if (refreshSuccess) { - // Store or clear the framebuffer etag. On success, the display shows the - // content just uploaded; update the etag so the next partial transfer can - // verify it. If the client did not supply a new_etag, clear the stored - // value to force the next transfer to be a full image. #ifdef TARGET_ESP32 framebuffer_etag = hasNewEtag ? newEtag : 0; - if (hasNewEtag) { - writeSerial("Framebuffer etag updated to 0x" + String(framebuffer_etag, HEX), true); - } else { - writeSerial("Framebuffer etag cleared (no new_etag supplied)", true); - } #endif uint8_t refreshResponse[] = {0x00, 0x73}; sendResponse(refreshResponse, sizeof(refreshResponse)); } else { - // Refresh failed or timed out — content on display is unknown; clear etag. #ifdef TARGET_ESP32 framebuffer_etag = 0; - writeSerial("Framebuffer etag cleared (refresh failed/timeout)", true); #endif uint8_t timeoutResponse[] = {0x00, 0x74}; sendResponse(timeoutResponse, sizeof(timeoutResponse)); } } -// --------------------------------------------------------------------------- -// 0x76 — Partial image data handler -// -// Payload: one or more segments, each with the wire format: -// x : uint16 BE -// y : uint16 BE -// width : uint16 BE -// height : uint16 BE -// flags : uint8 (bit 0: plane select; bits 1-7 reserved, masked off) -// pixels : width * height * bytes_per_pixel bytes -// -// Validation: -// - Mixed data (0x71 already received this transfer) → abort, ERR_MIXED_DATA -// - x + width > W or y + height > H → abort, ERR_SEGMENT_OOB -// -// ACK: echoes {0x00, 0x76} after every accepted packet (same as 0x71 → 0x71). -// There is no auto-end: the transfer always requires an explicit 0x72. -// --------------------------------------------------------------------------- +// 0x76 partial image data: one or more segments, each +// {x:u16BE, y:u16BE, w:u16BE, h:u16BE, flags:u8, pixels}. +// flags bit 0 = plane select (0=PLANE_0 new, 1=PLANE_1 old); bits 1-7 reserved. void handlePartialWriteData(uint8_t* data, uint16_t len) { - if (!directWriteActive) { - writeSerial("ERROR: 0x76 received but no transfer active (missing 0x70)", true); - return; - } - if (len == 0) { - writeSerial("ERROR: 0x76 received with empty payload — ignoring", true); - return; - } + if (!directWriteActive || len == 0) return; - // Reject if full-image data was already sent in this transfer. if (directWriteDataKind == DATA_KIND_FULL) { - writeSerial("ERROR: 0x76 partial data received after 0x71 full data — mixed transfer, aborting", true); #ifdef TARGET_ESP32 framebuffer_etag = 0; #endif @@ -1500,16 +1373,11 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { } directWriteDataKind = DATA_KIND_PARTIAL; - writeSerial("=== PARTIAL WRITE DATA (0x76): " + String(len) + " bytes ===", true); - int bitsPerPixel = getBitsPerPixel(); uint16_t offset = 0; while (offset < len) { - // Minimum segment header = 9 bytes (x[2] + y[2] + w[2] + h[2] + flags[1]) if ((uint16_t)(len - offset) < 9) { - writeSerial("ERROR: Incomplete segment header at offset " + String(offset) + - " (remaining=" + String(len - offset) + ") — aborting", true); #ifdef TARGET_ESP32 framebuffer_etag = 0; #endif @@ -1523,20 +1391,11 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { uint16_t segY = ((uint16_t)data[offset + 2] << 8) | data[offset + 3]; uint16_t segW = ((uint16_t)data[offset + 4] << 8) | data[offset + 5]; uint16_t segH = ((uint16_t)data[offset + 6] << 8) | data[offset + 7]; - uint8_t flags = data[offset + 8] & 0x01; // mask reserved bits 1-7 + uint8_t flags = data[offset + 8] & 0x01; offset += 9; - writeSerial("Segment: x=" + String(segX) + " y=" + String(segY) + - " w=" + String(segW) + " h=" + String(segH) + - " plane=" + String(flags & 0x01 ? 1 : 0), true); - - // Bounds check if ((uint32_t)segX + segW > directWriteWidth || (uint32_t)segY + segH > directWriteHeight) { - writeSerial("ERROR: Segment OOB (x=" + String(segX) + "+w=" + String(segW) + - " > W=" + String(directWriteWidth) + " or y=" + String(segY) + - "+h=" + String(segH) + " > H=" + String(directWriteHeight) + - ") — aborting", true); #ifdef TARGET_ESP32 framebuffer_etag = 0; #endif @@ -1546,7 +1405,6 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { return; } - // Calculate pixel data length for this segment uint32_t segPixels = (uint32_t)segW * (uint32_t)segH; uint32_t segBytes; if (bitsPerPixel == 4) segBytes = (segPixels + 1) / 2; @@ -1554,8 +1412,6 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { else segBytes = (segPixels + 7) / 8; if ((uint32_t)(len - offset) < segBytes) { - writeSerial("ERROR: Segment pixel data truncated (need=" + String(segBytes) + - " have=" + String(len - offset) + ") — aborting", true); #ifdef TARGET_ESP32 framebuffer_etag = 0; #endif @@ -1565,9 +1421,7 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { return; } - int plane = (flags & 0x01) ? PLANE_1 : PLANE_0; - writeSerial("Writing segment to plane " + String(plane), true); - + int plane = flags ? PLANE_1 : PLANE_0; bbepSetAddrWindow(&bbep, segX, segY, segW, segH); bbepStartWrite(&bbep, plane); bbepWriteData(&bbep, data + offset, (int)segBytes); @@ -1575,7 +1429,6 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { offset += (uint16_t)segBytes; } - writeSerial("Partial write data accepted (" + String(offset) + " bytes consumed)", true); uint8_t ackResponse[] = {0x00, 0x76}; sendResponse(ackResponse, sizeof(ackResponse)); } diff --git a/src/main.h b/src/main.h index 2de8c28..bc8d381 100644 --- a/src/main.h +++ b/src/main.h @@ -49,18 +49,17 @@ using namespace Adafruit_LittleFS_Namespace; #define MAX_RESPONSE_DATA_SIZE 100 // Maximum data size in response buffer // BLE response codes (second byte only, first byte is always 0x00 for success, 0xFF for error) -#define RESP_DIRECT_WRITE_START_ACK 0x70 // Direct write start acknowledgment -#define RESP_DIRECT_WRITE_DATA_ACK 0x71 // Direct write data acknowledgment -#define RESP_DIRECT_WRITE_END_ACK 0x72 // Direct write end acknowledgment +#define RESP_DIRECT_WRITE_START_ACK 0x70 // Direct write start acknowledgment +#define RESP_DIRECT_WRITE_DATA_ACK 0x71 // Direct write data acknowledgment +#define RESP_DIRECT_WRITE_END_ACK 0x72 // Direct write end acknowledgment #define RESP_DIRECT_WRITE_REFRESH_SUCCESS 0x73 // Display refresh completed successfully #define RESP_DIRECT_WRITE_REFRESH_TIMEOUT 0x74 // Display refresh timed out -#define RESP_PARTIAL_WRITE_DATA_ACK 0x76 // Partial image data acknowledgment -#define RESP_DIRECT_WRITE_ERROR 0xFF // Direct write error response -#define RESP_CONFIG_READ 0x40 // Config read response -#define RESP_CONFIG_WRITE 0x41 // Config write response -#define RESP_CONFIG_CHUNK 0x42 // Config chunk response -#define RESP_MSD_READ 0x44 // MSD (Manufacturer Specific Data) read response - +#define RESP_PARTIAL_WRITE_DATA_ACK 0x76 // Partial image data acknowledgment +#define RESP_DIRECT_WRITE_ERROR 0xFF // Direct write error response +#define RESP_CONFIG_READ 0x40 // Config read response +#define RESP_CONFIG_WRITE 0x41 // Config write response +#define RESP_CONFIG_CHUNK 0x42 // Config chunk response +#define RESP_MSD_READ 0x44 // MSD (Manufacturer Specific Data) read response // Communication mode bit definitions (for system_config.communication_modes) #define COMM_MODE_BLE (1 << 0) // Bit 0: BLE transfer supported @@ -188,11 +187,11 @@ bool directWriteBitplanes = false; // True if using bitplanes (BWR/BWY - 2 plan bool directWritePlane2 = false; // True when writing plane 2 (R/Y) for bitplanes uint32_t directWriteBytesWritten = 0; // Total bytes written to current plane uint32_t directWriteDecompressedTotal = 0; // Expected decompressed size -uint16_t directWriteWidth = 0; // Display width in pixels (authoritative for bounds-checking) -uint16_t directWriteHeight = 0; // Display height in pixels (authoritative for bounds-checking) +uint16_t directWriteWidth = 0; // Display width in pixels +uint16_t directWriteHeight = 0; // Display height in pixels uint32_t directWriteTotalBytes = 0; // Total bytes expected per plane (for bitplanes) or total (for others) uint8_t directWriteRefreshMode = 0; // 0 = FULL (default), 1 = FAST/PARTIAL (if supported) -uint8_t directWriteDataKind = DATA_KIND_NONE; // DATA_KIND_NONE/FULL/PARTIAL for this transfer +uint8_t directWriteDataKind = DATA_KIND_NONE; // 0x71 (FULL) vs 0x76 (PARTIAL) tracking for current transfer // Direct write compressed mode: use same buffer as regular image upload uint32_t directWriteCompressedSize = 0; // Total compressed size expected @@ -310,10 +309,7 @@ bool encryptionInitialized = false; RTC_DATA_ATTR bool woke_from_deep_sleep = false; RTC_DATA_ATTR uint32_t deep_sleep_count = 0; -// Framebuffer etag: 32-bit opaque identifier for the content currently on the -// display. Persists across deep sleep. 0x00000000 means "not set". -// Written by handleDirectWriteEnd() on success when the client supplies a -// new_etag; cleared on transfer abort or when no new_etag is provided. +// 0x00000000 = "not set". Persists across deep sleep. RTC_DATA_ATTR uint32_t framebuffer_etag = 0; // Advertising timeout state variables diff --git a/src/structs.h b/src/structs.h index ec9662f..b2e942d 100644 --- a/src/structs.h +++ b/src/structs.h @@ -280,20 +280,14 @@ struct SecurityConfig { #define SECURITY_FLAG_RESET_PIN_PULLUP (1 << 4) #define SECURITY_FLAG_RESET_PIN_PULLDOWN (1 << 5) -// --------------------------------------------------------------------------- -// Partial-rendering protocol constants (§1 of partial-rendering-plan.md) -// --------------------------------------------------------------------------- +// Partial-rendering NACK error codes ({0xFF, opcode, error, 0x00}). +#define ERR_ETAG_MISMATCH 0x01u +#define ERR_MIXED_DATA 0x02u +#define ERR_SEGMENT_OOB 0x03u -// Error codes for partial-rendering NACK responses: {0xFF, opcode, error, 0x00}. -// Mirror these values in py-opendisplay so the client can distinguish cases. -#define ERR_ETAG_MISMATCH 0x01u // 0x70: old_etag supplied but does not match stored etag -#define ERR_MIXED_DATA 0x02u // 0x76/0x71: mixed partial/full data in same transfer -#define ERR_SEGMENT_OOB 0x03u // 0x76: segment x+w > W or y+h > H - -// Transfer data-kind: tracks whether the current transfer has received full -// (0x71) or partial (0x76) data. Lives only for the duration of one transfer. -#define DATA_KIND_NONE 0u // No data received yet -#define DATA_KIND_FULL 1u // Full-image data (0x71) in progress -#define DATA_KIND_PARTIAL 2u // Partial-segment data (0x76) in progress +// Per-transfer data-kind tracking (0x71 vs 0x76). +#define DATA_KIND_NONE 0u +#define DATA_KIND_FULL 1u +#define DATA_KIND_PARTIAL 2u #endif \ No newline at end of file From 9d2b04d96a19ce842f49a0f53ee5ccd7fef1474d Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sat, 25 Apr 2026 23:33:13 +0200 Subject: [PATCH 07/32] =?UTF-8?q?Rename=20framebuffer=5Fetag=20=E2=86=92?= =?UTF-8?q?=20displayed=5Fetag=20and=20drop=20ESP32-only=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/display_service.cpp | 38 ++++++++++---------------------------- src/main.h | 13 ++++++++++--- 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index 1fbc9ff..dea4409 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -55,9 +55,7 @@ extern uint8_t* compressedDataBuffer; extern uint8_t dictionaryBuffer[]; extern uint8_t decompressionChunk[]; -#ifdef TARGET_ESP32 -extern uint32_t framebuffer_etag; -#endif +extern uint32_t displayed_etag; uint32_t max_compressed_image_rx_bytes(uint8_t tm) { if ((tm & TRANSMISSION_MODE_ZIP) == 0) return 0; @@ -1188,18 +1186,16 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { bool isCompressedStart = (len >= 5); bool hasOldEtag = (len == 4); -#ifdef TARGET_ESP32 if (hasOldEtag) { uint32_t oldEtag = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) | ((uint32_t)data[2] << 8) | (uint32_t)data[3]; - if (framebuffer_etag == 0 || framebuffer_etag != oldEtag) { - framebuffer_etag = 0; + if (displayed_etag == 0 || displayed_etag != oldEtag) { + displayed_etag = 0; uint8_t errResponse[] = {0xFF, 0x70, ERR_ETAG_MISMATCH, 0x00}; sendResponse(errResponse, sizeof(errResponse)); return; } } -#endif #if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) if (seeed_driver_used()) { @@ -1271,9 +1267,7 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { void handleDirectWriteData(uint8_t* data, uint16_t len) { if (!directWriteActive || len == 0) return; if (directWriteDataKind == DATA_KIND_PARTIAL) { -#ifdef TARGET_ESP32 - framebuffer_etag = 0; -#endif + displayed_etag = 0; cleanupDirectWriteState(false); uint8_t errResponse[] = {0xFF, 0x71, ERR_MIXED_DATA, 0x00}; sendResponse(errResponse, sizeof(errResponse)); @@ -1342,15 +1336,11 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { delay(50); cleanupDirectWriteState(false); if (refreshSuccess) { -#ifdef TARGET_ESP32 - framebuffer_etag = hasNewEtag ? newEtag : 0; -#endif + displayed_etag = hasNewEtag ? newEtag : 0; uint8_t refreshResponse[] = {0x00, 0x73}; sendResponse(refreshResponse, sizeof(refreshResponse)); } else { -#ifdef TARGET_ESP32 - framebuffer_etag = 0; -#endif + displayed_etag = 0; uint8_t timeoutResponse[] = {0x00, 0x74}; sendResponse(timeoutResponse, sizeof(timeoutResponse)); } @@ -1363,9 +1353,7 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { if (!directWriteActive || len == 0) return; if (directWriteDataKind == DATA_KIND_FULL) { -#ifdef TARGET_ESP32 - framebuffer_etag = 0; -#endif + displayed_etag = 0; cleanupDirectWriteState(false); uint8_t errResponse[] = {0xFF, 0x76, ERR_MIXED_DATA, 0x00}; sendResponse(errResponse, sizeof(errResponse)); @@ -1378,9 +1366,7 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { while (offset < len) { if ((uint16_t)(len - offset) < 9) { -#ifdef TARGET_ESP32 - framebuffer_etag = 0; -#endif + displayed_etag = 0; cleanupDirectWriteState(false); uint8_t errResponse[] = {0xFF, 0x76, ERR_SEGMENT_OOB, 0x00}; sendResponse(errResponse, sizeof(errResponse)); @@ -1396,9 +1382,7 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { if ((uint32_t)segX + segW > directWriteWidth || (uint32_t)segY + segH > directWriteHeight) { -#ifdef TARGET_ESP32 - framebuffer_etag = 0; -#endif + displayed_etag = 0; cleanupDirectWriteState(false); uint8_t errResponse[] = {0xFF, 0x76, ERR_SEGMENT_OOB, 0x00}; sendResponse(errResponse, sizeof(errResponse)); @@ -1412,9 +1396,7 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { else segBytes = (segPixels + 7) / 8; if ((uint32_t)(len - offset) < segBytes) { -#ifdef TARGET_ESP32 - framebuffer_etag = 0; -#endif + displayed_etag = 0; cleanupDirectWriteState(false); uint8_t errResponse[] = {0xFF, 0x76, ERR_SEGMENT_OOB, 0x00}; sendResponse(errResponse, sizeof(errResponse)); diff --git a/src/main.h b/src/main.h index bc8d381..5fa01fc 100644 --- a/src/main.h +++ b/src/main.h @@ -304,14 +304,21 @@ struct SecurityConfig securityConfig = {0}; EncryptionSession encryptionSession = {0}; bool encryptionInitialized = false; +#ifndef RTC_DATA_ATTR +// nRF52 has no equivalent of ESP32 RTC slow RAM; the variable becomes a +// regular global and resets on boot — partial-rendering then degrades to +// always-full-upload, which is the safe fallback. +#define RTC_DATA_ATTR +#endif + +// 0x00000000 = "not set". Persists across deep sleep on ESP32. +RTC_DATA_ATTR uint32_t displayed_etag = 0; + #ifdef TARGET_ESP32 // RTC memory variables for deep sleep state tracking RTC_DATA_ATTR bool woke_from_deep_sleep = false; RTC_DATA_ATTR uint32_t deep_sleep_count = 0; -// 0x00000000 = "not set". Persists across deep sleep. -RTC_DATA_ATTR uint32_t framebuffer_etag = 0; - // Advertising timeout state variables bool advertising_timeout_active = false; uint32_t advertising_start_time = 0; From cedb7d01971544627c2b38c3198d186d8f4d40bd Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Sun, 26 Apr 2026 00:13:28 +0200 Subject: [PATCH 08/32] Move partial rendering to versioned 0x76/0x77 protocol --- src/communication.cpp | 5 +- src/display_service.cpp | 100 ++++++++++++++++++++++++++++++---------- src/display_service.h | 1 + src/main.h | 8 ++-- src/structs.h | 8 +++- 5 files changed, 92 insertions(+), 30 deletions(-) diff --git a/src/communication.cpp b/src/communication.cpp index 2488d41..21f9472 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -162,7 +162,7 @@ void sendResponse(uint8_t* response, uint8_t len) { uint16_t command = (response[0] << 8) | response[1]; uint8_t status = (len >= 3) ? response[2] : 0x00; // Encrypt all authenticated responses except auth/version handshakes and FE/FF status. - // (0x0070–0x0074 direct-write/LED acks must be encrypted too — LAN/BLE clients decrypt every response.) + // Direct-write / partial-write / LED acks must be encrypted too; LAN/BLE clients decrypt every response. if (command != 0x0050 && command != 0x0043 && status != 0xFE && status != 0xFF) { uint8_t nonce[16]; uint8_t auth_tag[12]; @@ -563,6 +563,9 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin handleDirectWriteEnd(data + 2, len - 2); break; case 0x0076: + handlePartialWriteStart(data + 2, len - 2); + break; + case 0x0077: handlePartialWriteData(data + 2, len - 2); break; case 0x0073: diff --git a/src/display_service.cpp b/src/display_service.cpp index dea4409..66a656a 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -1178,24 +1178,7 @@ void cleanupDirectWriteState(bool refreshDisplay) { void handleDirectWriteStart(uint8_t* data, uint16_t len) { if (directWriteActive) cleanupDirectWriteState(false); - - // Payload disambiguation (§1.4): partial path is uncompressed only, so - // len == 0 → uncompressed, no etag - // len == 4 → uncompressed with old_etag (BE) - // len >= 5 → compressed (4-byte size + data; no etag tail) - bool isCompressedStart = (len >= 5); - bool hasOldEtag = (len == 4); - - if (hasOldEtag) { - uint32_t oldEtag = ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) | - ((uint32_t)data[2] << 8) | (uint32_t)data[3]; - if (displayed_etag == 0 || displayed_etag != oldEtag) { - displayed_etag = 0; - uint8_t errResponse[] = {0xFF, 0x70, ERR_ETAG_MISMATCH, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; - } - } + bool isCompressedStart = (len >= 4); #if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) if (seeed_driver_used()) { @@ -1264,6 +1247,67 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { sendResponse(ackResponse, sizeof(ackResponse)); } +void handlePartialWriteStart(uint8_t* data, uint16_t len) { + if (directWriteActive) cleanupDirectWriteState(false); + + if (len != 5 || data[0] != PARTIAL_WRITE_PROTOCOL_V1) { + displayed_etag = 0; + uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_VERSION, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + uint32_t oldEtag = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) | + ((uint32_t)data[3] << 8) | (uint32_t)data[4]; + if (displayed_etag == 0 || displayed_etag != oldEtag) { + displayed_etag = 0; + uint8_t errResponse[] = {0xFF, 0x76, ERR_ETAG_MISMATCH, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + +#if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) + if (seeed_driver_used()) { + seeed_gfx_prepare_hardware(); + } +#endif + uint8_t colorScheme = globalConfig.displays[0].color_scheme; + directWriteBitplanes = (colorScheme == 1 || colorScheme == 2); + directWritePlane2 = false; + directWriteCompressed = false; + directWriteWidth = globalConfig.displays[0].pixel_width; + directWriteHeight = globalConfig.displays[0].pixel_height; + uint32_t pixels = (uint32_t)directWriteWidth * (uint32_t)directWriteHeight; + if (directWriteBitplanes) directWriteTotalBytes = (pixels + 7) / 8; + else { + int bitsPerPixel = getBitsPerPixel(); + if (bitsPerPixel == 4) directWriteTotalBytes = (pixels + 1) / 2; + else if (bitsPerPixel == 2) directWriteTotalBytes = (pixels + 3) / 4; + else directWriteTotalBytes = (pixels + 7) / 8; + } + directWriteActive = true; + directWriteBytesWritten = 0; + directWriteStartTime = millis(); + if (displayPowerState) { + pwrmgm(false); + delay(50); + } + pwrmgm(true); +#if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) + if (seeed_driver_used()) { + seeed_gfx_direct_write_reset(); + } else +#endif + { + bbepInitIO(&bbep, globalConfig.displays[0].dc_pin, globalConfig.displays[0].reset_pin, globalConfig.displays[0].busy_pin, globalConfig.displays[0].cs_pin, globalConfig.displays[0].data_pin, globalConfig.displays[0].clk_pin, 8000000); + bbepWakeUp(&bbep); + bbepSendCMDSequence(&bbep, bbep.pInitFull); + } + + uint8_t ackResponse[] = {0x00, 0x76}; + sendResponse(ackResponse, sizeof(ackResponse)); +} + void handleDirectWriteData(uint8_t* data, uint16_t len) { if (!directWriteActive || len == 0) return; if (directWriteDataKind == DATA_KIND_PARTIAL) { @@ -1346,16 +1390,24 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { } } -// 0x76 partial image data: one or more segments, each +// 0x77 partial image data: one or more segments, each // {x:u16BE, y:u16BE, w:u16BE, h:u16BE, flags:u8, pixels}. // flags bit 0 = plane select (0=PLANE_0 new, 1=PLANE_1 old); bits 1-7 reserved. void handlePartialWriteData(uint8_t* data, uint16_t len) { if (!directWriteActive || len == 0) return; + if (directWriteCompressed) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x77, ERR_MIXED_DATA, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + if (directWriteDataKind == DATA_KIND_FULL) { displayed_etag = 0; cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x76, ERR_MIXED_DATA, 0x00}; + uint8_t errResponse[] = {0xFF, 0x77, ERR_MIXED_DATA, 0x00}; sendResponse(errResponse, sizeof(errResponse)); return; } @@ -1368,7 +1420,7 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { if ((uint16_t)(len - offset) < 9) { displayed_etag = 0; cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x76, ERR_SEGMENT_OOB, 0x00}; + uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; sendResponse(errResponse, sizeof(errResponse)); return; } @@ -1384,7 +1436,7 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { (uint32_t)segY + segH > directWriteHeight) { displayed_etag = 0; cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x76, ERR_SEGMENT_OOB, 0x00}; + uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; sendResponse(errResponse, sizeof(errResponse)); return; } @@ -1398,7 +1450,7 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { if ((uint32_t)(len - offset) < segBytes) { displayed_etag = 0; cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x76, ERR_SEGMENT_OOB, 0x00}; + uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; sendResponse(errResponse, sizeof(errResponse)); return; } @@ -1411,6 +1463,6 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { offset += (uint16_t)segBytes; } - uint8_t ackResponse[] = {0x00, 0x76}; + uint8_t ackResponse[] = {0x00, 0x77}; sendResponse(ackResponse, sizeof(ackResponse)); } diff --git a/src/display_service.h b/src/display_service.h index a09252e..e9978b7 100644 --- a/src/display_service.h +++ b/src/display_service.h @@ -29,6 +29,7 @@ void handleDirectWriteCompressedData(uint8_t* data, uint16_t len); void decompressDirectWriteData(); void cleanupDirectWriteState(bool refreshDisplay); void handleDirectWriteEnd(uint8_t* data, uint16_t len); +void handlePartialWriteStart(uint8_t* data, uint16_t len); void handlePartialWriteData(uint8_t* data, uint16_t len); int getplane(); int getBitsPerPixel(); diff --git a/src/main.h b/src/main.h index 5fa01fc..310f59d 100644 --- a/src/main.h +++ b/src/main.h @@ -54,7 +54,8 @@ using namespace Adafruit_LittleFS_Namespace; #define RESP_DIRECT_WRITE_END_ACK 0x72 // Direct write end acknowledgment #define RESP_DIRECT_WRITE_REFRESH_SUCCESS 0x73 // Display refresh completed successfully #define RESP_DIRECT_WRITE_REFRESH_TIMEOUT 0x74 // Display refresh timed out -#define RESP_PARTIAL_WRITE_DATA_ACK 0x76 // Partial image data acknowledgment +#define RESP_PARTIAL_WRITE_START_ACK 0x76 // Partial image start acknowledgment +#define RESP_PARTIAL_WRITE_DATA_ACK 0x77 // Partial image data acknowledgment #define RESP_DIRECT_WRITE_ERROR 0xFF // Direct write error response #define RESP_CONFIG_READ 0x40 // Config read response #define RESP_CONFIG_WRITE 0x41 // Config write response @@ -191,7 +192,7 @@ uint16_t directWriteWidth = 0; // Display width in pixels uint16_t directWriteHeight = 0; // Display height in pixels uint32_t directWriteTotalBytes = 0; // Total bytes expected per plane (for bitplanes) or total (for others) uint8_t directWriteRefreshMode = 0; // 0 = FULL (default), 1 = FAST/PARTIAL (if supported) -uint8_t directWriteDataKind = DATA_KIND_NONE; // 0x71 (FULL) vs 0x76 (PARTIAL) tracking for current transfer +uint8_t directWriteDataKind = DATA_KIND_NONE; // 0x71 (FULL) vs 0x77 (PARTIAL) tracking for current transfer // Direct write compressed mode: use same buffer as regular image upload uint32_t directWriteCompressedSize = 0; // Total compressed size expected @@ -263,6 +264,7 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len); void handleDirectWriteData(uint8_t* data, uint16_t len); void handleDirectWriteEnd(uint8_t* data = nullptr, uint16_t len = 0); void handleDirectWriteCompressedData(uint8_t* data, uint16_t len); +void handlePartialWriteStart(uint8_t* data, uint16_t len); void handlePartialWriteData(uint8_t* data, uint16_t len); int mapEpd(int id); uint8_t getFirmwareMajor(); @@ -397,4 +399,4 @@ MyBLEServerCallbacks staticServerCallbacks; // Static callback object (no dynam MyBLECharacteristicCallbacks staticCharCallbacks; // Static callback object (no dynamic allocation) #endif -extern const uint8_t writelineFont[] PROGMEM; \ No newline at end of file +extern const uint8_t writelineFont[] PROGMEM; diff --git a/src/structs.h b/src/structs.h index b2e942d..b40575a 100644 --- a/src/structs.h +++ b/src/structs.h @@ -284,10 +284,14 @@ struct SecurityConfig { #define ERR_ETAG_MISMATCH 0x01u #define ERR_MIXED_DATA 0x02u #define ERR_SEGMENT_OOB 0x03u +#define ERR_PARTIAL_VERSION 0x04u -// Per-transfer data-kind tracking (0x71 vs 0x76). +// Partial-rendering protocol. +#define PARTIAL_WRITE_PROTOCOL_V1 0x01u + +// Per-transfer data-kind tracking (0x71 vs 0x77). #define DATA_KIND_NONE 0u #define DATA_KIND_FULL 1u #define DATA_KIND_PARTIAL 2u -#endif \ No newline at end of file +#endif From 25ff7fd15c8f890c140946d29a1e1a560cbe106c Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 27 Apr 2026 00:49:25 +0200 Subject: [PATCH 09/32] Add compressed partial update segments --- README.md | 3 + docs/partial-update-protocol.md | 50 ++++++++++ src/display_service.cpp | 171 ++++++++++++++++++++++++++++---- src/structs.h | 6 ++ 4 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 docs/partial-update-protocol.md diff --git a/README.md b/README.md index 0041296..9de6ca1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ Firmware and tools for BLE (Bluetooth Low Energy) based Open Display tags. +Partial update wire details are documented in +[docs/partial-update-protocol.md](docs/partial-update-protocol.md). + ## Getting Started To quickly get started, visit the following resources: diff --git a/docs/partial-update-protocol.md b/docs/partial-update-protocol.md new file mode 100644 index 0000000..171dfcd --- /dev/null +++ b/docs/partial-update-protocol.md @@ -0,0 +1,50 @@ +# Partial Update Protocol + +Partial updates use the direct-write command family with two additional +messages. The current partial protocol version is `1`. + +## `0x76` Partial Start + +```text +[0x0076][version:1][old_etag:4 BE] +``` + +The device ACKs with `0x0076` when `old_etag` matches the image currently on +the panel. A NACK `ff 76 01 00` means the client must fall back to a full +upload. + +## `0x77` Partial Data + +Each `0x77` packet contains one or more complete segments: + +```text +[0x0077][segment...] + +segment: +x:u16BE y:u16BE width:u16BE height:u16BE flags:u8 payload:N +``` + +The geometry implies the uncompressed payload size from the active display +encoding. Segments must have `x` and `width` aligned to 8 pixels. + +Segment flags: + +```text +bit 0: plane select, 0 = PLANE_0/new image, 1 = PLANE_1/old image +bit 1: payload is one complete zlib stream +bits 2-7: reserved, must be 0 +``` + +When bit 1 is clear, `payload` is the raw packed segment bytes. When bit 1 is +set, `payload` is a zlib-compressed stream whose decompressed size must exactly +match the size implied by the segment geometry. + +Known partial NACK error codes: + +```text +0x01: etag mismatch on 0x76 +0x02: mixed full/partial data in one transfer +0x03: invalid segment, out of bounds segment, truncated segment, or malformed compressed payload +0x04: unsupported partial protocol version +0x05: segment x/width alignment error +``` diff --git a/src/display_service.cpp b/src/display_service.cpp index 66a656a..661af63 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -86,6 +86,10 @@ void bbepStartWrite(BBEPDISP *pBBEP, int iPlane); void bbepWriteData(BBEPDISP *pBBEP, uint8_t *pData, int iLen); bool bbepIsBusy(BBEPDISP *pBBEP); void flashLed(uint8_t color, uint8_t brightness); +static void clear_partial_planes_for_gdem133(void); +static bool write_partial_compressed_segment(uint8_t* data, uint16_t len, uint16_t* offset, + uint16_t segX, uint16_t segY, uint16_t segW, + uint16_t segH, uint32_t segBytes, int plane); #define AXP2101_SLAVE_ADDRESS 0x34 #define AXP2101_REG_POWER_STATUS 0x00 #define AXP2101_REG_DC_ONOFF_DVM_CTRL 0x80 @@ -1301,7 +1305,12 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { { bbepInitIO(&bbep, globalConfig.displays[0].dc_pin, globalConfig.displays[0].reset_pin, globalConfig.displays[0].busy_pin, globalConfig.displays[0].cs_pin, globalConfig.displays[0].data_pin, globalConfig.displays[0].clk_pin, 8000000); bbepWakeUp(&bbep); - bbepSendCMDSequence(&bbep, bbep.pInitFull); + if (bbep.pInitPart != NULL) { + bbepSendCMDSequence(&bbep, bbep.pInitPart); + } else { + bbepSendCMDSequence(&bbep, bbep.pInitFull); + } + clear_partial_planes_for_gdem133(); } uint8_t ackResponse[] = {0x00, 0x76}; @@ -1356,9 +1365,16 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { ((uint32_t)data[3] << 8) | (uint32_t)data[4]; } - int refreshMode = REFRESH_FULL; - if (data != nullptr && len >= 1 && data[0] == 1) refreshMode = REFRESH_FAST; - writeSerial(String("EPD refresh: ") + (refreshMode == REFRESH_FAST ? "FAST" : "FULL") + " (mode=" + String(refreshMode) + + int refreshMode = (directWriteDataKind == DATA_KIND_PARTIAL) ? REFRESH_PARTIAL : REFRESH_FULL; + if (data != nullptr && len >= 1) { + if (data[0] == 1) refreshMode = REFRESH_FAST; + else if (data[0] == 2) refreshMode = REFRESH_PARTIAL; + else refreshMode = REFRESH_FULL; + } + const char* refreshLabel = "FULL"; + if (refreshMode == REFRESH_FAST) refreshLabel = "FAST"; + else if (refreshMode == REFRESH_PARTIAL) refreshLabel = "PARTIAL"; + writeSerial(String("EPD refresh: ") + refreshLabel + " (mode=" + String(refreshMode) + ", end payload " + (data != nullptr && len > 0 ? ("0x" + String(data[0], HEX)) : String("none (auto)")) + ")", true); uint8_t ackResponse[] = {0x00, 0x72}; @@ -1375,7 +1391,9 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { { bbepRefresh(&bbep, refreshMode); refreshSuccess = waitforrefresh(60); - bbepSleep(&bbep, 1); + if (!(refreshMode == REFRESH_PARTIAL && bbep.type == GDEM133T91_960x680)) { + bbepSleep(&bbep, 1); + } } delay(50); cleanupDirectWriteState(false); @@ -1390,9 +1408,93 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { } } +static void clear_partial_planes_for_gdem133(void) { + if (bbep.type != GDEM133T91_960x680) return; + + const uint32_t total_bytes = ((uint32_t)bbep.native_width * (uint32_t)bbep.native_height) / 8; + uint8_t white[64]; + memset(white, 0xFF, sizeof(white)); + + bbepSetAddrWindow(&bbep, 0, 0, bbep.native_width, bbep.native_height); + bbepStartWrite(&bbep, PLANE_1); + for (uint32_t written = 0; written < total_bytes; written += sizeof(white)) { + uint32_t chunk = total_bytes - written; + if (chunk > sizeof(white)) chunk = sizeof(white); + bbepWriteData(&bbep, white, (int)chunk); + } + + bbepSetAddrWindow(&bbep, 0, 0, bbep.native_width, bbep.native_height); + bbepStartWrite(&bbep, PLANE_0); + for (uint32_t written = 0; written < total_bytes; written += sizeof(white)) { + uint32_t chunk = total_bytes - written; + if (chunk > sizeof(white)) chunk = sizeof(white); + bbepWriteData(&bbep, white, (int)chunk); + } +} + +static bool write_partial_compressed_segment(uint8_t* data, uint16_t len, uint16_t* offset, + uint16_t segX, uint16_t segY, uint16_t segW, + uint16_t segH, uint32_t segBytes, int plane) { + if (*offset >= len) return false; + + struct uzlib_uncomp d; + memset(&d, 0, sizeof(d)); + d.source = data + *offset; + d.source_limit = data + len; + d.source_read_cb = NULL; + + uzlib_init(); + int hdr = uzlib_zlib_parse_header(&d); + if (hdr < 0) return false; + + uint16_t window = 0x100 << hdr; + if (window > (uint16_t)(32 * 1024)) window = (uint16_t)(32 * 1024); + uzlib_uncompress_init(&d, dictionaryBuffer, window); + + bbepSetAddrWindow(&bbep, segX, segY, segW, segH); + bbepStartWrite(&bbep, plane); + + uint32_t bytesWritten = 0; + int res = TINF_OK; + while (bytesWritten < segBytes) { + uint32_t remaining = segBytes - bytesWritten; + uint32_t chunk = remaining < 4096u ? remaining : 4096u; + d.dest_start = decompressionChunk; + d.dest = decompressionChunk; + d.dest_limit = decompressionChunk + chunk; + res = uzlib_uncompress_chksum(&d); + if (res < 0) return false; + size_t bytesOut = d.dest - d.dest_start; + if (bytesOut == 0 && res != TINF_DONE) return false; + if (bytesOut > remaining) return false; + if (bytesOut > 0) { + bbepWriteData(&bbep, decompressionChunk, (int)bytesOut); + bytesWritten += (uint32_t)bytesOut; + } + if (res == TINF_DONE) break; + } + + if (bytesWritten != segBytes) return false; + + while (res != TINF_DONE) { + d.dest_start = decompressionChunk; + d.dest = decompressionChunk; + d.dest_limit = decompressionChunk + 1; + res = uzlib_uncompress_chksum(&d); + if (res < 0) return false; + if (d.dest != d.dest_start) return false; + } + + uintptr_t consumed = (uintptr_t)(d.source - data); + if (consumed <= *offset || consumed > len) return false; + *offset = (uint16_t)consumed; + return true; +} + // 0x77 partial image data: one or more segments, each -// {x:u16BE, y:u16BE, w:u16BE, h:u16BE, flags:u8, pixels}. -// flags bit 0 = plane select (0=PLANE_0 new, 1=PLANE_1 old); bits 1-7 reserved. +// {x:u16BE, y:u16BE, w:u16BE, h:u16BE, flags:u8, payload}. +// flags bit 0 = plane select (0=PLANE_0 new, 1=PLANE_1 old). +// flags bit 1 = payload is one complete zlib stream. bits 2-7 reserved. void handlePartialWriteData(uint8_t* data, uint16_t len) { if (!directWriteActive || len == 0) return; @@ -1429,9 +1531,28 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { uint16_t segY = ((uint16_t)data[offset + 2] << 8) | data[offset + 3]; uint16_t segW = ((uint16_t)data[offset + 4] << 8) | data[offset + 5]; uint16_t segH = ((uint16_t)data[offset + 6] << 8) | data[offset + 7]; - uint8_t flags = data[offset + 8] & 0x01; + uint8_t flags = data[offset + 8]; offset += 9; + if ((flags & PARTIAL_SEGMENT_FLAG_RESERVED) != 0) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + // SSD16xx-class controllers stream 8 horizontal pixels per byte and + // bbepSetAddrWindow rounds the address-window X to a byte boundary. + // Reject misaligned X/W rather than silently corrupt the segment. + if (((segX | segW) & 0x07) != 0) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_ALIGN, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + if ((uint32_t)segX + segW > directWriteWidth || (uint32_t)segY + segH > directWriteHeight) { displayed_etag = 0; @@ -1447,20 +1568,30 @@ void handlePartialWriteData(uint8_t* data, uint16_t len) { else if (bitsPerPixel == 2) segBytes = (segPixels + 3) / 4; else segBytes = (segPixels + 7) / 8; - if ((uint32_t)(len - offset) < segBytes) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; - } + int plane = (flags & PARTIAL_SEGMENT_FLAG_PLANE1) ? PLANE_1 : PLANE_0; + if ((flags & PARTIAL_SEGMENT_FLAG_COMPRESSED) != 0) { + if (!write_partial_compressed_segment(data, len, &offset, segX, segY, segW, segH, segBytes, plane)) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + } else { + if ((uint32_t)(len - offset) < segBytes) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } - int plane = flags ? PLANE_1 : PLANE_0; - bbepSetAddrWindow(&bbep, segX, segY, segW, segH); - bbepStartWrite(&bbep, plane); - bbepWriteData(&bbep, data + offset, (int)segBytes); + bbepSetAddrWindow(&bbep, segX, segY, segW, segH); + bbepStartWrite(&bbep, plane); + bbepWriteData(&bbep, data + offset, (int)segBytes); - offset += (uint16_t)segBytes; + offset += (uint16_t)segBytes; + } } uint8_t ackResponse[] = {0x00, 0x77}; diff --git a/src/structs.h b/src/structs.h index b40575a..48cf4f2 100644 --- a/src/structs.h +++ b/src/structs.h @@ -285,10 +285,16 @@ struct SecurityConfig { #define ERR_MIXED_DATA 0x02u #define ERR_SEGMENT_OOB 0x03u #define ERR_PARTIAL_VERSION 0x04u +#define ERR_SEGMENT_ALIGN 0x05u // segment x or width not aligned to 8 pixels // Partial-rendering protocol. #define PARTIAL_WRITE_PROTOCOL_V1 0x01u +// 0x77 partial segment flags. +#define PARTIAL_SEGMENT_FLAG_PLANE1 0x01u +#define PARTIAL_SEGMENT_FLAG_COMPRESSED 0x02u +#define PARTIAL_SEGMENT_FLAG_RESERVED 0xFCu + // Per-transfer data-kind tracking (0x71 vs 0x77). #define DATA_KIND_NONE 0u #define DATA_KIND_FULL 1u From 058925bafdee796ccceaf91efdfd860ad3275a8c Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 27 Apr 2026 17:20:57 +0200 Subject: [PATCH 10/32] Update for bb_epaper change --- src/display_service.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index 661af63..8ddde3b 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -193,7 +193,7 @@ int mapEpd(int id){ case 0x003F: return EP31_240x320; case 0x0040: return EP75YR_800x480; case 0x0041: return EP_PANEL_UNDEFINED; - case 0x0042: return GDEM133T91_960x680; + case 0x0042: return EP133_960x680; default: return EP_PANEL_UNDEFINED; } } @@ -1391,7 +1391,7 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { { bbepRefresh(&bbep, refreshMode); refreshSuccess = waitforrefresh(60); - if (!(refreshMode == REFRESH_PARTIAL && bbep.type == GDEM133T91_960x680)) { + if (!(refreshMode == REFRESH_PARTIAL && bbep.type == EP133_960x680)) { bbepSleep(&bbep, 1); } } @@ -1409,7 +1409,7 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { } static void clear_partial_planes_for_gdem133(void) { - if (bbep.type != GDEM133T91_960x680) return; + if (bbep.type != EP133_960x680) return; const uint32_t total_bytes = ((uint32_t)bbep.native_width * (uint32_t)bbep.native_height) / 8; uint8_t white[64]; From aab3986fc23fa938ee4481088654854eff013988 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Mon, 27 Apr 2026 17:21:10 +0200 Subject: [PATCH 11/32] GUID fixes for discovery on windows BLE stack --- src/ble_init.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ble_init.cpp b/src/ble_init.cpp index 12f66c4..32b548a 100644 --- a/src/ble_init.cpp +++ b/src/ble_init.cpp @@ -24,6 +24,7 @@ void writeSerial(String message, bool newLine = true); #include #include #include +#include String getChipIdHex(); void writeSerial(String message, bool newLine = true); @@ -107,14 +108,16 @@ void ble_init_esp32(bool update_manufacturer_data) { } pServer->setCallbacks(&staticServerCallbacks); writeSerial("Server callbacks configured"); - BLEUUID serviceUUID("00002446-0000-1000-8000-00805F9B34FB"); + // Register the OpenDisplay UUID through its 16-bit alias so the ESP32 stack + // exposes it on the standard Bluetooth base UUID consistently across clients. + BLEUUID serviceUUID((uint16_t)0x2446); pService = pServer->createService(serviceUUID); if (pService == nullptr) { writeSerial("ERROR: Failed to create BLE service"); return; } writeSerial("BLE service 0x2446 created successfully"); - BLEUUID charUUID("00002446-0000-1000-8000-00805F9B34FB"); + BLEUUID charUUID((uint16_t)0x2446); pTxCharacteristic = pService->createCharacteristic( charUUID, BLECharacteristic::PROPERTY_READ | @@ -127,6 +130,8 @@ void ble_init_esp32(bool update_manufacturer_data) { return; } writeSerial("Characteristic created with properties: READ, NOTIFY, WRITE, WRITE_NR"); + pTxCharacteristic->addDescriptor(new BLE2902()); + writeSerial("CCCD descriptor added to characteristic"); pTxCharacteristic->setCallbacks(&staticCharCallbacks); pRxCharacteristic = pTxCharacteristic; pService->start(); From da95682d28a7426929a49b73d6248e23beab321a Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 09:10:45 +0200 Subject: [PATCH 12/32] Implement streamed partial rendering --- docs/partial-update-protocol.md | 96 ++++-- platformio.ini | 4 +- src/communication.cpp | 3 - src/display_service.cpp | 556 +++++++++++++++++++++----------- src/display_service.h | 1 - src/main.h | 4 +- src/structs.h | 42 ++- 7 files changed, 472 insertions(+), 234 deletions(-) diff --git a/docs/partial-update-protocol.md b/docs/partial-update-protocol.md index 171dfcd..df82910 100644 --- a/docs/partial-update-protocol.md +++ b/docs/partial-update-protocol.md @@ -1,50 +1,96 @@ # Partial Update Protocol -Partial updates use the direct-write command family with two additional -messages. The current partial protocol version is `1`. +Partial updates use a streamed single-rectangle protocol: + +```text +0x76 PARTIAL_IMAGE_START +0x71 DATA... +0x72 END + partial refresh +``` + +Full uploads continue to use `0x70`, `0x71`, and `0x72`. `0x77` is unused. ## `0x76` Partial Start ```text -[0x0076][version:1][old_etag:4 BE] +[0x0076] +[version:1 = 0x01] +[flags:2 BE] +[old_etag:4 BE] +[x:2 BE][y:2 BE][width:2 BE][height:2 BE] +[interleave_span_pixels:2 BE] +[uncompressed_size:4 LE] +[initial_stream_bytes...] ``` -The device ACKs with `0x0076` when `old_etag` matches the image currently on -the panel. A NACK `ff 76 01 00` means the client must fall back to a full -upload. +The stream bytes are zlib bytes when `flags & 0x0004` is set, otherwise raw +logical bytes. `uncompressed_size` is always `rect_bytes * 2`. + +Flags: + +```text +bits 0..1: plane order, 0 = old PLANE_1 then new PLANE_0 +bit 2: stream is zlib-compressed +bit 3: 0x72 includes new_etag to store after successful refresh +bit 4: keep panel awake hint +bits 5..15: reserved, must be 0 +``` -## `0x77` Partial Data +The rectangle must be in bounds. `x` and `width` must be aligned to the active +packed-pixel byte boundary: 8 pixels for 1 bpp, 4 for 2 bpp, 2 for 4 bpp, and +1 for 8 bpp. -Each `0x77` packet contains one or more complete segments: +## Stream Body + +The logical stream contains both old and new rectangle images. In the default +plane order: ```text -[0x0077][segment...] +old group 0 bytes for PLANE_1 +new group 0 bytes for PLANE_0 +old group 1 bytes for PLANE_1 +new group 1 bytes for PLANE_0 +... +``` + +`interleave_span_pixels` defines each group. Clients initially use row bands, +usually `width * 8` pixels, so each group maps to a simple rectangle. + +## `0x71` Data -segment: -x:u16BE y:u16BE width:u16BE height:u16BE flags:u8 payload:N +After `0x76`, `0x71` carries the remaining partial stream bytes. It has no +partial metadata: + +```text +[0x0071][stream_bytes...] ``` -The geometry implies the uncompressed payload size from the active display -encoding. Segments must have `x` and `width` aligned to 8 pixels. +Current firmware buffers compressed partial stream bytes just like compressed +full uploads, then inflates at `0x72`. Raw partial streams are consumed as +`0x76`/`0x71` bytes arrive. + +## `0x72` End -Segment flags: +When `flags & 0x0008` was set on `0x76`, the end payload is: ```text -bit 0: plane select, 0 = PLANE_0/new image, 1 = PLANE_1/old image -bit 1: payload is one complete zlib stream -bits 2-7: reserved, must be 0 +[0x0072][refresh_mode:1][new_etag:4 BE] ``` -When bit 1 is clear, `payload` is the raw packed segment bytes. When bit 1 is -set, `payload` is a zlib-compressed stream whose decompressed size must exactly -match the size implied by the segment geometry. +Firmware validates the logical byte count and per-plane byte counts, then +refreshes with a partial-capable refresh mode. The new etag is stored only +after refresh completion. -Known partial NACK error codes: +Known partial NACK error codes use `{0xFF, opcode, error, 0x00}`: ```text -0x01: etag mismatch on 0x76 -0x02: mixed full/partial data in one transfer -0x03: invalid segment, out of bounds segment, truncated segment, or malformed compressed payload +0x01: etag mismatch +0x02: mixed full/partial data +0x03: rectangle out of bounds 0x04: unsupported partial protocol version -0x05: segment x/width alignment error +0x05: rectangle alignment error +0x06: unsupported or reserved flags +0x07: uncompressed_size mismatch +0x08: invalid interleave_span_pixels +0x09: stream byte count or content error ``` diff --git a/platformio.ini b/platformio.ini index de9cc5b..9456135 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,6 +1,6 @@ [env] -lib_deps = - https://github.com/bitbank2/bb_epaper.git +lib_deps = + symlink://../bb_epaper https://github.com/pfalcon/uzlib [env:nrf52840custom] diff --git a/src/communication.cpp b/src/communication.cpp index 21f9472..af615e0 100644 --- a/src/communication.cpp +++ b/src/communication.cpp @@ -565,9 +565,6 @@ void imageDataWritten(BLEConnHandle conn_hdl, BLECharPtr chr, uint8_t* data, uin case 0x0076: handlePartialWriteStart(data + 2, len - 2); break; - case 0x0077: - handlePartialWriteData(data + 2, len - 2); - break; case 0x0073: writeSerial("=== LED ACTIVATE COMMAND (0x0073) ==="); handleLedActivate(data + 2, len - 2); diff --git a/src/display_service.cpp b/src/display_service.cpp index 8ddde3b..ef3f2c3 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -86,10 +86,11 @@ void bbepStartWrite(BBEPDISP *pBBEP, int iPlane); void bbepWriteData(BBEPDISP *pBBEP, uint8_t *pData, int iLen); bool bbepIsBusy(BBEPDISP *pBBEP); void flashLed(uint8_t color, uint8_t brightness); -static void clear_partial_planes_for_gdem133(void); -static bool write_partial_compressed_segment(uint8_t* data, uint16_t len, uint16_t* offset, - uint16_t segX, uint16_t segY, uint16_t segW, - uint16_t segH, uint32_t segBytes, int plane); +static void clear_partial_planes_to_white(void); +static void setup_phase(void); +static bool partial_consume_bytes(uint8_t* data, uint32_t len); +static bool decompress_partial_stream(void); +static PartialStreamContext partialCtx = {}; #define AXP2101_SLAVE_ADDRESS 0x34 #define AXP2101_REG_POWER_STATUS 0x00 #define AXP2101_REG_DC_ONOFF_DVM_CTRL 0x80 @@ -1165,6 +1166,7 @@ void cleanupDirectWriteState(bool refreshDisplay) { directWriteRefreshMode = 0; directWriteStartTime = 0; directWriteDataKind = DATA_KIND_NONE; + memset(&partialCtx, 0, sizeof(partialCtx)); if (refreshDisplay && displayPowerState) { #if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) if (seeed_driver_used()) { @@ -1181,6 +1183,13 @@ void cleanupDirectWriteState(bool refreshDisplay) { } void handleDirectWriteStart(uint8_t* data, uint16_t len) { + if (partialCtx.active) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x70, ERR_MIXED_DATA, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } if (directWriteActive) cleanupDirectWriteState(false); bool isCompressedStart = (len >= 4); @@ -1254,63 +1263,169 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { void handlePartialWriteStart(uint8_t* data, uint16_t len) { if (directWriteActive) cleanupDirectWriteState(false); - if (len != 5 || data[0] != PARTIAL_WRITE_PROTOCOL_V1) { +#if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) + if (seeed_driver_used()) { + displayed_etag = 0; + uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_FLAGS, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } +#endif + + if (len < 21 || data[0] != PARTIAL_WRITE_PROTOCOL_V1) { displayed_etag = 0; uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_VERSION, 0x00}; sendResponse(errResponse, sizeof(errResponse)); return; } - uint32_t oldEtag = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) | - ((uint32_t)data[3] << 8) | (uint32_t)data[4]; - if (displayed_etag == 0 || displayed_etag != oldEtag) { + uint16_t flags = ((uint16_t)data[1] << 8) | data[2]; + uint32_t oldEtag = ((uint32_t)data[3] << 24) | ((uint32_t)data[4] << 16) | + ((uint32_t)data[5] << 8) | (uint32_t)data[6]; + uint16_t rectX = ((uint16_t)data[7] << 8) | data[8]; + uint16_t rectY = ((uint16_t)data[9] << 8) | data[10]; + uint16_t rectW = ((uint16_t)data[11] << 8) | data[12]; + uint16_t rectH = ((uint16_t)data[13] << 8) | data[14]; + uint16_t spanPx = ((uint16_t)data[15] << 8) | data[16]; + uint32_t uncompSize; + memcpy(&uncompSize, data + 17, 4); // 4-byte LE + + // Validate reserved flags (bits 5-15 must be 0; bits 0-4 are defined) + if (flags & 0xFFE0u) { + displayed_etag = 0; + uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_FLAGS, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + uint8_t planeOrder = flags & 0x03u; + if (planeOrder == 0x02u || planeOrder == 0x03u) { + displayed_etag = 0; + uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_FLAGS, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + if (oldEtag == 0 || displayed_etag == 0 || displayed_etag != oldEtag) { displayed_etag = 0; uint8_t errResponse[] = {0xFF, 0x76, ERR_ETAG_MISMATCH, 0x00}; sendResponse(errResponse, sizeof(errResponse)); return; } -#if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) - if (seeed_driver_used()) { - seeed_gfx_prepare_hardware(); + uint16_t dispW = globalConfig.displays[0].pixel_width; + uint16_t dispH = globalConfig.displays[0].pixel_height; + int bpp = getBitsPerPixel(); + uint8_t pixPerByte = (bpp == 1) ? 8u : (bpp == 2) ? 4u : (bpp == 4) ? 2u : 1u; + + if (rectW == 0 || rectH == 0 || + (uint32_t)rectX + rectW > dispW || + (uint32_t)rectY + rectH > dispH) { + displayed_etag = 0; + uint8_t errResponse[] = {0xFF, 0x76, ERR_RECT_OOB, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; } -#endif - uint8_t colorScheme = globalConfig.displays[0].color_scheme; - directWriteBitplanes = (colorScheme == 1 || colorScheme == 2); - directWritePlane2 = false; - directWriteCompressed = false; - directWriteWidth = globalConfig.displays[0].pixel_width; - directWriteHeight = globalConfig.displays[0].pixel_height; - uint32_t pixels = (uint32_t)directWriteWidth * (uint32_t)directWriteHeight; - if (directWriteBitplanes) directWriteTotalBytes = (pixels + 7) / 8; - else { - int bitsPerPixel = getBitsPerPixel(); - if (bitsPerPixel == 4) directWriteTotalBytes = (pixels + 1) / 2; - else if (bitsPerPixel == 2) directWriteTotalBytes = (pixels + 3) / 4; - else directWriteTotalBytes = (pixels + 7) / 8; + + if ((rectX % pixPerByte) != 0 || (rectW % pixPerByte) != 0) { + displayed_etag = 0; + uint8_t errResponse[] = {0xFF, 0x76, ERR_RECT_ALIGN, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; } + + if (spanPx == 0 || (spanPx % pixPerByte) != 0 || + (rectW % spanPx != 0 && spanPx % rectW != 0)) { + displayed_etag = 0; + uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_SPAN, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + uint32_t rectPixels = (uint32_t)rectW * rectH; + uint32_t rectBytes; + if (bpp == 1) rectBytes = (rectPixels + 7) / 8; + else if (bpp == 2) rectBytes = (rectPixels + 3) / 4; + else if (bpp == 4) rectBytes = (rectPixels + 1) / 2; + else rectBytes = rectPixels; + if (uncompSize != rectBytes * 2u) { + displayed_etag = 0; + uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_SIZE, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + bool isCompressed = (flags & PARTIAL_FLAG_COMPRESSED) != 0; + if (isCompressed && !compressedDataBuffer) { + displayed_etag = 0; + uint8_t errResponse[] = {0xFF, 0xFF}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + // Hardware init + directWriteWidth = dispW; + directWriteHeight = dispH; directWriteActive = true; - directWriteBytesWritten = 0; + directWriteDataKind = DATA_KIND_PARTIAL; directWriteStartTime = millis(); if (displayPowerState) { pwrmgm(false); delay(50); } pwrmgm(true); -#if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) - if (seeed_driver_used()) { - seeed_gfx_direct_write_reset(); - } else -#endif - { - bbepInitIO(&bbep, globalConfig.displays[0].dc_pin, globalConfig.displays[0].reset_pin, globalConfig.displays[0].busy_pin, globalConfig.displays[0].cs_pin, globalConfig.displays[0].data_pin, globalConfig.displays[0].clk_pin, 8000000); - bbepWakeUp(&bbep); - if (bbep.pInitPart != NULL) { - bbepSendCMDSequence(&bbep, bbep.pInitPart); + bbepInitIO(&bbep, globalConfig.displays[0].dc_pin, globalConfig.displays[0].reset_pin, + globalConfig.displays[0].busy_pin, globalConfig.displays[0].cs_pin, + globalConfig.displays[0].data_pin, globalConfig.displays[0].clk_pin, 8000000); + bbepWakeUp(&bbep); + if (bbep.pInitPart != NULL) { + bbepSendCMDSequence(&bbep, bbep.pInitPart); + } else { + bbepSendCMDSequence(&bbep, bbep.pInitFull); + } + clear_partial_planes_to_white(); + + // Initialize stream context + memset(&partialCtx, 0, sizeof(partialCtx)); + partialCtx.active = true; + partialCtx.flags = flags; + partialCtx.old_etag = oldEtag; + partialCtx.x = rectX; + partialCtx.y = rectY; + partialCtx.width = rectW; + partialCtx.height = rectH; + partialCtx.interleave_span_pixels = spanPx; + partialCtx.logical_uncompressed_size = uncompSize; + partialCtx.bits_per_pixel = (uint8_t)bpp; + partialCtx.pixels_per_byte = pixPerByte; + + if (isCompressed) { + directWriteCompressedBuffer = compressedDataBuffer; + directWriteCompressedReceived = 0; + } + + // Process optional initial stream bytes before ACK + if (len > 21) { + uint16_t initLen = len - 21; + if (isCompressed) { + uint32_t cap = max_compressed_image_rx_bytes(globalConfig.displays[0].transmission_modes); + if (cap == 0 || initLen > cap) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_STREAM, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + memcpy(directWriteCompressedBuffer, data + 21, initLen); + directWriteCompressedReceived = initLen; } else { - bbepSendCMDSequence(&bbep, bbep.pInitFull); + if (!partial_consume_bytes(data + 21, (uint32_t)initLen)) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_STREAM, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } } - clear_partial_planes_for_gdem133(); } uint8_t ackResponse[] = {0x00, 0x76}; @@ -1320,10 +1435,39 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { void handleDirectWriteData(uint8_t* data, uint16_t len) { if (!directWriteActive || len == 0) return; if (directWriteDataKind == DATA_KIND_PARTIAL) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x71, ERR_MIXED_DATA, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + bool isCompressed = (partialCtx.flags & PARTIAL_FLAG_COMPRESSED) != 0; + if (isCompressed) { + // Match compressed full-image uploads: collect the zlib stream + // across 0x76/0x71, then inflate and write at 0x72. + if (!directWriteCompressedBuffer) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0xFF}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + uint32_t cap = max_compressed_image_rx_bytes(globalConfig.displays[0].transmission_modes); + uint32_t newTotal = directWriteCompressedReceived + len; + if (cap == 0 || newTotal > cap) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x71, ERR_PARTIAL_STREAM, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + memcpy(directWriteCompressedBuffer + directWriteCompressedReceived, data, len); + directWriteCompressedReceived += len; + } else { + if (!partial_consume_bytes(data, (uint32_t)len)) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x71, ERR_PARTIAL_STREAM, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + } + uint8_t ackResponse[] = {0x00, 0x71}; + sendResponse(ackResponse, sizeof(ackResponse)); return; } directWriteDataKind = DATA_KIND_FULL; @@ -1355,6 +1499,77 @@ void handleDirectWriteData(uint8_t* data, uint16_t len) { void handleDirectWriteEnd(uint8_t* data, uint16_t len) { if (!directWriteActive) return; directWriteStartTime = 0; + + if (directWriteDataKind == DATA_KIND_PARTIAL) { + bool isCompressed = (partialCtx.flags & PARTIAL_FLAG_COMPRESSED) != 0; + bool storeEtag = (partialCtx.flags & PARTIAL_FLAG_STORE_ETAG) != 0; + + if (isCompressed && !decompress_partial_stream()) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x72, ERR_PARTIAL_STREAM, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + uint32_t rectPixels = (uint32_t)partialCtx.width * partialCtx.height; + uint32_t rectBytes; + uint8_t bpp = partialCtx.bits_per_pixel; + if (bpp == 1) rectBytes = (rectPixels + 7) / 8; + else if (bpp == 2) rectBytes = (rectPixels + 3) / 4; + else if (bpp == 4) rectBytes = (rectPixels + 1) / 2; + else rectBytes = rectPixels; + + if (partialCtx.logical_bytes_written != partialCtx.logical_uncompressed_size || + partialCtx.old_plane_bytes_written != rectBytes || + partialCtx.new_plane_bytes_written != rectBytes) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x72, ERR_PARTIAL_STREAM, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + bool hasNewEtag = storeEtag && data != nullptr && len >= 5; + uint32_t newEtag = 0; + if (hasNewEtag) { + newEtag = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) | + ((uint32_t)data[3] << 8) | (uint32_t)data[4]; + } + + // Default PARTIAL; allow FAST override; FULL → PARTIAL for partial streams + int refreshMode = REFRESH_PARTIAL; + if (data != nullptr && len >= 1 && data[0] == 1) refreshMode = REFRESH_FAST; + + writeSerial(String("EPD partial refresh: ") + + (refreshMode == REFRESH_FAST ? "FAST" : "PARTIAL") + + " (mode=" + String(refreshMode) + + ", rect=" + String(partialCtx.x) + "," + String(partialCtx.y) + + " " + String(partialCtx.width) + "x" + String(partialCtx.height) + ")", true); + uint8_t ackResponse[] = {0x00, 0x72}; + sendResponse(ackResponse, sizeof(ackResponse)); + delay(20); + + bbepRefresh(&bbep, refreshMode); + bool refreshSuccess = waitforrefresh(60); + if (!(refreshMode == REFRESH_PARTIAL && bbep.type == EP133_960x680)) { + bbepSleep(&bbep, 1); + } + delay(50); + cleanupDirectWriteState(false); + + if (refreshSuccess) { + displayed_etag = hasNewEtag ? newEtag : 0; + uint8_t refreshResponse[] = {0x00, 0x73}; + sendResponse(refreshResponse, sizeof(refreshResponse)); + } else { + displayed_etag = 0; + uint8_t timeoutResponse[] = {0x00, 0x74}; + sendResponse(timeoutResponse, sizeof(timeoutResponse)); + } + return; + } + if (directWriteCompressed && directWriteCompressedReceived > 0) decompressDirectWriteData(); // Optional new_etag tail: refresh_mode(1) + new_etag(4 BE) when len >= 5. @@ -1408,9 +1623,12 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { } } -static void clear_partial_planes_for_gdem133(void) { - if (bbep.type != EP133_960x680) return; - +static void clear_partial_planes_to_white(void) { + // bb_epaper's partial refresh path runs the panel's partial init sequence + // inside bbepRefresh(). Some panel init sequences force the RAM window + // back to the full panel, so partial refresh may touch panel memory outside + // the 0x76 dirty rectangle. Seed both planes with white first so untouched + // areas refresh as white instead of stale or uninitialized RAM. const uint32_t total_bytes = ((uint32_t)bbep.native_width * (uint32_t)bbep.native_height) / 8; uint8_t white[64]; memset(white, 0xFF, sizeof(white)); @@ -1432,168 +1650,124 @@ static void clear_partial_planes_for_gdem133(void) { } } -static bool write_partial_compressed_segment(uint8_t* data, uint16_t len, uint16_t* offset, - uint16_t segX, uint16_t segY, uint16_t segW, - uint16_t segH, uint32_t segBytes, int plane) { - if (*offset >= len) return false; - - struct uzlib_uncomp d; - memset(&d, 0, sizeof(d)); - d.source = data + *offset; - d.source_limit = data + len; - d.source_read_cb = NULL; - - uzlib_init(); - int hdr = uzlib_zlib_parse_header(&d); - if (hdr < 0) return false; - - uint16_t window = 0x100 << hdr; - if (window > (uint16_t)(32 * 1024)) window = (uint16_t)(32 * 1024); - uzlib_uncompress_init(&d, dictionaryBuffer, window); - - bbepSetAddrWindow(&bbep, segX, segY, segW, segH); - bbepStartWrite(&bbep, plane); - - uint32_t bytesWritten = 0; - int res = TINF_OK; - while (bytesWritten < segBytes) { - uint32_t remaining = segBytes - bytesWritten; - uint32_t chunk = remaining < 4096u ? remaining : 4096u; - d.dest_start = decompressionChunk; - d.dest = decompressionChunk; - d.dest_limit = decompressionChunk + chunk; - res = uzlib_uncompress_chksum(&d); - if (res < 0) return false; - size_t bytesOut = d.dest - d.dest_start; - if (bytesOut == 0 && res != TINF_DONE) return false; - if (bytesOut > remaining) return false; - if (bytesOut > 0) { - bbepWriteData(&bbep, decompressionChunk, (int)bytesOut); - bytesWritten += (uint32_t)bytesOut; - } - if (res == TINF_DONE) break; - } - - if (bytesWritten != segBytes) return false; - - while (res != TINF_DONE) { - d.dest_start = decompressionChunk; - d.dest = decompressionChunk; - d.dest_limit = decompressionChunk + 1; - res = uzlib_uncompress_chksum(&d); - if (res < 0) return false; - if (d.dest != d.dest_start) return false; - } - - uintptr_t consumed = (uintptr_t)(d.source - data); - if (consumed <= *offset || consumed > len) return false; - *offset = (uint16_t)consumed; - return true; -} - -// 0x77 partial image data: one or more segments, each -// {x:u16BE, y:u16BE, w:u16BE, h:u16BE, flags:u8, payload}. -// flags bit 0 = plane select (0=PLANE_0 new, 1=PLANE_1 old). -// flags bit 1 = payload is one complete zlib stream. bits 2-7 reserved. -void handlePartialWriteData(uint8_t* data, uint16_t len) { - if (!directWriteActive || len == 0) return; +static void setup_phase(void) { + uint32_t rect_pixels = (uint32_t)partialCtx.width * partialCtx.height; + uint32_t span_pixels = partialCtx.interleave_span_pixels; + uint32_t group_start_pixel = partialCtx.group_index * span_pixels; - if (directWriteCompressed) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x77, ERR_MIXED_DATA, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + if (group_start_pixel >= rect_pixels) { + partialCtx.bytes_remaining_in_phase = 0; return; } - if (directWriteDataKind == DATA_KIND_FULL) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x77, ERR_MIXED_DATA, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; + uint32_t group_pixels = span_pixels; + if (group_start_pixel + group_pixels > rect_pixels) + group_pixels = rect_pixels - group_start_pixel; + + uint8_t bpp = partialCtx.bits_per_pixel; + uint32_t group_bytes; + if (bpp == 1) group_bytes = (group_pixels + 7) / 8; + else if (bpp == 2) group_bytes = (group_pixels + 3) / 4; + else if (bpp == 4) group_bytes = (group_pixels + 1) / 2; + else group_bytes = group_pixels; + + uint16_t group_x, group_y, group_w, group_h; + uint16_t w = partialCtx.width; + if (span_pixels % w == 0) { + // Row-band: span covers whole rows + group_x = partialCtx.x; + group_y = partialCtx.y + (uint16_t)(group_start_pixel / w); + group_w = w; + group_h = (uint16_t)(group_pixels / w); + } else { + // Horizontal run within a row + group_x = partialCtx.x + (uint16_t)(group_start_pixel % w); + group_y = partialCtx.y + (uint16_t)(group_start_pixel / w); + group_w = (uint16_t)group_pixels; + group_h = 1; + } + + // Determine plane: default (0b00) = phase 0 → PLANE_1 (old), phase 1 → PLANE_0 (new) + // new/old (0b01) = phase 0 → PLANE_0 (new), phase 1 → PLANE_1 (old) + uint8_t planeOrder = partialCtx.flags & 0x03u; + int plane; + if (planeOrder == 0x00u) { + plane = (partialCtx.phase == 0) ? PLANE_1 : PLANE_0; + } else { + plane = (partialCtx.phase == 0) ? PLANE_0 : PLANE_1; } - directWriteDataKind = DATA_KIND_PARTIAL; - int bitsPerPixel = getBitsPerPixel(); - uint16_t offset = 0; + bbepSetAddrWindow(&bbep, group_x, group_y, group_w, group_h); + bbepStartWrite(&bbep, plane); + partialCtx.bytes_remaining_in_phase = group_bytes; +} +static bool partial_consume_bytes(uint8_t* data, uint32_t len) { + uint32_t offset = 0; while (offset < len) { - if ((uint16_t)(len - offset) < 9) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; + if (partialCtx.bytes_remaining_in_phase == 0) { + setup_phase(); + if (partialCtx.bytes_remaining_in_phase == 0) + return false; // extra bytes beyond expected stream size } - uint16_t segX = ((uint16_t)data[offset + 0] << 8) | data[offset + 1]; - uint16_t segY = ((uint16_t)data[offset + 2] << 8) | data[offset + 3]; - uint16_t segW = ((uint16_t)data[offset + 4] << 8) | data[offset + 5]; - uint16_t segH = ((uint16_t)data[offset + 6] << 8) | data[offset + 7]; - uint8_t flags = data[offset + 8]; - offset += 9; - - if ((flags & PARTIAL_SEGMENT_FLAG_RESERVED) != 0) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; - } + uint32_t can_consume = len - offset; + if (can_consume > partialCtx.bytes_remaining_in_phase) + can_consume = partialCtx.bytes_remaining_in_phase; - // SSD16xx-class controllers stream 8 horizontal pixels per byte and - // bbepSetAddrWindow rounds the address-window X to a byte boundary. - // Reject misaligned X/W rather than silently corrupt the segment. - if (((segX | segW) & 0x07) != 0) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_ALIGN, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; - } + bbepWriteData(&bbep, data + offset, (int)can_consume); - if ((uint32_t)segX + segW > directWriteWidth || - (uint32_t)segY + segH > directWriteHeight) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; - } + // Track per-plane byte counts + uint8_t planeOrder = partialCtx.flags & 0x03u; + bool is_old_phase = (planeOrder == 0x00u) ? (partialCtx.phase == 0) : (partialCtx.phase == 1); + if (is_old_phase) partialCtx.old_plane_bytes_written += can_consume; + else partialCtx.new_plane_bytes_written += can_consume; - uint32_t segPixels = (uint32_t)segW * (uint32_t)segH; - uint32_t segBytes; - if (bitsPerPixel == 4) segBytes = (segPixels + 1) / 2; - else if (bitsPerPixel == 2) segBytes = (segPixels + 3) / 4; - else segBytes = (segPixels + 7) / 8; + partialCtx.bytes_remaining_in_phase -= can_consume; + partialCtx.logical_bytes_written += can_consume; + offset += can_consume; - int plane = (flags & PARTIAL_SEGMENT_FLAG_PLANE1) ? PLANE_1 : PLANE_0; - if ((flags & PARTIAL_SEGMENT_FLAG_COMPRESSED) != 0) { - if (!write_partial_compressed_segment(data, len, &offset, segX, segY, segW, segH, segBytes, plane)) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; - } - } else { - if ((uint32_t)(len - offset) < segBytes) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x77, ERR_SEGMENT_OOB, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; + if (partialCtx.bytes_remaining_in_phase == 0) { + // Advance to next section + if (partialCtx.phase == 0) { + partialCtx.phase = 1; + } else { + partialCtx.phase = 0; + partialCtx.group_index++; } - - bbepSetAddrWindow(&bbep, segX, segY, segW, segH); - bbepStartWrite(&bbep, plane); - bbepWriteData(&bbep, data + offset, (int)segBytes); - - offset += (uint16_t)segBytes; } } + return true; +} - uint8_t ackResponse[] = {0x00, 0x77}; - sendResponse(ackResponse, sizeof(ackResponse)); +static bool decompress_partial_stream(void) { + if (!directWriteCompressedBuffer || directWriteCompressedReceived == 0) return false; + struct uzlib_uncomp d; + memset(&d, 0, sizeof(d)); + d.source = directWriteCompressedBuffer; + d.source_limit = directWriteCompressedBuffer + directWriteCompressedReceived; + d.source_read_cb = NULL; + uzlib_init(); + int hdr = uzlib_zlib_parse_header(&d); + if (hdr < 0) return false; + uint16_t window = 0x100 << hdr; + if (window > (uint16_t)(32 * 1024)) window = (uint16_t)(32 * 1024); + uzlib_uncompress_init(&d, dictionaryBuffer, window); + int res; + do { + d.dest_start = decompressionChunk; + d.dest = decompressionChunk; + d.dest_limit = decompressionChunk + 4096; + res = uzlib_uncompress_chksum(&d); + size_t bytesOut = d.dest - d.dest_start; + if (bytesOut > 0) { + if (!partial_consume_bytes(decompressionChunk, (uint32_t)bytesOut)) return false; + } + if (res < 0) return false; + if (partialCtx.logical_bytes_written > partialCtx.logical_uncompressed_size) return false; + } while (res == TINF_OK); + + return res == TINF_DONE && + partialCtx.logical_bytes_written == partialCtx.logical_uncompressed_size && + d.source == d.source_limit; } diff --git a/src/display_service.h b/src/display_service.h index e9978b7..d478fbe 100644 --- a/src/display_service.h +++ b/src/display_service.h @@ -30,7 +30,6 @@ void decompressDirectWriteData(); void cleanupDirectWriteState(bool refreshDisplay); void handleDirectWriteEnd(uint8_t* data, uint16_t len); void handlePartialWriteStart(uint8_t* data, uint16_t len); -void handlePartialWriteData(uint8_t* data, uint16_t len); int getplane(); int getBitsPerPixel(); diff --git a/src/main.h b/src/main.h index 310f59d..78ee10f 100644 --- a/src/main.h +++ b/src/main.h @@ -55,7 +55,6 @@ using namespace Adafruit_LittleFS_Namespace; #define RESP_DIRECT_WRITE_REFRESH_SUCCESS 0x73 // Display refresh completed successfully #define RESP_DIRECT_WRITE_REFRESH_TIMEOUT 0x74 // Display refresh timed out #define RESP_PARTIAL_WRITE_START_ACK 0x76 // Partial image start acknowledgment -#define RESP_PARTIAL_WRITE_DATA_ACK 0x77 // Partial image data acknowledgment #define RESP_DIRECT_WRITE_ERROR 0xFF // Direct write error response #define RESP_CONFIG_READ 0x40 // Config read response #define RESP_CONFIG_WRITE 0x41 // Config write response @@ -192,7 +191,7 @@ uint16_t directWriteWidth = 0; // Display width in pixels uint16_t directWriteHeight = 0; // Display height in pixels uint32_t directWriteTotalBytes = 0; // Total bytes expected per plane (for bitplanes) or total (for others) uint8_t directWriteRefreshMode = 0; // 0 = FULL (default), 1 = FAST/PARTIAL (if supported) -uint8_t directWriteDataKind = DATA_KIND_NONE; // 0x71 (FULL) vs 0x77 (PARTIAL) tracking for current transfer +uint8_t directWriteDataKind = DATA_KIND_NONE; // DATA_KIND_FULL (0x71 after 0x70) vs DATA_KIND_PARTIAL (0x71 after 0x76) // Direct write compressed mode: use same buffer as regular image upload uint32_t directWriteCompressedSize = 0; // Total compressed size expected @@ -265,7 +264,6 @@ void handleDirectWriteData(uint8_t* data, uint16_t len); void handleDirectWriteEnd(uint8_t* data = nullptr, uint16_t len = 0); void handleDirectWriteCompressedData(uint8_t* data, uint16_t len); void handlePartialWriteStart(uint8_t* data, uint16_t len); -void handlePartialWriteData(uint8_t* data, uint16_t len); int mapEpd(int id); uint8_t getFirmwareMajor(); uint8_t getFirmwareMinor(); diff --git a/src/structs.h b/src/structs.h index 48cf4f2..adcf024 100644 --- a/src/structs.h +++ b/src/structs.h @@ -283,21 +283,45 @@ struct SecurityConfig { // Partial-rendering NACK error codes ({0xFF, opcode, error, 0x00}). #define ERR_ETAG_MISMATCH 0x01u #define ERR_MIXED_DATA 0x02u -#define ERR_SEGMENT_OOB 0x03u +#define ERR_RECT_OOB 0x03u #define ERR_PARTIAL_VERSION 0x04u -#define ERR_SEGMENT_ALIGN 0x05u // segment x or width not aligned to 8 pixels +#define ERR_RECT_ALIGN 0x05u // rectangle x or width not aligned to byte boundary +#define ERR_PARTIAL_FLAGS 0x06u // unsupported or reserved flags set +#define ERR_PARTIAL_SIZE 0x07u // uncompressed_size does not match rectangle geometry +#define ERR_PARTIAL_SPAN 0x08u // invalid interleave_span_pixels +#define ERR_PARTIAL_STREAM 0x09u // stream byte count or content error // Partial-rendering protocol. #define PARTIAL_WRITE_PROTOCOL_V1 0x01u -// 0x77 partial segment flags. -#define PARTIAL_SEGMENT_FLAG_PLANE1 0x01u -#define PARTIAL_SEGMENT_FLAG_COMPRESSED 0x02u -#define PARTIAL_SEGMENT_FLAG_RESERVED 0xFCu +// 0x76 flags bitfield. +#define PARTIAL_FLAG_COMPRESSED 0x0004u // bit 2: stream is zlib-compressed +#define PARTIAL_FLAG_STORE_ETAG 0x0008u // bit 3: 0x72 includes new_etag; store after refresh -// Per-transfer data-kind tracking (0x71 vs 0x77). +// Per-transfer data-kind tracking. #define DATA_KIND_NONE 0u -#define DATA_KIND_FULL 1u -#define DATA_KIND_PARTIAL 2u +#define DATA_KIND_FULL 1u // 0x71 carrying full-image data after 0x70 +#define DATA_KIND_PARTIAL 2u // 0x71 carrying partial stream data after 0x76 + +// Partial stream writer state (initialized by 0x76, consumed by 0x71, committed by 0x72). +struct PartialStreamContext { + bool active; + uint16_t flags; + uint32_t old_etag; + uint16_t x; + uint16_t y; + uint16_t width; + uint16_t height; + uint16_t interleave_span_pixels; + uint32_t logical_uncompressed_size; + uint32_t logical_bytes_written; + uint32_t group_index; + uint32_t bytes_remaining_in_phase; + uint32_t old_plane_bytes_written; + uint32_t new_plane_bytes_written; + uint8_t phase; // 0 = old image (PLANE_1), 1 = new image (PLANE_0) + uint8_t bits_per_pixel; + uint8_t pixels_per_byte; +}; #endif From c807560517e08a708a731824c7b52e00d15039eb Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 13:12:06 +0200 Subject: [PATCH 13/32] Revert "GUID fixes for discovery on windows BLE stack" This reverts commit aab3986fc23fa938ee4481088654854eff013988. Was not needed in the end --- src/ble_init.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/ble_init.cpp b/src/ble_init.cpp index 32b548a..12f66c4 100644 --- a/src/ble_init.cpp +++ b/src/ble_init.cpp @@ -24,7 +24,6 @@ void writeSerial(String message, bool newLine = true); #include #include #include -#include String getChipIdHex(); void writeSerial(String message, bool newLine = true); @@ -108,16 +107,14 @@ void ble_init_esp32(bool update_manufacturer_data) { } pServer->setCallbacks(&staticServerCallbacks); writeSerial("Server callbacks configured"); - // Register the OpenDisplay UUID through its 16-bit alias so the ESP32 stack - // exposes it on the standard Bluetooth base UUID consistently across clients. - BLEUUID serviceUUID((uint16_t)0x2446); + BLEUUID serviceUUID("00002446-0000-1000-8000-00805F9B34FB"); pService = pServer->createService(serviceUUID); if (pService == nullptr) { writeSerial("ERROR: Failed to create BLE service"); return; } writeSerial("BLE service 0x2446 created successfully"); - BLEUUID charUUID((uint16_t)0x2446); + BLEUUID charUUID("00002446-0000-1000-8000-00805F9B34FB"); pTxCharacteristic = pService->createCharacteristic( charUUID, BLECharacteristic::PROPERTY_READ | @@ -130,8 +127,6 @@ void ble_init_esp32(bool update_manufacturer_data) { return; } writeSerial("Characteristic created with properties: READ, NOTIFY, WRITE, WRITE_NR"); - pTxCharacteristic->addDescriptor(new BLE2902()); - writeSerial("CCCD descriptor added to characteristic"); pTxCharacteristic->setCallbacks(&staticCharCallbacks); pRxCharacteristic = pTxCharacteristic; pService->start(); From 66ac2fe63511a5e7a487eacba146b674951c48c9 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 13:18:02 +0200 Subject: [PATCH 14/32] Use plane-major partial streams --- docs/partial-update-protocol.md | 22 +++---- src/display_service.cpp | 100 +++++++------------------------- src/structs.h | 7 +-- 3 files changed, 30 insertions(+), 99 deletions(-) diff --git a/docs/partial-update-protocol.md b/docs/partial-update-protocol.md index df82910..0e873ca 100644 --- a/docs/partial-update-protocol.md +++ b/docs/partial-update-protocol.md @@ -18,7 +18,6 @@ Full uploads continue to use `0x70`, `0x71`, and `0x72`. `0x77` is unused. [flags:2 BE] [old_etag:4 BE] [x:2 BE][y:2 BE][width:2 BE][height:2 BE] -[interleave_span_pixels:2 BE] [uncompressed_size:4 LE] [initial_stream_bytes...] ``` @@ -29,11 +28,9 @@ logical bytes. `uncompressed_size` is always `rect_bytes * 2`. Flags: ```text -bits 0..1: plane order, 0 = old PLANE_1 then new PLANE_0 bit 2: stream is zlib-compressed bit 3: 0x72 includes new_etag to store after successful refresh -bit 4: keep panel awake hint -bits 5..15: reserved, must be 0 +all other bits: reserved, must be 0 ``` The rectangle must be in bounds. `x` and `width` must be aligned to the active @@ -42,19 +39,15 @@ packed-pixel byte boundary: 8 pixels for 1 bpp, 4 for 2 bpp, 2 for 4 bpp, and ## Stream Body -The logical stream contains both old and new rectangle images. In the default -plane order: +The logical stream contains both old and new rectangle images in this order: ```text -old group 0 bytes for PLANE_1 -new group 0 bytes for PLANE_0 -old group 1 bytes for PLANE_1 -new group 1 bytes for PLANE_0 -... +old rectangle bytes for PLANE_1 +new rectangle bytes for PLANE_0 ``` -`interleave_span_pixels` defines each group. Clients initially use row bands, -usually `width * 8` pixels, so each group maps to a simple rectangle. +Firmware writes the full old rectangle first, resets the address window to the +same rectangle, then writes the full new rectangle. ## `0x71` Data @@ -91,6 +84,5 @@ Known partial NACK error codes use `{0xFF, opcode, error, 0x00}`: 0x05: rectangle alignment error 0x06: unsupported or reserved flags 0x07: uncompressed_size mismatch -0x08: invalid interleave_span_pixels -0x09: stream byte count or content error +0x08: stream byte count or content error ``` diff --git a/src/display_service.cpp b/src/display_service.cpp index ef3f2c3..2215350 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -1272,7 +1272,7 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { } #endif - if (len < 21 || data[0] != PARTIAL_WRITE_PROTOCOL_V1) { + if (len < 19 || data[0] != PARTIAL_WRITE_PROTOCOL_V1) { displayed_etag = 0; uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_VERSION, 0x00}; sendResponse(errResponse, sizeof(errResponse)); @@ -1286,19 +1286,11 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { uint16_t rectY = ((uint16_t)data[9] << 8) | data[10]; uint16_t rectW = ((uint16_t)data[11] << 8) | data[12]; uint16_t rectH = ((uint16_t)data[13] << 8) | data[14]; - uint16_t spanPx = ((uint16_t)data[15] << 8) | data[16]; uint32_t uncompSize; - memcpy(&uncompSize, data + 17, 4); // 4-byte LE + memcpy(&uncompSize, data + 15, 4); // 4-byte LE - // Validate reserved flags (bits 5-15 must be 0; bits 0-4 are defined) - if (flags & 0xFFE0u) { - displayed_etag = 0; - uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_FLAGS, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; - } - uint8_t planeOrder = flags & 0x03u; - if (planeOrder == 0x02u || planeOrder == 0x03u) { + // Validate reserved flags. Only compressed and store-etag are defined. + if (flags & ~(PARTIAL_FLAG_COMPRESSED | PARTIAL_FLAG_STORE_ETAG)) { displayed_etag = 0; uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_FLAGS, 0x00}; sendResponse(errResponse, sizeof(errResponse)); @@ -1333,14 +1325,6 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { return; } - if (spanPx == 0 || (spanPx % pixPerByte) != 0 || - (rectW % spanPx != 0 && spanPx % rectW != 0)) { - displayed_etag = 0; - uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_SPAN, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); - return; - } - uint32_t rectPixels = (uint32_t)rectW * rectH; uint32_t rectBytes; if (bpp == 1) rectBytes = (rectPixels + 7) / 8; @@ -1393,7 +1377,6 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { partialCtx.y = rectY; partialCtx.width = rectW; partialCtx.height = rectH; - partialCtx.interleave_span_pixels = spanPx; partialCtx.logical_uncompressed_size = uncompSize; partialCtx.bits_per_pixel = (uint8_t)bpp; partialCtx.pixels_per_byte = pixPerByte; @@ -1404,8 +1387,8 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { } // Process optional initial stream bytes before ACK - if (len > 21) { - uint16_t initLen = len - 21; + if (len > 19) { + uint16_t initLen = len - 19; if (isCompressed) { uint32_t cap = max_compressed_image_rx_bytes(globalConfig.displays[0].transmission_modes); if (cap == 0 || initLen > cap) { @@ -1415,10 +1398,10 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { sendResponse(errResponse, sizeof(errResponse)); return; } - memcpy(directWriteCompressedBuffer, data + 21, initLen); + memcpy(directWriteCompressedBuffer, data + 19, initLen); directWriteCompressedReceived = initLen; } else { - if (!partial_consume_bytes(data + 21, (uint32_t)initLen)) { + if (!partial_consume_bytes(data + 19, (uint32_t)initLen)) { displayed_etag = 0; cleanupDirectWriteState(false); uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_STREAM, 0x00}; @@ -1652,54 +1635,22 @@ static void clear_partial_planes_to_white(void) { static void setup_phase(void) { uint32_t rect_pixels = (uint32_t)partialCtx.width * partialCtx.height; - uint32_t span_pixels = partialCtx.interleave_span_pixels; - uint32_t group_start_pixel = partialCtx.group_index * span_pixels; + uint8_t bpp = partialCtx.bits_per_pixel; + uint32_t rect_bytes; + if (bpp == 1) rect_bytes = (rect_pixels + 7) / 8; + else if (bpp == 2) rect_bytes = (rect_pixels + 3) / 4; + else if (bpp == 4) rect_bytes = (rect_pixels + 1) / 2; + else rect_bytes = rect_pixels; - if (group_start_pixel >= rect_pixels) { + if (partialCtx.phase > 1) { partialCtx.bytes_remaining_in_phase = 0; return; } - uint32_t group_pixels = span_pixels; - if (group_start_pixel + group_pixels > rect_pixels) - group_pixels = rect_pixels - group_start_pixel; - - uint8_t bpp = partialCtx.bits_per_pixel; - uint32_t group_bytes; - if (bpp == 1) group_bytes = (group_pixels + 7) / 8; - else if (bpp == 2) group_bytes = (group_pixels + 3) / 4; - else if (bpp == 4) group_bytes = (group_pixels + 1) / 2; - else group_bytes = group_pixels; - - uint16_t group_x, group_y, group_w, group_h; - uint16_t w = partialCtx.width; - if (span_pixels % w == 0) { - // Row-band: span covers whole rows - group_x = partialCtx.x; - group_y = partialCtx.y + (uint16_t)(group_start_pixel / w); - group_w = w; - group_h = (uint16_t)(group_pixels / w); - } else { - // Horizontal run within a row - group_x = partialCtx.x + (uint16_t)(group_start_pixel % w); - group_y = partialCtx.y + (uint16_t)(group_start_pixel / w); - group_w = (uint16_t)group_pixels; - group_h = 1; - } - - // Determine plane: default (0b00) = phase 0 → PLANE_1 (old), phase 1 → PLANE_0 (new) - // new/old (0b01) = phase 0 → PLANE_0 (new), phase 1 → PLANE_1 (old) - uint8_t planeOrder = partialCtx.flags & 0x03u; - int plane; - if (planeOrder == 0x00u) { - plane = (partialCtx.phase == 0) ? PLANE_1 : PLANE_0; - } else { - plane = (partialCtx.phase == 0) ? PLANE_0 : PLANE_1; - } - - bbepSetAddrWindow(&bbep, group_x, group_y, group_w, group_h); + int plane = (partialCtx.phase == 0) ? PLANE_1 : PLANE_0; + bbepSetAddrWindow(&bbep, partialCtx.x, partialCtx.y, partialCtx.width, partialCtx.height); bbepStartWrite(&bbep, plane); - partialCtx.bytes_remaining_in_phase = group_bytes; + partialCtx.bytes_remaining_in_phase = rect_bytes; } static bool partial_consume_bytes(uint8_t* data, uint32_t len) { @@ -1717,24 +1668,15 @@ static bool partial_consume_bytes(uint8_t* data, uint32_t len) { bbepWriteData(&bbep, data + offset, (int)can_consume); - // Track per-plane byte counts - uint8_t planeOrder = partialCtx.flags & 0x03u; - bool is_old_phase = (planeOrder == 0x00u) ? (partialCtx.phase == 0) : (partialCtx.phase == 1); - if (is_old_phase) partialCtx.old_plane_bytes_written += can_consume; - else partialCtx.new_plane_bytes_written += can_consume; + if (partialCtx.phase == 0) partialCtx.old_plane_bytes_written += can_consume; + else partialCtx.new_plane_bytes_written += can_consume; partialCtx.bytes_remaining_in_phase -= can_consume; partialCtx.logical_bytes_written += can_consume; offset += can_consume; if (partialCtx.bytes_remaining_in_phase == 0) { - // Advance to next section - if (partialCtx.phase == 0) { - partialCtx.phase = 1; - } else { - partialCtx.phase = 0; - partialCtx.group_index++; - } + partialCtx.phase++; } } return true; diff --git a/src/structs.h b/src/structs.h index adcf024..29aca9d 100644 --- a/src/structs.h +++ b/src/structs.h @@ -288,8 +288,7 @@ struct SecurityConfig { #define ERR_RECT_ALIGN 0x05u // rectangle x or width not aligned to byte boundary #define ERR_PARTIAL_FLAGS 0x06u // unsupported or reserved flags set #define ERR_PARTIAL_SIZE 0x07u // uncompressed_size does not match rectangle geometry -#define ERR_PARTIAL_SPAN 0x08u // invalid interleave_span_pixels -#define ERR_PARTIAL_STREAM 0x09u // stream byte count or content error +#define ERR_PARTIAL_STREAM 0x08u // stream byte count or content error // Partial-rendering protocol. #define PARTIAL_WRITE_PROTOCOL_V1 0x01u @@ -312,14 +311,12 @@ struct PartialStreamContext { uint16_t y; uint16_t width; uint16_t height; - uint16_t interleave_span_pixels; uint32_t logical_uncompressed_size; uint32_t logical_bytes_written; - uint32_t group_index; uint32_t bytes_remaining_in_phase; uint32_t old_plane_bytes_written; uint32_t new_plane_bytes_written; - uint8_t phase; // 0 = old image (PLANE_1), 1 = new image (PLANE_0) + uint8_t phase; // 0 = old image (PLANE_1), 1 = new image (PLANE_0), 2 = complete uint8_t bits_per_pixel; uint8_t pixels_per_byte; }; From 3ce773fc5cc23517996318de0ef570de1f86ff54 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 13:38:10 +0200 Subject: [PATCH 15/32] Cleanup --- README.md | 3 -- docs/partial-update-protocol.md | 88 --------------------------------- platformio.ini | 4 +- 3 files changed, 2 insertions(+), 93 deletions(-) delete mode 100644 docs/partial-update-protocol.md diff --git a/README.md b/README.md index 9de6ca1..0041296 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,6 @@ Firmware and tools for BLE (Bluetooth Low Energy) based Open Display tags. -Partial update wire details are documented in -[docs/partial-update-protocol.md](docs/partial-update-protocol.md). - ## Getting Started To quickly get started, visit the following resources: diff --git a/docs/partial-update-protocol.md b/docs/partial-update-protocol.md deleted file mode 100644 index 0e873ca..0000000 --- a/docs/partial-update-protocol.md +++ /dev/null @@ -1,88 +0,0 @@ -# Partial Update Protocol - -Partial updates use a streamed single-rectangle protocol: - -```text -0x76 PARTIAL_IMAGE_START -0x71 DATA... -0x72 END + partial refresh -``` - -Full uploads continue to use `0x70`, `0x71`, and `0x72`. `0x77` is unused. - -## `0x76` Partial Start - -```text -[0x0076] -[version:1 = 0x01] -[flags:2 BE] -[old_etag:4 BE] -[x:2 BE][y:2 BE][width:2 BE][height:2 BE] -[uncompressed_size:4 LE] -[initial_stream_bytes...] -``` - -The stream bytes are zlib bytes when `flags & 0x0004` is set, otherwise raw -logical bytes. `uncompressed_size` is always `rect_bytes * 2`. - -Flags: - -```text -bit 2: stream is zlib-compressed -bit 3: 0x72 includes new_etag to store after successful refresh -all other bits: reserved, must be 0 -``` - -The rectangle must be in bounds. `x` and `width` must be aligned to the active -packed-pixel byte boundary: 8 pixels for 1 bpp, 4 for 2 bpp, 2 for 4 bpp, and -1 for 8 bpp. - -## Stream Body - -The logical stream contains both old and new rectangle images in this order: - -```text -old rectangle bytes for PLANE_1 -new rectangle bytes for PLANE_0 -``` - -Firmware writes the full old rectangle first, resets the address window to the -same rectangle, then writes the full new rectangle. - -## `0x71` Data - -After `0x76`, `0x71` carries the remaining partial stream bytes. It has no -partial metadata: - -```text -[0x0071][stream_bytes...] -``` - -Current firmware buffers compressed partial stream bytes just like compressed -full uploads, then inflates at `0x72`. Raw partial streams are consumed as -`0x76`/`0x71` bytes arrive. - -## `0x72` End - -When `flags & 0x0008` was set on `0x76`, the end payload is: - -```text -[0x0072][refresh_mode:1][new_etag:4 BE] -``` - -Firmware validates the logical byte count and per-plane byte counts, then -refreshes with a partial-capable refresh mode. The new etag is stored only -after refresh completion. - -Known partial NACK error codes use `{0xFF, opcode, error, 0x00}`: - -```text -0x01: etag mismatch -0x02: mixed full/partial data -0x03: rectangle out of bounds -0x04: unsupported partial protocol version -0x05: rectangle alignment error -0x06: unsupported or reserved flags -0x07: uncompressed_size mismatch -0x08: stream byte count or content error -``` diff --git a/platformio.ini b/platformio.ini index 9456135..de9cc5b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,6 +1,6 @@ [env] -lib_deps = - symlink://../bb_epaper +lib_deps = + https://github.com/bitbank2/bb_epaper.git https://github.com/pfalcon/uzlib [env:nrf52840custom] From c6c8230f77b4da49330fc41f1cfd457d9c14f68a Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 13:48:57 +0200 Subject: [PATCH 16/32] Fix partial update edge cases --- src/display_service.cpp | 59 +++++++++++++++++++++++------------------ src/structs.h | 2 -- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index 2215350..315f8a7 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -90,6 +90,8 @@ static void clear_partial_planes_to_white(void); static void setup_phase(void); static bool partial_consume_bytes(uint8_t* data, uint32_t len); static bool decompress_partial_stream(void); +static uint32_t calc_rect_bytes(uint8_t bpp, uint32_t pixels); +static bool should_sleep_after_refresh(int refreshMode); static PartialStreamContext partialCtx = {}; #define AXP2101_SLAVE_ADDRESS 0x34 #define AXP2101_REG_POWER_STATUS 0x00 @@ -1325,12 +1327,7 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { return; } - uint32_t rectPixels = (uint32_t)rectW * rectH; - uint32_t rectBytes; - if (bpp == 1) rectBytes = (rectPixels + 7) / 8; - else if (bpp == 2) rectBytes = (rectPixels + 3) / 4; - else if (bpp == 4) rectBytes = (rectPixels + 1) / 2; - else rectBytes = rectPixels; + uint32_t rectBytes = calc_rect_bytes((uint8_t)bpp, (uint32_t)rectW * rectH); if (uncompSize != rectBytes * 2u) { displayed_etag = 0; uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_SIZE, 0x00}; @@ -1372,14 +1369,12 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { memset(&partialCtx, 0, sizeof(partialCtx)); partialCtx.active = true; partialCtx.flags = flags; - partialCtx.old_etag = oldEtag; partialCtx.x = rectX; partialCtx.y = rectY; partialCtx.width = rectW; partialCtx.height = rectH; partialCtx.logical_uncompressed_size = uncompSize; partialCtx.bits_per_pixel = (uint8_t)bpp; - partialCtx.pixels_per_byte = pixPerByte; if (isCompressed) { directWriteCompressedBuffer = compressedDataBuffer; @@ -1495,13 +1490,8 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { return; } - uint32_t rectPixels = (uint32_t)partialCtx.width * partialCtx.height; - uint32_t rectBytes; - uint8_t bpp = partialCtx.bits_per_pixel; - if (bpp == 1) rectBytes = (rectPixels + 7) / 8; - else if (bpp == 2) rectBytes = (rectPixels + 3) / 4; - else if (bpp == 4) rectBytes = (rectPixels + 1) / 2; - else rectBytes = rectPixels; + uint32_t rectBytes = calc_rect_bytes(partialCtx.bits_per_pixel, + (uint32_t)partialCtx.width * partialCtx.height); if (partialCtx.logical_bytes_written != partialCtx.logical_uncompressed_size || partialCtx.old_plane_bytes_written != rectBytes || @@ -1513,7 +1503,15 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { return; } - bool hasNewEtag = storeEtag && data != nullptr && len >= 5; + if (storeEtag && (data == nullptr || len < 5)) { + displayed_etag = 0; + cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, 0x72, ERR_PARTIAL_STREAM, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); + return; + } + + bool hasNewEtag = storeEtag; uint32_t newEtag = 0; if (hasNewEtag) { newEtag = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) | @@ -1535,7 +1533,7 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { bbepRefresh(&bbep, refreshMode); bool refreshSuccess = waitforrefresh(60); - if (!(refreshMode == REFRESH_PARTIAL && bbep.type == EP133_960x680)) { + if (should_sleep_after_refresh(refreshMode)) { bbepSleep(&bbep, 1); } delay(50); @@ -1563,7 +1561,7 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { ((uint32_t)data[3] << 8) | (uint32_t)data[4]; } - int refreshMode = (directWriteDataKind == DATA_KIND_PARTIAL) ? REFRESH_PARTIAL : REFRESH_FULL; + int refreshMode = REFRESH_FULL; if (data != nullptr && len >= 1) { if (data[0] == 1) refreshMode = REFRESH_FAST; else if (data[0] == 2) refreshMode = REFRESH_PARTIAL; @@ -1589,7 +1587,7 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { { bbepRefresh(&bbep, refreshMode); refreshSuccess = waitforrefresh(60); - if (!(refreshMode == REFRESH_PARTIAL && bbep.type == EP133_960x680)) { + if (should_sleep_after_refresh(refreshMode)) { bbepSleep(&bbep, 1); } } @@ -1612,6 +1610,7 @@ static void clear_partial_planes_to_white(void) { // back to the full panel, so partial refresh may touch panel memory outside // the 0x76 dirty rectangle. Seed both planes with white first so untouched // areas refresh as white instead of stale or uninitialized RAM. + // This is controller RAM, not logical image bpp: each controller plane is 1 bpp. const uint32_t total_bytes = ((uint32_t)bbep.native_width * (uint32_t)bbep.native_height) / 8; uint8_t white[64]; memset(white, 0xFF, sizeof(white)); @@ -1634,13 +1633,8 @@ static void clear_partial_planes_to_white(void) { } static void setup_phase(void) { - uint32_t rect_pixels = (uint32_t)partialCtx.width * partialCtx.height; - uint8_t bpp = partialCtx.bits_per_pixel; - uint32_t rect_bytes; - if (bpp == 1) rect_bytes = (rect_pixels + 7) / 8; - else if (bpp == 2) rect_bytes = (rect_pixels + 3) / 4; - else if (bpp == 4) rect_bytes = (rect_pixels + 1) / 2; - else rect_bytes = rect_pixels; + uint32_t rect_bytes = calc_rect_bytes(partialCtx.bits_per_pixel, + (uint32_t)partialCtx.width * partialCtx.height); if (partialCtx.phase > 1) { partialCtx.bytes_remaining_in_phase = 0; @@ -1713,3 +1707,16 @@ static bool decompress_partial_stream(void) { partialCtx.logical_bytes_written == partialCtx.logical_uncompressed_size && d.source == d.source_limit; } + +static uint32_t calc_rect_bytes(uint8_t bpp, uint32_t pixels) { + if (bpp == 1) return (pixels + 7) / 8; + if (bpp == 2) return (pixels + 3) / 4; + if (bpp == 4) return (pixels + 1) / 2; + return pixels; +} + +static bool should_sleep_after_refresh(int refreshMode) { + // EP133 loses its partial-refresh state if it is slept immediately after a + // partial refresh; keep it awake so the next delta can be applied. + return !(refreshMode == REFRESH_PARTIAL && bbep.type == EP133_960x680); +} diff --git a/src/structs.h b/src/structs.h index 29aca9d..ae60578 100644 --- a/src/structs.h +++ b/src/structs.h @@ -306,7 +306,6 @@ struct SecurityConfig { struct PartialStreamContext { bool active; uint16_t flags; - uint32_t old_etag; uint16_t x; uint16_t y; uint16_t width; @@ -318,7 +317,6 @@ struct PartialStreamContext { uint32_t new_plane_bytes_written; uint8_t phase; // 0 = old image (PLANE_1), 1 = new image (PLANE_0), 2 = complete uint8_t bits_per_pixel; - uint8_t pixels_per_byte; }; #endif From 86e10a1bed421aa5bc74f4d26a4027ffd9a3bf27 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 13:51:04 +0200 Subject: [PATCH 17/32] Trim partial update boilerplate --- src/display_service.cpp | 76 +++++++++++++---------------------------- src/main.h | 1 - 2 files changed, 23 insertions(+), 54 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index 315f8a7..03f9771 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -92,6 +92,7 @@ static bool partial_consume_bytes(uint8_t* data, uint32_t len); static bool decompress_partial_stream(void); static uint32_t calc_rect_bytes(uint8_t bpp, uint32_t pixels); static bool should_sleep_after_refresh(int refreshMode); +static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState); static PartialStreamContext partialCtx = {}; #define AXP2101_SLAVE_ADDRESS 0x34 #define AXP2101_REG_POWER_STATUS 0x00 @@ -1186,10 +1187,7 @@ void cleanupDirectWriteState(bool refreshDisplay) { void handleDirectWriteStart(uint8_t* data, uint16_t len) { if (partialCtx.active) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x70, ERR_MIXED_DATA, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x70, ERR_MIXED_DATA, true); return; } if (directWriteActive) cleanupDirectWriteState(false); @@ -1267,17 +1265,13 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { #if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) if (seeed_driver_used()) { - displayed_etag = 0; - uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_FLAGS, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x76, ERR_PARTIAL_FLAGS, false); return; } #endif if (len < 19 || data[0] != PARTIAL_WRITE_PROTOCOL_V1) { - displayed_etag = 0; - uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_VERSION, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x76, ERR_PARTIAL_VERSION, false); return; } @@ -1293,16 +1287,12 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { // Validate reserved flags. Only compressed and store-etag are defined. if (flags & ~(PARTIAL_FLAG_COMPRESSED | PARTIAL_FLAG_STORE_ETAG)) { - displayed_etag = 0; - uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_FLAGS, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x76, ERR_PARTIAL_FLAGS, false); return; } if (oldEtag == 0 || displayed_etag == 0 || displayed_etag != oldEtag) { - displayed_etag = 0; - uint8_t errResponse[] = {0xFF, 0x76, ERR_ETAG_MISMATCH, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x76, ERR_ETAG_MISMATCH, false); return; } @@ -1314,24 +1304,18 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { if (rectW == 0 || rectH == 0 || (uint32_t)rectX + rectW > dispW || (uint32_t)rectY + rectH > dispH) { - displayed_etag = 0; - uint8_t errResponse[] = {0xFF, 0x76, ERR_RECT_OOB, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x76, ERR_RECT_OOB, false); return; } if ((rectX % pixPerByte) != 0 || (rectW % pixPerByte) != 0) { - displayed_etag = 0; - uint8_t errResponse[] = {0xFF, 0x76, ERR_RECT_ALIGN, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x76, ERR_RECT_ALIGN, false); return; } uint32_t rectBytes = calc_rect_bytes((uint8_t)bpp, (uint32_t)rectW * rectH); if (uncompSize != rectBytes * 2u) { - displayed_etag = 0; - uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_SIZE, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x76, ERR_PARTIAL_SIZE, false); return; } @@ -1387,20 +1371,14 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { if (isCompressed) { uint32_t cap = max_compressed_image_rx_bytes(globalConfig.displays[0].transmission_modes); if (cap == 0 || initLen > cap) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_STREAM, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x76, ERR_PARTIAL_STREAM, true); return; } memcpy(directWriteCompressedBuffer, data + 19, initLen); directWriteCompressedReceived = initLen; } else { if (!partial_consume_bytes(data + 19, (uint32_t)initLen)) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x76, ERR_PARTIAL_STREAM, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x76, ERR_PARTIAL_STREAM, true); return; } } @@ -1427,20 +1405,14 @@ void handleDirectWriteData(uint8_t* data, uint16_t len) { uint32_t cap = max_compressed_image_rx_bytes(globalConfig.displays[0].transmission_modes); uint32_t newTotal = directWriteCompressedReceived + len; if (cap == 0 || newTotal > cap) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x71, ERR_PARTIAL_STREAM, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x71, ERR_PARTIAL_STREAM, true); return; } memcpy(directWriteCompressedBuffer + directWriteCompressedReceived, data, len); directWriteCompressedReceived += len; } else { if (!partial_consume_bytes(data, (uint32_t)len)) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x71, ERR_PARTIAL_STREAM, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x71, ERR_PARTIAL_STREAM, true); return; } } @@ -1483,10 +1455,7 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { bool storeEtag = (partialCtx.flags & PARTIAL_FLAG_STORE_ETAG) != 0; if (isCompressed && !decompress_partial_stream()) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x72, ERR_PARTIAL_STREAM, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); return; } @@ -1496,18 +1465,12 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { if (partialCtx.logical_bytes_written != partialCtx.logical_uncompressed_size || partialCtx.old_plane_bytes_written != rectBytes || partialCtx.new_plane_bytes_written != rectBytes) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x72, ERR_PARTIAL_STREAM, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); return; } if (storeEtag && (data == nullptr || len < 5)) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0x72, ERR_PARTIAL_STREAM, 0x00}; - sendResponse(errResponse, sizeof(errResponse)); + send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); return; } @@ -1720,3 +1683,10 @@ static bool should_sleep_after_refresh(int refreshMode) { // partial refresh; keep it awake so the next delta can be applied. return !(refreshMode == REFRESH_PARTIAL && bbep.type == EP133_960x680); } + +static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState) { + displayed_etag = 0; + if (cleanupState) cleanupDirectWriteState(false); + uint8_t errResponse[] = {0xFF, opcode, error, 0x00}; + sendResponse(errResponse, sizeof(errResponse)); +} diff --git a/src/main.h b/src/main.h index 78ee10f..add74e0 100644 --- a/src/main.h +++ b/src/main.h @@ -54,7 +54,6 @@ using namespace Adafruit_LittleFS_Namespace; #define RESP_DIRECT_WRITE_END_ACK 0x72 // Direct write end acknowledgment #define RESP_DIRECT_WRITE_REFRESH_SUCCESS 0x73 // Display refresh completed successfully #define RESP_DIRECT_WRITE_REFRESH_TIMEOUT 0x74 // Display refresh timed out -#define RESP_PARTIAL_WRITE_START_ACK 0x76 // Partial image start acknowledgment #define RESP_DIRECT_WRITE_ERROR 0xFF // Direct write error response #define RESP_CONFIG_READ 0x40 // Config read response #define RESP_CONFIG_WRITE 0x41 // Config write response From bc94f62f8cb1948e8a467a3eadea388328233737 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 13:53:26 +0200 Subject: [PATCH 18/32] Keep partial protocol state private --- src/display_service.cpp | 33 +++++++++++++++++++++++++++++++++ src/main.h | 2 +- src/structs.h | 41 +---------------------------------------- 3 files changed, 35 insertions(+), 41 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index 03f9771..21ce008 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -57,6 +57,39 @@ extern uint8_t decompressionChunk[]; extern uint32_t displayed_etag; +static const uint8_t DATA_KIND_NONE = 0u; +static const uint8_t DATA_KIND_FULL = 1u; +static const uint8_t DATA_KIND_PARTIAL = 2u; + +static const uint8_t ERR_ETAG_MISMATCH = 0x01u; +static const uint8_t ERR_MIXED_DATA = 0x02u; +static const uint8_t ERR_RECT_OOB = 0x03u; +static const uint8_t ERR_PARTIAL_VERSION = 0x04u; +static const uint8_t ERR_RECT_ALIGN = 0x05u; +static const uint8_t ERR_PARTIAL_FLAGS = 0x06u; +static const uint8_t ERR_PARTIAL_SIZE = 0x07u; +static const uint8_t ERR_PARTIAL_STREAM = 0x08u; + +static const uint8_t PARTIAL_WRITE_PROTOCOL_V1 = 0x01u; +static const uint16_t PARTIAL_FLAG_COMPRESSED = 0x0004u; +static const uint16_t PARTIAL_FLAG_STORE_ETAG = 0x0008u; + +struct PartialStreamContext { + bool active; + uint16_t flags; + uint16_t x; + uint16_t y; + uint16_t width; + uint16_t height; + uint32_t logical_uncompressed_size; + uint32_t logical_bytes_written; + uint32_t bytes_remaining_in_phase; + uint32_t old_plane_bytes_written; + uint32_t new_plane_bytes_written; + uint8_t phase; // 0 = old image (PLANE_1), 1 = new image (PLANE_0), 2 = complete + uint8_t bits_per_pixel; +}; + uint32_t max_compressed_image_rx_bytes(uint8_t tm) { if ((tm & TRANSMISSION_MODE_ZIP) == 0) return 0; if ((tm & TRANSMISSION_MODE_ZIPXL) != 0 && diff --git a/src/main.h b/src/main.h index add74e0..3a3199e 100644 --- a/src/main.h +++ b/src/main.h @@ -190,7 +190,7 @@ uint16_t directWriteWidth = 0; // Display width in pixels uint16_t directWriteHeight = 0; // Display height in pixels uint32_t directWriteTotalBytes = 0; // Total bytes expected per plane (for bitplanes) or total (for others) uint8_t directWriteRefreshMode = 0; // 0 = FULL (default), 1 = FAST/PARTIAL (if supported) -uint8_t directWriteDataKind = DATA_KIND_NONE; // DATA_KIND_FULL (0x71 after 0x70) vs DATA_KIND_PARTIAL (0x71 after 0x76) +uint8_t directWriteDataKind = 0; // none; display_service.cpp tracks full vs partial 0x71 streams // Direct write compressed mode: use same buffer as regular image upload uint32_t directWriteCompressedSize = 0; // Total compressed size expected diff --git a/src/structs.h b/src/structs.h index ae60578..45f165e 100644 --- a/src/structs.h +++ b/src/structs.h @@ -280,43 +280,4 @@ struct SecurityConfig { #define SECURITY_FLAG_RESET_PIN_PULLUP (1 << 4) #define SECURITY_FLAG_RESET_PIN_PULLDOWN (1 << 5) -// Partial-rendering NACK error codes ({0xFF, opcode, error, 0x00}). -#define ERR_ETAG_MISMATCH 0x01u -#define ERR_MIXED_DATA 0x02u -#define ERR_RECT_OOB 0x03u -#define ERR_PARTIAL_VERSION 0x04u -#define ERR_RECT_ALIGN 0x05u // rectangle x or width not aligned to byte boundary -#define ERR_PARTIAL_FLAGS 0x06u // unsupported or reserved flags set -#define ERR_PARTIAL_SIZE 0x07u // uncompressed_size does not match rectangle geometry -#define ERR_PARTIAL_STREAM 0x08u // stream byte count or content error - -// Partial-rendering protocol. -#define PARTIAL_WRITE_PROTOCOL_V1 0x01u - -// 0x76 flags bitfield. -#define PARTIAL_FLAG_COMPRESSED 0x0004u // bit 2: stream is zlib-compressed -#define PARTIAL_FLAG_STORE_ETAG 0x0008u // bit 3: 0x72 includes new_etag; store after refresh - -// Per-transfer data-kind tracking. -#define DATA_KIND_NONE 0u -#define DATA_KIND_FULL 1u // 0x71 carrying full-image data after 0x70 -#define DATA_KIND_PARTIAL 2u // 0x71 carrying partial stream data after 0x76 - -// Partial stream writer state (initialized by 0x76, consumed by 0x71, committed by 0x72). -struct PartialStreamContext { - bool active; - uint16_t flags; - uint16_t x; - uint16_t y; - uint16_t width; - uint16_t height; - uint32_t logical_uncompressed_size; - uint32_t logical_bytes_written; - uint32_t bytes_remaining_in_phase; - uint32_t old_plane_bytes_written; - uint32_t new_plane_bytes_written; - uint8_t phase; // 0 = old image (PLANE_1), 1 = new image (PLANE_0), 2 = complete - uint8_t bits_per_pixel; -}; - -#endif +#endif \ No newline at end of file From 677ae9801d913e5bc60baa79788b506e480d238e Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 13:57:57 +0200 Subject: [PATCH 19/32] Fix partial plane byte counts --- src/display_service.cpp | 41 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index 21ce008..4c28ed5 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -81,13 +81,12 @@ struct PartialStreamContext { uint16_t y; uint16_t width; uint16_t height; - uint32_t logical_uncompressed_size; + uint32_t expected_stream_size; uint32_t logical_bytes_written; uint32_t bytes_remaining_in_phase; uint32_t old_plane_bytes_written; uint32_t new_plane_bytes_written; uint8_t phase; // 0 = old image (PLANE_1), 1 = new image (PLANE_0), 2 = complete - uint8_t bits_per_pixel; }; uint32_t max_compressed_image_rx_bytes(uint8_t tm) { @@ -123,7 +122,7 @@ static void clear_partial_planes_to_white(void); static void setup_phase(void); static bool partial_consume_bytes(uint8_t* data, uint32_t len); static bool decompress_partial_stream(void); -static uint32_t calc_rect_bytes(uint8_t bpp, uint32_t pixels); +static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height); static bool should_sleep_after_refresh(int refreshMode); static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState); static PartialStreamContext partialCtx = {}; @@ -1331,8 +1330,6 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { uint16_t dispW = globalConfig.displays[0].pixel_width; uint16_t dispH = globalConfig.displays[0].pixel_height; - int bpp = getBitsPerPixel(); - uint8_t pixPerByte = (bpp == 1) ? 8u : (bpp == 2) ? 4u : (bpp == 4) ? 2u : 1u; if (rectW == 0 || rectH == 0 || (uint32_t)rectX + rectW > dispW || @@ -1341,13 +1338,13 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { return; } - if ((rectX % pixPerByte) != 0 || (rectW % pixPerByte) != 0) { + if ((rectX & 7u) != 0 || (rectW & 7u) != 0) { send_direct_write_nack(0x76, ERR_RECT_ALIGN, false); return; } - uint32_t rectBytes = calc_rect_bytes((uint8_t)bpp, (uint32_t)rectW * rectH); - if (uncompSize != rectBytes * 2u) { + uint32_t planeBytes = calc_controller_plane_bytes(rectW, rectH); + if (uncompSize != planeBytes * 2u) { send_direct_write_nack(0x76, ERR_PARTIAL_SIZE, false); return; } @@ -1390,8 +1387,7 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { partialCtx.y = rectY; partialCtx.width = rectW; partialCtx.height = rectH; - partialCtx.logical_uncompressed_size = uncompSize; - partialCtx.bits_per_pixel = (uint8_t)bpp; + partialCtx.expected_stream_size = uncompSize; if (isCompressed) { directWriteCompressedBuffer = compressedDataBuffer; @@ -1492,12 +1488,11 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { return; } - uint32_t rectBytes = calc_rect_bytes(partialCtx.bits_per_pixel, - (uint32_t)partialCtx.width * partialCtx.height); + uint32_t planeBytes = calc_controller_plane_bytes(partialCtx.width, partialCtx.height); - if (partialCtx.logical_bytes_written != partialCtx.logical_uncompressed_size || - partialCtx.old_plane_bytes_written != rectBytes || - partialCtx.new_plane_bytes_written != rectBytes) { + if (partialCtx.logical_bytes_written != partialCtx.expected_stream_size || + partialCtx.old_plane_bytes_written != planeBytes || + partialCtx.new_plane_bytes_written != planeBytes) { send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); return; } @@ -1629,8 +1624,7 @@ static void clear_partial_planes_to_white(void) { } static void setup_phase(void) { - uint32_t rect_bytes = calc_rect_bytes(partialCtx.bits_per_pixel, - (uint32_t)partialCtx.width * partialCtx.height); + uint32_t plane_bytes = calc_controller_plane_bytes(partialCtx.width, partialCtx.height); if (partialCtx.phase > 1) { partialCtx.bytes_remaining_in_phase = 0; @@ -1640,7 +1634,7 @@ static void setup_phase(void) { int plane = (partialCtx.phase == 0) ? PLANE_1 : PLANE_0; bbepSetAddrWindow(&bbep, partialCtx.x, partialCtx.y, partialCtx.width, partialCtx.height); bbepStartWrite(&bbep, plane); - partialCtx.bytes_remaining_in_phase = rect_bytes; + partialCtx.bytes_remaining_in_phase = plane_bytes; } static bool partial_consume_bytes(uint8_t* data, uint32_t len) { @@ -1696,19 +1690,16 @@ static bool decompress_partial_stream(void) { if (!partial_consume_bytes(decompressionChunk, (uint32_t)bytesOut)) return false; } if (res < 0) return false; - if (partialCtx.logical_bytes_written > partialCtx.logical_uncompressed_size) return false; + if (partialCtx.logical_bytes_written > partialCtx.expected_stream_size) return false; } while (res == TINF_OK); return res == TINF_DONE && - partialCtx.logical_bytes_written == partialCtx.logical_uncompressed_size && + partialCtx.logical_bytes_written == partialCtx.expected_stream_size && d.source == d.source_limit; } -static uint32_t calc_rect_bytes(uint8_t bpp, uint32_t pixels) { - if (bpp == 1) return (pixels + 7) / 8; - if (bpp == 2) return (pixels + 3) / 4; - if (bpp == 4) return (pixels + 1) / 2; - return pixels; +static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height) { + return ((uint32_t)(width + 7u) / 8u) * height; } static bool should_sleep_after_refresh(int refreshMode) { From f0c988c1cb620c4b084cc6f405bf8e1499f38e80 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 14:01:52 +0200 Subject: [PATCH 20/32] Reject unsupported partial bpp --- src/display_service.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/display_service.cpp b/src/display_service.cpp index 4c28ed5..d881ef5 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -69,6 +69,7 @@ static const uint8_t ERR_RECT_ALIGN = 0x05u; static const uint8_t ERR_PARTIAL_FLAGS = 0x06u; static const uint8_t ERR_PARTIAL_SIZE = 0x07u; static const uint8_t ERR_PARTIAL_STREAM = 0x08u; +static const uint8_t ERR_PARTIAL_UNSUPPORTED = 0x09u; static const uint8_t PARTIAL_WRITE_PROTOCOL_V1 = 0x01u; static const uint16_t PARTIAL_FLAG_COMPRESSED = 0x0004u; @@ -1330,6 +1331,13 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { uint16_t dispW = globalConfig.displays[0].pixel_width; uint16_t dispH = globalConfig.displays[0].pixel_height; + if (getBitsPerPixel() != 1) { + // bb_epaper partial refresh support is effectively non-existent for + // 2bpp+ panels, and physical panels may not support that mode either. + // This protocol uses two 1bpp controller planes as old/new image memory. + send_direct_write_nack(0x76, ERR_PARTIAL_UNSUPPORTED, false); + return; + } if (rectW == 0 || rectH == 0 || (uint32_t)rectX + rectW > dispW || From 0c4f37196b8b7268ec25cd1d0b4536bacb22a6d5 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 14:20:10 +0200 Subject: [PATCH 21/32] Document direct write streaming paths --- src/display_service.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index d881ef5..b128ed2 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -1130,6 +1130,8 @@ void updatemsdata(){ } void handleDirectWriteCompressedData(uint8_t* data, uint16_t len) { + // Compressed full-image 0x71 chunks are buffered as compressed bytes. + // uzlib is run once at 0x72, so we never keep a decompressed full image in RAM. if (!compressedDataBuffer) { cleanupDirectWriteState(false); uint8_t errorResponse[] = {0xFF, 0xFF}; @@ -1153,6 +1155,8 @@ void handleDirectWriteCompressedData(uint8_t* data, uint16_t len) { } void decompressDirectWriteData() { + // Inflate the buffered compressed full-image stream and write each output + // chunk directly to the active panel write window. if (directWriteCompressedReceived == 0) return; struct uzlib_uncomp d; memset(&d, 0, sizeof(d)); @@ -1431,7 +1435,7 @@ void handleDirectWriteData(uint8_t* data, uint16_t len) { bool isCompressed = (partialCtx.flags & PARTIAL_FLAG_COMPRESSED) != 0; if (isCompressed) { // Match compressed full-image uploads: collect the zlib stream - // across 0x76/0x71, then inflate and write at 0x72. + // across 0x76/0x71, then inflate at 0x72 through the partial sink. if (!directWriteCompressedBuffer) { displayed_etag = 0; cleanupDirectWriteState(false); @@ -1448,6 +1452,8 @@ void handleDirectWriteData(uint8_t* data, uint16_t len) { memcpy(directWriteCompressedBuffer + directWriteCompressedReceived, data, len); directWriteCompressedReceived += len; } else { + // Raw partial 0x71 data is already panel-plane data, so stream it + // immediately and let partial_consume_bytes() retarget planes. if (!partial_consume_bytes(data, (uint32_t)len)) { send_direct_write_nack(0x71, ERR_PARTIAL_STREAM, true); return; @@ -1462,6 +1468,8 @@ void handleDirectWriteData(uint8_t* data, uint16_t len) { handleDirectWriteCompressedData(data, len); return; } + // Raw full-image 0x71 data streams directly to the panel; only compressed + // mode needs the intermediate compressed buffer. uint32_t remainingBytes = (directWriteBytesWritten < directWriteTotalBytes) ? (directWriteTotalBytes - directWriteBytesWritten) : 0; uint16_t bytesToWrite = (len > remainingBytes) ? remainingBytes : len; if (bytesToWrite > 0) { @@ -1646,6 +1654,10 @@ static void setup_phase(void) { } static bool partial_consume_bytes(uint8_t* data, uint32_t len) { + // Partial streams are two controller-plane images concatenated: + // old image to PLANE_1, then new image to PLANE_0. This sink accepts raw + // or decompressed bytes, writes as much as fits in the current plane, then + // opens the same rectangle on the next plane and continues with no buffering. uint32_t offset = 0; while (offset < len) { if (partialCtx.bytes_remaining_in_phase == 0) { @@ -1675,6 +1687,9 @@ static bool partial_consume_bytes(uint8_t* data, uint32_t len) { } static bool decompress_partial_stream(void) { + // Like decompressDirectWriteData(), compressed partial uploads are inflated + // only at 0x72. The output chunks go through partial_consume_bytes() so the + // old/new plane boundary can retarget the panel write window mid-stream. if (!directWriteCompressedBuffer || directWriteCompressedReceived == 0) return false; struct uzlib_uncomp d; memset(&d, 0, sizeof(d)); From b5f705b79a799d6c3ce921f61b7e911c83a934cc Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 14:21:08 +0200 Subject: [PATCH 22/32] Remove panel-specific sleep guard --- src/display_service.cpp | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index b128ed2..a20e94f 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -124,7 +124,6 @@ static void setup_phase(void); static bool partial_consume_bytes(uint8_t* data, uint32_t len); static bool decompress_partial_stream(void); static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height); -static bool should_sleep_after_refresh(int refreshMode); static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState); static PartialStreamContext partialCtx = {}; #define AXP2101_SLAVE_ADDRESS 0x34 @@ -1540,9 +1539,7 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { bbepRefresh(&bbep, refreshMode); bool refreshSuccess = waitforrefresh(60); - if (should_sleep_after_refresh(refreshMode)) { - bbepSleep(&bbep, 1); - } + bbepSleep(&bbep, 1); delay(50); cleanupDirectWriteState(false); @@ -1594,9 +1591,7 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { { bbepRefresh(&bbep, refreshMode); refreshSuccess = waitforrefresh(60); - if (should_sleep_after_refresh(refreshMode)) { - bbepSleep(&bbep, 1); - } + bbepSleep(&bbep, 1); } delay(50); cleanupDirectWriteState(false); @@ -1725,12 +1720,6 @@ static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height) { return ((uint32_t)(width + 7u) / 8u) * height; } -static bool should_sleep_after_refresh(int refreshMode) { - // EP133 loses its partial-refresh state if it is slept immediately after a - // partial refresh; keep it awake so the next delta can be applied. - return !(refreshMode == REFRESH_PARTIAL && bbep.type == EP133_960x680); -} - static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState) { displayed_etag = 0; if (cleanupState) cleanupDirectWriteState(false); From 594a310691b618e8dd5aae87879cd404e68122a6 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Tue, 28 Apr 2026 14:25:50 +0200 Subject: [PATCH 23/32] Split displayed etag storage by target --- src/main.h | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main.h b/src/main.h index 3a3199e..21d19ab 100644 --- a/src/main.h +++ b/src/main.h @@ -303,15 +303,13 @@ struct SecurityConfig securityConfig = {0}; EncryptionSession encryptionSession = {0}; bool encryptionInitialized = false; -#ifndef RTC_DATA_ATTR -// nRF52 has no equivalent of ESP32 RTC slow RAM; the variable becomes a -// regular global and resets on boot — partial-rendering then degrades to -// always-full-upload, which is the safe fallback. -#define RTC_DATA_ATTR -#endif - +#ifdef TARGET_ESP32 // 0x00000000 = "not set". Persists across deep sleep on ESP32. RTC_DATA_ATTR uint32_t displayed_etag = 0; +#else +// 0x00000000 = "not set". Non-ESP32 targets reset this on boot. +uint32_t displayed_etag = 0; +#endif #ifdef TARGET_ESP32 // RTC memory variables for deep sleep state tracking From 30829d37a1c7919d0339d0fba18c50e389394fcc Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 29 Apr 2026 12:47:05 +0200 Subject: [PATCH 24/32] Restore full image direct write path --- src/display_service.cpp | 146 ++++------------------------------------ 1 file changed, 14 insertions(+), 132 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index a20e94f..d41c4bf 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -1129,8 +1129,6 @@ void updatemsdata(){ } void handleDirectWriteCompressedData(uint8_t* data, uint16_t len) { - // Compressed full-image 0x71 chunks are buffered as compressed bytes. - // uzlib is run once at 0x72, so we never keep a decompressed full image in RAM. if (!compressedDataBuffer) { cleanupDirectWriteState(false); uint8_t errorResponse[] = {0xFF, 0xFF}; @@ -1154,8 +1152,6 @@ void handleDirectWriteCompressedData(uint8_t* data, uint16_t len) { } void decompressDirectWriteData() { - // Inflate the buffered compressed full-image stream and write each output - // chunk directly to the active panel write window. if (directWriteCompressedReceived == 0) return; struct uzlib_uncomp d; memset(&d, 0, sizeof(d)); @@ -1204,8 +1200,6 @@ void cleanupDirectWriteState(bool refreshDisplay) { directWriteTotalBytes = 0; directWriteRefreshMode = 0; directWriteStartTime = 0; - directWriteDataKind = DATA_KIND_NONE; - memset(&partialCtx, 0, sizeof(partialCtx)); if (refreshDisplay && displayPowerState) { #if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) if (seeed_driver_used()) { @@ -1222,13 +1216,7 @@ void cleanupDirectWriteState(bool refreshDisplay) { } void handleDirectWriteStart(uint8_t* data, uint16_t len) { - if (partialCtx.active) { - send_direct_write_nack(0x70, ERR_MIXED_DATA, true); - return; - } if (directWriteActive) cleanupDirectWriteState(false); - bool isCompressedStart = (len >= 4); - #if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) if (seeed_driver_used()) { seeed_gfx_prepare_hardware(); @@ -1237,7 +1225,7 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { uint8_t colorScheme = globalConfig.displays[0].color_scheme; directWriteBitplanes = (colorScheme == 1 || colorScheme == 2); directWritePlane2 = false; - directWriteCompressed = isCompressedStart; + directWriteCompressed = (len >= 4); directWriteWidth = globalConfig.displays[0].pixel_width; directWriteHeight = globalConfig.displays[0].pixel_height; uint32_t pixels = (uint32_t)directWriteWidth * (uint32_t)directWriteHeight; @@ -1430,45 +1418,10 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { void handleDirectWriteData(uint8_t* data, uint16_t len) { if (!directWriteActive || len == 0) return; - if (directWriteDataKind == DATA_KIND_PARTIAL) { - bool isCompressed = (partialCtx.flags & PARTIAL_FLAG_COMPRESSED) != 0; - if (isCompressed) { - // Match compressed full-image uploads: collect the zlib stream - // across 0x76/0x71, then inflate at 0x72 through the partial sink. - if (!directWriteCompressedBuffer) { - displayed_etag = 0; - cleanupDirectWriteState(false); - uint8_t errResponse[] = {0xFF, 0xFF}; - sendResponse(errResponse, sizeof(errResponse)); - return; - } - uint32_t cap = max_compressed_image_rx_bytes(globalConfig.displays[0].transmission_modes); - uint32_t newTotal = directWriteCompressedReceived + len; - if (cap == 0 || newTotal > cap) { - send_direct_write_nack(0x71, ERR_PARTIAL_STREAM, true); - return; - } - memcpy(directWriteCompressedBuffer + directWriteCompressedReceived, data, len); - directWriteCompressedReceived += len; - } else { - // Raw partial 0x71 data is already panel-plane data, so stream it - // immediately and let partial_consume_bytes() retarget planes. - if (!partial_consume_bytes(data, (uint32_t)len)) { - send_direct_write_nack(0x71, ERR_PARTIAL_STREAM, true); - return; - } - } - uint8_t ackResponse[] = {0x00, 0x71}; - sendResponse(ackResponse, sizeof(ackResponse)); - return; - } - directWriteDataKind = DATA_KIND_FULL; if (directWriteCompressed) { handleDirectWriteCompressedData(data, len); return; } - // Raw full-image 0x71 data streams directly to the panel; only compressed - // mode needs the intermediate compressed buffer. uint32_t remainingBytes = (directWriteBytesWritten < directWriteTotalBytes) ? (directWriteTotalBytes - directWriteBytesWritten) : 0; uint16_t bytesToWrite = (len > remainingBytes) ? remainingBytes : len; if (bytesToWrite > 0) { @@ -1493,90 +1446,21 @@ void handleDirectWriteData(uint8_t* data, uint16_t len) { void handleDirectWriteEnd(uint8_t* data, uint16_t len) { if (!directWriteActive) return; directWriteStartTime = 0; - - if (directWriteDataKind == DATA_KIND_PARTIAL) { - bool isCompressed = (partialCtx.flags & PARTIAL_FLAG_COMPRESSED) != 0; - bool storeEtag = (partialCtx.flags & PARTIAL_FLAG_STORE_ETAG) != 0; - - if (isCompressed && !decompress_partial_stream()) { - send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); - return; - } - - uint32_t planeBytes = calc_controller_plane_bytes(partialCtx.width, partialCtx.height); - - if (partialCtx.logical_bytes_written != partialCtx.expected_stream_size || - partialCtx.old_plane_bytes_written != planeBytes || - partialCtx.new_plane_bytes_written != planeBytes) { - send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); - return; - } - - if (storeEtag && (data == nullptr || len < 5)) { - send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); - return; - } - - bool hasNewEtag = storeEtag; - uint32_t newEtag = 0; - if (hasNewEtag) { - newEtag = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) | - ((uint32_t)data[3] << 8) | (uint32_t)data[4]; - } - - // Default PARTIAL; allow FAST override; FULL → PARTIAL for partial streams - int refreshMode = REFRESH_PARTIAL; - if (data != nullptr && len >= 1 && data[0] == 1) refreshMode = REFRESH_FAST; - - writeSerial(String("EPD partial refresh: ") + - (refreshMode == REFRESH_FAST ? "FAST" : "PARTIAL") + - " (mode=" + String(refreshMode) + - ", rect=" + String(partialCtx.x) + "," + String(partialCtx.y) + - " " + String(partialCtx.width) + "x" + String(partialCtx.height) + ")", true); - uint8_t ackResponse[] = {0x00, 0x72}; - sendResponse(ackResponse, sizeof(ackResponse)); - delay(20); - - bbepRefresh(&bbep, refreshMode); - bool refreshSuccess = waitforrefresh(60); - bbepSleep(&bbep, 1); - delay(50); - cleanupDirectWriteState(false); - - if (refreshSuccess) { - displayed_etag = hasNewEtag ? newEtag : 0; - uint8_t refreshResponse[] = {0x00, 0x73}; - sendResponse(refreshResponse, sizeof(refreshResponse)); - } else { - displayed_etag = 0; - uint8_t timeoutResponse[] = {0x00, 0x74}; - sendResponse(timeoutResponse, sizeof(timeoutResponse)); - } - return; - } - if (directWriteCompressed && directWriteCompressedReceived > 0) decompressDirectWriteData(); - - // Optional new_etag tail: refresh_mode(1) + new_etag(4 BE) when len >= 5. - bool hasNewEtag = (data != nullptr && len >= 5); - uint32_t newEtag = 0; - if (hasNewEtag) { - newEtag = ((uint32_t)data[1] << 24) | ((uint32_t)data[2] << 16) | - ((uint32_t)data[3] << 8) | (uint32_t)data[4]; - } - int refreshMode = REFRESH_FULL; - if (data != nullptr && len >= 1) { - if (data[0] == 1) refreshMode = REFRESH_FAST; - else if (data[0] == 2) refreshMode = REFRESH_PARTIAL; - else refreshMode = REFRESH_FULL; - } - const char* refreshLabel = "FULL"; - if (refreshMode == REFRESH_FAST) refreshLabel = "FAST"; - else if (refreshMode == REFRESH_PARTIAL) refreshLabel = "PARTIAL"; - writeSerial(String("EPD refresh: ") + refreshLabel + " (mode=" + String(refreshMode) + - ", end payload " + (data != nullptr && len > 0 ? ("0x" + String(data[0], HEX)) : String("none (auto)")) + ")", - true); + if (data != nullptr && len >= 1 && data[0] == 1) refreshMode = REFRESH_FAST; + writeSerial("EPD refresh: ", false); + writeSerial(refreshMode == REFRESH_FAST ? "FAST" : "FULL", false); + writeSerial(" (mode=", false); + writeSerial(String(refreshMode), false); + writeSerial(", end payload ", false); + if (data != nullptr && len > 0) { + writeSerial("0x", false); + writeSerial(String(data[0], HEX), false); + } else { + writeSerial("none (auto)", false); + } + writeSerial(")", true); uint8_t ackResponse[] = {0x00, 0x72}; sendResponse(ackResponse, sizeof(ackResponse)); delay(20); @@ -1596,11 +1480,9 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { delay(50); cleanupDirectWriteState(false); if (refreshSuccess) { - displayed_etag = hasNewEtag ? newEtag : 0; uint8_t refreshResponse[] = {0x00, 0x73}; sendResponse(refreshResponse, sizeof(refreshResponse)); } else { - displayed_etag = 0; uint8_t timeoutResponse[] = {0x00, 0x74}; sendResponse(timeoutResponse, sizeof(timeoutResponse)); } From 7b3bf46409005f2a518f8e09a2137aa1a0fc8d33 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 29 Apr 2026 13:04:18 +0200 Subject: [PATCH 25/32] Allow local PlatformIO overrides --- .gitignore | 2 +- platformio.ini | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 44e447d..089643a 100644 --- a/.gitignore +++ b/.gitignore @@ -78,8 +78,8 @@ dkms.conf .gcc-flags.json .pio .vscode +platformio.local.ini ### Other files .idea - diff --git a/platformio.ini b/platformio.ini index de9cc5b..2e2d1c1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,3 +1,7 @@ +[platformio] +extra_configs = + platformio.local.ini + [env] lib_deps = https://github.com/bitbank2/bb_epaper.git From d47f65fc9323c4151cc2e4577b363837d75aaf21 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 29 Apr 2026 13:12:37 +0200 Subject: [PATCH 26/32] Add partial image state validation --- src/display_service.cpp | 229 +++++++++------------------------------- 1 file changed, 49 insertions(+), 180 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index d41c4bf..c6eac3b 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -50,19 +50,12 @@ extern bool directWritePlane2; extern bool directWriteBitplanes; extern bool directWriteCompressed; extern bool directWriteActive; -extern uint8_t directWriteDataKind; extern uint8_t* compressedDataBuffer; extern uint8_t dictionaryBuffer[]; extern uint8_t decompressionChunk[]; extern uint32_t displayed_etag; -static const uint8_t DATA_KIND_NONE = 0u; -static const uint8_t DATA_KIND_FULL = 1u; -static const uint8_t DATA_KIND_PARTIAL = 2u; - -static const uint8_t ERR_ETAG_MISMATCH = 0x01u; -static const uint8_t ERR_MIXED_DATA = 0x02u; static const uint8_t ERR_RECT_OOB = 0x03u; static const uint8_t ERR_PARTIAL_VERSION = 0x04u; static const uint8_t ERR_RECT_ALIGN = 0x05u; @@ -72,8 +65,6 @@ static const uint8_t ERR_PARTIAL_STREAM = 0x08u; static const uint8_t ERR_PARTIAL_UNSUPPORTED = 0x09u; static const uint8_t PARTIAL_WRITE_PROTOCOL_V1 = 0x01u; -static const uint16_t PARTIAL_FLAG_COMPRESSED = 0x0004u; -static const uint16_t PARTIAL_FLAG_STORE_ETAG = 0x0008u; struct PartialStreamContext { bool active; @@ -83,11 +74,7 @@ struct PartialStreamContext { uint16_t width; uint16_t height; uint32_t expected_stream_size; - uint32_t logical_bytes_written; - uint32_t bytes_remaining_in_phase; - uint32_t old_plane_bytes_written; - uint32_t new_plane_bytes_written; - uint8_t phase; // 0 = old image (PLANE_1), 1 = new image (PLANE_0), 2 = complete + uint32_t bytes_received; }; uint32_t max_compressed_image_rx_bytes(uint8_t tm) { @@ -119,10 +106,8 @@ void bbepStartWrite(BBEPDISP *pBBEP, int iPlane); void bbepWriteData(BBEPDISP *pBBEP, uint8_t *pData, int iLen); bool bbepIsBusy(BBEPDISP *pBBEP); void flashLed(uint8_t color, uint8_t brightness); -static void clear_partial_planes_to_white(void); -static void setup_phase(void); +static void cleanup_partial_write_state(void); static bool partial_consume_bytes(uint8_t* data, uint32_t len); -static bool decompress_partial_stream(void); static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height); static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState); static PartialStreamContext partialCtx = {}; @@ -1216,6 +1201,7 @@ void cleanupDirectWriteState(bool refreshDisplay) { } void handleDirectWriteStart(uint8_t* data, uint16_t len) { + if (partialCtx.active) cleanup_partial_write_state(); if (directWriteActive) cleanupDirectWriteState(false); #if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) if (seeed_driver_used()) { @@ -1286,13 +1272,7 @@ void handleDirectWriteStart(uint8_t* data, uint16_t len) { void handlePartialWriteStart(uint8_t* data, uint16_t len) { if (directWriteActive) cleanupDirectWriteState(false); - -#if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) - if (seeed_driver_used()) { - send_direct_write_nack(0x76, ERR_PARTIAL_FLAGS, false); - return; - } -#endif + if (partialCtx.active) cleanup_partial_write_state(); if (len < 19 || data[0] != PARTIAL_WRITE_PROTOCOL_V1) { send_direct_write_nack(0x76, ERR_PARTIAL_VERSION, false); @@ -1309,16 +1289,13 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { uint32_t uncompSize; memcpy(&uncompSize, data + 15, 4); // 4-byte LE - // Validate reserved flags. Only compressed and store-etag are defined. - if (flags & ~(PARTIAL_FLAG_COMPRESSED | PARTIAL_FLAG_STORE_ETAG)) { + // Step 1 accepts raw partial streams only. Compression and ETags are added later. + if (flags != 0) { send_direct_write_nack(0x76, ERR_PARTIAL_FLAGS, false); return; } - if (oldEtag == 0 || displayed_etag == 0 || displayed_etag != oldEtag) { - send_direct_write_nack(0x76, ERR_ETAG_MISMATCH, false); - return; - } + (void)oldEtag; uint16_t dispW = globalConfig.displays[0].pixel_width; uint16_t dispH = globalConfig.displays[0].pixel_height; @@ -1343,42 +1320,17 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { } uint32_t planeBytes = calc_controller_plane_bytes(rectW, rectH); - if (uncompSize != planeBytes * 2u) { + if (uncompSize != planeBytes) { send_direct_write_nack(0x76, ERR_PARTIAL_SIZE, false); return; } - bool isCompressed = (flags & PARTIAL_FLAG_COMPRESSED) != 0; - if (isCompressed && !compressedDataBuffer) { - displayed_etag = 0; + if (!compressedDataBuffer || uncompSize > MAX_COMPRESSED_BUFFER_BYTES) { uint8_t errResponse[] = {0xFF, 0xFF}; sendResponse(errResponse, sizeof(errResponse)); return; } - // Hardware init - directWriteWidth = dispW; - directWriteHeight = dispH; - directWriteActive = true; - directWriteDataKind = DATA_KIND_PARTIAL; - directWriteStartTime = millis(); - if (displayPowerState) { - pwrmgm(false); - delay(50); - } - pwrmgm(true); - bbepInitIO(&bbep, globalConfig.displays[0].dc_pin, globalConfig.displays[0].reset_pin, - globalConfig.displays[0].busy_pin, globalConfig.displays[0].cs_pin, - globalConfig.displays[0].data_pin, globalConfig.displays[0].clk_pin, 8000000); - bbepWakeUp(&bbep); - if (bbep.pInitPart != NULL) { - bbepSendCMDSequence(&bbep, bbep.pInitPart); - } else { - bbepSendCMDSequence(&bbep, bbep.pInitFull); - } - clear_partial_planes_to_white(); - - // Initialize stream context memset(&partialCtx, 0, sizeof(partialCtx)); partialCtx.active = true; partialCtx.flags = flags; @@ -1387,28 +1339,15 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { partialCtx.width = rectW; partialCtx.height = rectH; partialCtx.expected_stream_size = uncompSize; - - if (isCompressed) { - directWriteCompressedBuffer = compressedDataBuffer; - directWriteCompressedReceived = 0; - } + directWriteCompressedBuffer = compressedDataBuffer; + directWriteCompressedReceived = 0; // Process optional initial stream bytes before ACK if (len > 19) { uint16_t initLen = len - 19; - if (isCompressed) { - uint32_t cap = max_compressed_image_rx_bytes(globalConfig.displays[0].transmission_modes); - if (cap == 0 || initLen > cap) { - send_direct_write_nack(0x76, ERR_PARTIAL_STREAM, true); - return; - } - memcpy(directWriteCompressedBuffer, data + 19, initLen); - directWriteCompressedReceived = initLen; - } else { - if (!partial_consume_bytes(data + 19, (uint32_t)initLen)) { - send_direct_write_nack(0x76, ERR_PARTIAL_STREAM, true); - return; - } + if (!partial_consume_bytes(data + 19, (uint32_t)initLen)) { + send_direct_write_nack(0x76, ERR_PARTIAL_STREAM, true); + return; } } @@ -1417,6 +1356,16 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { } void handleDirectWriteData(uint8_t* data, uint16_t len) { + if (partialCtx.active) { + if (len == 0) return; + if (!partial_consume_bytes(data, (uint32_t)len)) { + send_direct_write_nack(0x71, ERR_PARTIAL_STREAM, true); + return; + } + uint8_t ackResponse[] = {0x00, 0x71}; + sendResponse(ackResponse, sizeof(ackResponse)); + return; + } if (!directWriteActive || len == 0) return; if (directWriteCompressed) { handleDirectWriteCompressedData(data, len); @@ -1444,6 +1393,18 @@ void handleDirectWriteData(uint8_t* data, uint16_t len) { } void handleDirectWriteEnd(uint8_t* data, uint16_t len) { + if (partialCtx.active) { + if (partialCtx.bytes_received != partialCtx.expected_stream_size) { + send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); + return; + } + uint8_t ackResponse[] = {0x00, 0x72}; + sendResponse(ackResponse, sizeof(ackResponse)); + uint8_t validatedResponse[] = {0x00, 0x73}; + sendResponse(validatedResponse, sizeof(validatedResponse)); + cleanup_partial_write_state(); + return; + } if (!directWriteActive) return; directWriteStartTime = 0; if (directWriteCompressed && directWriteCompressedReceived > 0) decompressDirectWriteData(); @@ -1488,123 +1449,31 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { } } -static void clear_partial_planes_to_white(void) { - // bb_epaper's partial refresh path runs the panel's partial init sequence - // inside bbepRefresh(). Some panel init sequences force the RAM window - // back to the full panel, so partial refresh may touch panel memory outside - // the 0x76 dirty rectangle. Seed both planes with white first so untouched - // areas refresh as white instead of stale or uninitialized RAM. - // This is controller RAM, not logical image bpp: each controller plane is 1 bpp. - const uint32_t total_bytes = ((uint32_t)bbep.native_width * (uint32_t)bbep.native_height) / 8; - uint8_t white[64]; - memset(white, 0xFF, sizeof(white)); - - bbepSetAddrWindow(&bbep, 0, 0, bbep.native_width, bbep.native_height); - bbepStartWrite(&bbep, PLANE_1); - for (uint32_t written = 0; written < total_bytes; written += sizeof(white)) { - uint32_t chunk = total_bytes - written; - if (chunk > sizeof(white)) chunk = sizeof(white); - bbepWriteData(&bbep, white, (int)chunk); - } - - bbepSetAddrWindow(&bbep, 0, 0, bbep.native_width, bbep.native_height); - bbepStartWrite(&bbep, PLANE_0); - for (uint32_t written = 0; written < total_bytes; written += sizeof(white)) { - uint32_t chunk = total_bytes - written; - if (chunk > sizeof(white)) chunk = sizeof(white); - bbepWriteData(&bbep, white, (int)chunk); - } -} - -static void setup_phase(void) { - uint32_t plane_bytes = calc_controller_plane_bytes(partialCtx.width, partialCtx.height); - - if (partialCtx.phase > 1) { - partialCtx.bytes_remaining_in_phase = 0; - return; - } - - int plane = (partialCtx.phase == 0) ? PLANE_1 : PLANE_0; - bbepSetAddrWindow(&bbep, partialCtx.x, partialCtx.y, partialCtx.width, partialCtx.height); - bbepStartWrite(&bbep, plane); - partialCtx.bytes_remaining_in_phase = plane_bytes; +static void cleanup_partial_write_state(void) { + memset(&partialCtx, 0, sizeof(partialCtx)); + directWriteCompressedBuffer = nullptr; + directWriteCompressedReceived = 0; } static bool partial_consume_bytes(uint8_t* data, uint32_t len) { - // Partial streams are two controller-plane images concatenated: - // old image to PLANE_1, then new image to PLANE_0. This sink accepts raw - // or decompressed bytes, writes as much as fits in the current plane, then - // opens the same rectangle on the next plane and continues with no buffering. - uint32_t offset = 0; - while (offset < len) { - if (partialCtx.bytes_remaining_in_phase == 0) { - setup_phase(); - if (partialCtx.bytes_remaining_in_phase == 0) - return false; // extra bytes beyond expected stream size - } - - uint32_t can_consume = len - offset; - if (can_consume > partialCtx.bytes_remaining_in_phase) - can_consume = partialCtx.bytes_remaining_in_phase; - - bbepWriteData(&bbep, data + offset, (int)can_consume); - - if (partialCtx.phase == 0) partialCtx.old_plane_bytes_written += can_consume; - else partialCtx.new_plane_bytes_written += can_consume; - - partialCtx.bytes_remaining_in_phase -= can_consume; - partialCtx.logical_bytes_written += can_consume; - offset += can_consume; - - if (partialCtx.bytes_remaining_in_phase == 0) { - partialCtx.phase++; - } - } + if (!directWriteCompressedBuffer) return false; + if (len > partialCtx.expected_stream_size - partialCtx.bytes_received) return false; + memcpy(directWriteCompressedBuffer + partialCtx.bytes_received, data, len); + partialCtx.bytes_received += len; + directWriteCompressedReceived = partialCtx.bytes_received; return true; } -static bool decompress_partial_stream(void) { - // Like decompressDirectWriteData(), compressed partial uploads are inflated - // only at 0x72. The output chunks go through partial_consume_bytes() so the - // old/new plane boundary can retarget the panel write window mid-stream. - if (!directWriteCompressedBuffer || directWriteCompressedReceived == 0) return false; - struct uzlib_uncomp d; - memset(&d, 0, sizeof(d)); - d.source = directWriteCompressedBuffer; - d.source_limit = directWriteCompressedBuffer + directWriteCompressedReceived; - d.source_read_cb = NULL; - uzlib_init(); - int hdr = uzlib_zlib_parse_header(&d); - if (hdr < 0) return false; - uint16_t window = 0x100 << hdr; - if (window > (uint16_t)(32 * 1024)) window = (uint16_t)(32 * 1024); - uzlib_uncompress_init(&d, dictionaryBuffer, window); - int res; - do { - d.dest_start = decompressionChunk; - d.dest = decompressionChunk; - d.dest_limit = decompressionChunk + 4096; - res = uzlib_uncompress_chksum(&d); - size_t bytesOut = d.dest - d.dest_start; - if (bytesOut > 0) { - if (!partial_consume_bytes(decompressionChunk, (uint32_t)bytesOut)) return false; - } - if (res < 0) return false; - if (partialCtx.logical_bytes_written > partialCtx.expected_stream_size) return false; - } while (res == TINF_OK); - - return res == TINF_DONE && - partialCtx.logical_bytes_written == partialCtx.expected_stream_size && - d.source == d.source_limit; -} - static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height) { return ((uint32_t)(width + 7u) / 8u) * height; } static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState) { displayed_etag = 0; - if (cleanupState) cleanupDirectWriteState(false); + if (cleanupState) { + if (partialCtx.active) cleanup_partial_write_state(); + else cleanupDirectWriteState(false); + } uint8_t errResponse[] = {0xFF, opcode, error, 0x00}; sendResponse(errResponse, sizeof(errResponse)); } From 8d44b0bf28bfc94a49f9ad8f1c8894a3ffd82b37 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 29 Apr 2026 13:19:06 +0200 Subject: [PATCH 27/32] Add raw partial panel writes --- src/display_service.cpp | 45 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index c6eac3b..c6a3819 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -108,6 +108,7 @@ bool bbepIsBusy(BBEPDISP *pBBEP); void flashLed(uint8_t color, uint8_t brightness); static void cleanup_partial_write_state(void); static bool partial_consume_bytes(uint8_t* data, uint32_t len); +static bool partial_write_to_panel(void); static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height); static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState); static PartialStreamContext partialCtx = {}; @@ -1400,8 +1401,14 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { } uint8_t ackResponse[] = {0x00, 0x72}; sendResponse(ackResponse, sizeof(ackResponse)); - uint8_t validatedResponse[] = {0x00, 0x73}; - sendResponse(validatedResponse, sizeof(validatedResponse)); + bool refreshSuccess = partial_write_to_panel(); + if (refreshSuccess) { + uint8_t validatedResponse[] = {0x00, 0x73}; + sendResponse(validatedResponse, sizeof(validatedResponse)); + } else { + uint8_t timeoutResponse[] = {0x00, 0x74}; + sendResponse(timeoutResponse, sizeof(timeoutResponse)); + } cleanup_partial_write_state(); return; } @@ -1464,6 +1471,40 @@ static bool partial_consume_bytes(uint8_t* data, uint32_t len) { return true; } +static bool partial_write_to_panel(void) { + if (!directWriteCompressedBuffer) return false; + + writeSerial("EPD refresh: PARTIAL (raw rect ", false); + writeSerial(String(partialCtx.x), false); + writeSerial(",", false); + writeSerial(String(partialCtx.y), false); + writeSerial(" ", false); + writeSerial(String(partialCtx.width), false); + writeSerial("x", false); + writeSerial(String(partialCtx.height), false); + writeSerial(")", true); + + if (displayPowerState) { + pwrmgm(false); + delay(50); + } + pwrmgm(true); + bbepInitIO(&bbep, globalConfig.displays[0].dc_pin, globalConfig.displays[0].reset_pin, globalConfig.displays[0].busy_pin, globalConfig.displays[0].cs_pin, globalConfig.displays[0].data_pin, globalConfig.displays[0].clk_pin, 8000000); + bbepWakeUp(&bbep); + bbepSendCMDSequence(&bbep, bbep.pInitFull); + bbepSetAddrWindow(&bbep, partialCtx.x, partialCtx.y, partialCtx.width, partialCtx.height); + bbepStartWrite(&bbep, getplane()); + bbepWriteData(&bbep, directWriteCompressedBuffer, partialCtx.expected_stream_size); + delay(20); + bbepRefresh(&bbep, REFRESH_PARTIAL); + bool refreshSuccess = waitforrefresh(60); + bbepSleep(&bbep, 1); + delay(50); + displayPowerState = false; + pwrmgm(false); + return refreshSuccess; +} + static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height) { return ((uint32_t)(width + 7u) / 8u) * height; } From c9ebb677359378bfd67544fc682f51cee67fdf37 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 29 Apr 2026 13:35:33 +0200 Subject: [PATCH 28/32] Add partial write commands --- src/display_service.cpp | 141 +++++++++++++++++++++++++++++++++++----- 1 file changed, 125 insertions(+), 16 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index c6a3819..4a4dc3c 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -56,6 +56,7 @@ extern uint8_t decompressionChunk[]; extern uint32_t displayed_etag; +static const uint8_t ERR_ETAG_MISMATCH = 0x01u; static const uint8_t ERR_RECT_OOB = 0x03u; static const uint8_t ERR_PARTIAL_VERSION = 0x04u; static const uint8_t ERR_RECT_ALIGN = 0x05u; @@ -65,15 +66,21 @@ static const uint8_t ERR_PARTIAL_STREAM = 0x08u; static const uint8_t ERR_PARTIAL_UNSUPPORTED = 0x09u; static const uint8_t PARTIAL_WRITE_PROTOCOL_V1 = 0x01u; +static const uint16_t PARTIAL_FLAG_COMPRESSED = 0x0004u; +static const uint16_t PARTIAL_FLAG_STORE_ETAG = 0x0008u; +static const uint16_t PARTIAL_ALLOWED_FLAGS = PARTIAL_FLAG_COMPRESSED | PARTIAL_FLAG_STORE_ETAG; struct PartialStreamContext { bool active; + bool compressed; + bool store_etag; uint16_t flags; uint16_t x; uint16_t y; uint16_t width; uint16_t height; uint32_t expected_stream_size; + uint32_t plane_size; uint32_t bytes_received; }; @@ -108,8 +115,10 @@ bool bbepIsBusy(BBEPDISP *pBBEP); void flashLed(uint8_t color, uint8_t brightness); static void cleanup_partial_write_state(void); static bool partial_consume_bytes(uint8_t* data, uint32_t len); -static bool partial_write_to_panel(void); +static bool partial_prepare_logical_stream(void); +static bool partial_write_to_panel(int refreshMode); static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height); +static uint32_t parse_be_u32(const uint8_t* data); static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState); static PartialStreamContext partialCtx = {}; #define AXP2101_SLAVE_ADDRESS 0x34 @@ -1281,8 +1290,7 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { } uint16_t flags = ((uint16_t)data[1] << 8) | data[2]; - uint32_t oldEtag = ((uint32_t)data[3] << 24) | ((uint32_t)data[4] << 16) | - ((uint32_t)data[5] << 8) | (uint32_t)data[6]; + uint32_t oldEtag = parse_be_u32(data + 3); uint16_t rectX = ((uint16_t)data[7] << 8) | data[8]; uint16_t rectY = ((uint16_t)data[9] << 8) | data[10]; uint16_t rectW = ((uint16_t)data[11] << 8) | data[12]; @@ -1290,13 +1298,22 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { uint32_t uncompSize; memcpy(&uncompSize, data + 15, 4); // 4-byte LE - // Step 1 accepts raw partial streams only. Compression and ETags are added later. - if (flags != 0) { + if ((flags & ~PARTIAL_ALLOWED_FLAGS) != 0) { send_direct_write_nack(0x76, ERR_PARTIAL_FLAGS, false); return; } - (void)oldEtag; + if ((flags & PARTIAL_FLAG_COMPRESSED) != 0 && + (globalConfig.displays[0].transmission_modes & TRANSMISSION_MODE_ZIP) == 0) { + send_direct_write_nack(0x76, ERR_PARTIAL_FLAGS, false); + return; + } + + if ((flags & PARTIAL_FLAG_STORE_ETAG) != 0 && + (oldEtag == 0 || oldEtag != displayed_etag)) { + send_direct_write_nack(0x76, ERR_ETAG_MISMATCH, false); + return; + } uint16_t dispW = globalConfig.displays[0].pixel_width; uint16_t dispH = globalConfig.displays[0].pixel_height; @@ -1321,7 +1338,8 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { } uint32_t planeBytes = calc_controller_plane_bytes(rectW, rectH); - if (uncompSize != planeBytes) { + uint32_t expectedLogicalSize = ((flags & PARTIAL_FLAG_STORE_ETAG) != 0) ? planeBytes * 2u : planeBytes; + if (uncompSize != expectedLogicalSize) { send_direct_write_nack(0x76, ERR_PARTIAL_SIZE, false); return; } @@ -1332,15 +1350,24 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { return; } + uint32_t rxOffset = ((flags & PARTIAL_FLAG_COMPRESSED) != 0) ? uncompSize : 0u; + if (rxOffset >= MAX_COMPRESSED_BUFFER_BYTES) { + send_direct_write_nack(0x76, ERR_PARTIAL_SIZE, false); + return; + } + memset(&partialCtx, 0, sizeof(partialCtx)); partialCtx.active = true; + partialCtx.compressed = (flags & PARTIAL_FLAG_COMPRESSED) != 0; + partialCtx.store_etag = (flags & PARTIAL_FLAG_STORE_ETAG) != 0; partialCtx.flags = flags; partialCtx.x = rectX; partialCtx.y = rectY; partialCtx.width = rectW; partialCtx.height = rectH; partialCtx.expected_stream_size = uncompSize; - directWriteCompressedBuffer = compressedDataBuffer; + partialCtx.plane_size = planeBytes; + directWriteCompressedBuffer = compressedDataBuffer + rxOffset; directWriteCompressedReceived = 0; // Process optional initial stream bytes before ACK @@ -1395,17 +1422,39 @@ void handleDirectWriteData(uint8_t* data, uint16_t len) { void handleDirectWriteEnd(uint8_t* data, uint16_t len) { if (partialCtx.active) { - if (partialCtx.bytes_received != partialCtx.expected_stream_size) { + uint32_t newEtag = 0; + if (partialCtx.store_etag) { + if (data == nullptr || len < 5) { + send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); + return; + } + newEtag = parse_be_u32(data + 1); + if (newEtag == 0) { + send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); + return; + } + } + if (partialCtx.compressed) { + if (partialCtx.bytes_received == 0 || !partial_prepare_logical_stream()) { + send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); + return; + } + } else if (partialCtx.bytes_received != partialCtx.expected_stream_size) { send_direct_write_nack(0x72, ERR_PARTIAL_STREAM, true); return; } uint8_t ackResponse[] = {0x00, 0x72}; sendResponse(ackResponse, sizeof(ackResponse)); - bool refreshSuccess = partial_write_to_panel(); + int refreshMode = REFRESH_PARTIAL; + if (data != nullptr && len >= 1 && data[0] == REFRESH_FULL) refreshMode = REFRESH_FULL; + else if (data != nullptr && len >= 1 && data[0] == REFRESH_FAST) refreshMode = REFRESH_FAST; + bool refreshSuccess = partial_write_to_panel(refreshMode); if (refreshSuccess) { + if (partialCtx.store_etag) displayed_etag = newEtag; uint8_t validatedResponse[] = {0x00, 0x73}; sendResponse(validatedResponse, sizeof(validatedResponse)); } else { + if (partialCtx.store_etag) displayed_etag = 0; uint8_t timeoutResponse[] = {0x00, 0x74}; sendResponse(timeoutResponse, sizeof(timeoutResponse)); } @@ -1433,6 +1482,9 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { sendResponse(ackResponse, sizeof(ackResponse)); delay(20); bool refreshSuccess = false; + uint32_t newEtag = 0; + bool hasNewEtag = data != nullptr && len >= 5; + if (hasNewEtag) newEtag = parse_be_u32(data + 1); #if defined(TARGET_ESP32) && defined(OPENDISPLAY_SEEED_GFX) if (seeed_driver_used()) { seeed_gfx_direct_refresh(refreshMode); @@ -1448,9 +1500,11 @@ void handleDirectWriteEnd(uint8_t* data, uint16_t len) { delay(50); cleanupDirectWriteState(false); if (refreshSuccess) { + if (hasNewEtag && newEtag != 0) displayed_etag = newEtag; uint8_t refreshResponse[] = {0x00, 0x73}; sendResponse(refreshResponse, sizeof(refreshResponse)); } else { + if (hasNewEtag) displayed_etag = 0; uint8_t timeoutResponse[] = {0x00, 0x74}; sendResponse(timeoutResponse, sizeof(timeoutResponse)); } @@ -1464,15 +1518,57 @@ static void cleanup_partial_write_state(void) { static bool partial_consume_bytes(uint8_t* data, uint32_t len) { if (!directWriteCompressedBuffer) return false; - if (len > partialCtx.expected_stream_size - partialCtx.bytes_received) return false; + uint32_t rxLimit = partialCtx.compressed + ? (MAX_COMPRESSED_BUFFER_BYTES - partialCtx.expected_stream_size) + : partialCtx.expected_stream_size; + if (len > rxLimit - partialCtx.bytes_received) return false; memcpy(directWriteCompressedBuffer + partialCtx.bytes_received, data, len); partialCtx.bytes_received += len; directWriteCompressedReceived = partialCtx.bytes_received; return true; } -static bool partial_write_to_panel(void) { - if (!directWriteCompressedBuffer) return false; +static bool partial_prepare_logical_stream(void) { + if (!directWriteCompressedBuffer || !compressedDataBuffer) return false; + if (!partialCtx.compressed) return partialCtx.bytes_received == partialCtx.expected_stream_size; + + struct uzlib_uncomp d; + memset(&d, 0, sizeof(d)); + d.source = directWriteCompressedBuffer; + d.source_limit = directWriteCompressedBuffer + partialCtx.bytes_received; + d.source_read_cb = NULL; + uzlib_init(); + int hdr = uzlib_zlib_parse_header(&d); + if (hdr < 0) return false; + uint16_t window = 0x100 << hdr; + if (window > (uint16_t)(32 * 1024)) window = (uint16_t)(32 * 1024); + uzlib_uncompress_init(&d, dictionaryBuffer, window); + + uint32_t bytesOutTotal = 0; + int res; + do { + d.dest_start = decompressionChunk; + d.dest = decompressionChunk; + d.dest_limit = decompressionChunk + 4096; + res = uzlib_uncompress(&d); + size_t bytesOut = d.dest - d.dest_start; + if (bytesOut > 0) { + if (bytesOutTotal + bytesOut > partialCtx.expected_stream_size) return false; + memcpy(compressedDataBuffer + bytesOutTotal, decompressionChunk, bytesOut); + bytesOutTotal += bytesOut; + } + } while (res == TINF_OK && bytesOutTotal < partialCtx.expected_stream_size); + + if (res != TINF_DONE) return false; + if (bytesOutTotal != partialCtx.expected_stream_size) return false; + directWriteCompressedBuffer = compressedDataBuffer; + directWriteCompressedReceived = bytesOutTotal; + partialCtx.bytes_received = bytesOutTotal; + return true; +} + +static bool partial_write_to_panel(int refreshMode) { + if (!compressedDataBuffer) return false; writeSerial("EPD refresh: PARTIAL (raw rect ", false); writeSerial(String(partialCtx.x), false); @@ -1493,10 +1589,18 @@ static bool partial_write_to_panel(void) { bbepWakeUp(&bbep); bbepSendCMDSequence(&bbep, bbep.pInitFull); bbepSetAddrWindow(&bbep, partialCtx.x, partialCtx.y, partialCtx.width, partialCtx.height); - bbepStartWrite(&bbep, getplane()); - bbepWriteData(&bbep, directWriteCompressedBuffer, partialCtx.expected_stream_size); + if (partialCtx.store_etag) { + bbepStartWrite(&bbep, PLANE_1); + bbepWriteData(&bbep, compressedDataBuffer, partialCtx.plane_size); + bbepSetAddrWindow(&bbep, partialCtx.x, partialCtx.y, partialCtx.width, partialCtx.height); + bbepStartWrite(&bbep, PLANE_0); + bbepWriteData(&bbep, compressedDataBuffer + partialCtx.plane_size, partialCtx.plane_size); + } else { + bbepStartWrite(&bbep, getplane()); + bbepWriteData(&bbep, compressedDataBuffer, partialCtx.expected_stream_size); + } delay(20); - bbepRefresh(&bbep, REFRESH_PARTIAL); + bbepRefresh(&bbep, refreshMode); bool refreshSuccess = waitforrefresh(60); bbepSleep(&bbep, 1); delay(50); @@ -1509,6 +1613,11 @@ static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height) { return ((uint32_t)(width + 7u) / 8u) * height; } +static uint32_t parse_be_u32(const uint8_t* data) { + return ((uint32_t)data[0] << 24) | ((uint32_t)data[1] << 16) | + ((uint32_t)data[2] << 8) | (uint32_t)data[3]; +} + static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState) { displayed_etag = 0; if (cleanupState) { From 6abedd17a29c2a8d3a6cf0729b2df29d892435fb Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 29 Apr 2026 18:58:27 +0200 Subject: [PATCH 29/32] Fill panel RAM before partial writes --- src/display_service.cpp | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index 4a4dc3c..bc4dd12 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -111,10 +111,12 @@ void bbepSleep(BBEPDISP *pBBEP, int iMode); void bbepSetAddrWindow(BBEPDISP *pBBEP, int x, int y, int cx, int cy); void bbepStartWrite(BBEPDISP *pBBEP, int iPlane); void bbepWriteData(BBEPDISP *pBBEP, uint8_t *pData, int iLen); +void bbepFill(BBEPDISP *pBBEP, uint8_t pattern); bool bbepIsBusy(BBEPDISP *pBBEP); void flashLed(uint8_t color, uint8_t brightness); static void cleanup_partial_write_state(void); static bool partial_consume_bytes(uint8_t* data, uint32_t len); +static void partial_prepare_panel_ram(void); static bool partial_prepare_logical_stream(void); static bool partial_write_to_panel(int refreshMode); static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height); @@ -1379,6 +1381,8 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { } } + partial_prepare_panel_ram(); + uint8_t ackResponse[] = {0x00, 0x76}; sendResponse(ackResponse, sizeof(ackResponse)); } @@ -1567,6 +1571,17 @@ static bool partial_prepare_logical_stream(void) { return true; } +static void partial_prepare_panel_ram(void) { + writeSerial("EPD partial start: auto-fill panel RAM", true); + if (!displayPowerState) { + pwrmgm(true); + } + bbepInitIO(&bbep, globalConfig.displays[0].dc_pin, globalConfig.displays[0].reset_pin, globalConfig.displays[0].busy_pin, globalConfig.displays[0].cs_pin, globalConfig.displays[0].data_pin, globalConfig.displays[0].clk_pin, 8000000); + bbepWakeUp(&bbep); + bbepSendCMDSequence(&bbep, bbep.pInitFull); + bbepFill(&bbep, 0xF7); +} + static bool partial_write_to_panel(int refreshMode) { if (!compressedDataBuffer) return false; @@ -1580,14 +1595,12 @@ static bool partial_write_to_panel(int refreshMode) { writeSerial(String(partialCtx.height), false); writeSerial(")", true); - if (displayPowerState) { - pwrmgm(false); - delay(50); + if (!displayPowerState) { + pwrmgm(true); + bbepInitIO(&bbep, globalConfig.displays[0].dc_pin, globalConfig.displays[0].reset_pin, globalConfig.displays[0].busy_pin, globalConfig.displays[0].cs_pin, globalConfig.displays[0].data_pin, globalConfig.displays[0].clk_pin, 8000000); + bbepWakeUp(&bbep); + bbepSendCMDSequence(&bbep, bbep.pInitFull); } - pwrmgm(true); - bbepInitIO(&bbep, globalConfig.displays[0].dc_pin, globalConfig.displays[0].reset_pin, globalConfig.displays[0].busy_pin, globalConfig.displays[0].cs_pin, globalConfig.displays[0].data_pin, globalConfig.displays[0].clk_pin, 8000000); - bbepWakeUp(&bbep); - bbepSendCMDSequence(&bbep, bbep.pInitFull); bbepSetAddrWindow(&bbep, partialCtx.x, partialCtx.y, partialCtx.width, partialCtx.height); if (partialCtx.store_etag) { bbepStartWrite(&bbep, PLANE_1); From 9522556632d8d5096163cf35aff79a9a823905d8 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 29 Apr 2026 19:25:18 +0200 Subject: [PATCH 30/32] Remove version from 0x76, reduce flags size --- src/display_service.cpp | 47 +++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index bc4dd12..ec8647d 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -58,23 +58,20 @@ extern uint32_t displayed_etag; static const uint8_t ERR_ETAG_MISMATCH = 0x01u; static const uint8_t ERR_RECT_OOB = 0x03u; -static const uint8_t ERR_PARTIAL_VERSION = 0x04u; static const uint8_t ERR_RECT_ALIGN = 0x05u; static const uint8_t ERR_PARTIAL_FLAGS = 0x06u; static const uint8_t ERR_PARTIAL_SIZE = 0x07u; static const uint8_t ERR_PARTIAL_STREAM = 0x08u; static const uint8_t ERR_PARTIAL_UNSUPPORTED = 0x09u; -static const uint8_t PARTIAL_WRITE_PROTOCOL_V1 = 0x01u; -static const uint16_t PARTIAL_FLAG_COMPRESSED = 0x0004u; -static const uint16_t PARTIAL_FLAG_STORE_ETAG = 0x0008u; -static const uint16_t PARTIAL_ALLOWED_FLAGS = PARTIAL_FLAG_COMPRESSED | PARTIAL_FLAG_STORE_ETAG; +static const uint8_t PARTIAL_FLAG_COMPRESSED = 0x01u; +static const uint8_t PARTIAL_ALLOWED_FLAGS = PARTIAL_FLAG_COMPRESSED; struct PartialStreamContext { bool active; bool compressed; bool store_etag; - uint16_t flags; + uint8_t flags; uint16_t x; uint16_t y; uint16_t width; @@ -121,6 +118,7 @@ static bool partial_prepare_logical_stream(void); static bool partial_write_to_panel(int refreshMode); static uint32_t calc_controller_plane_bytes(uint16_t width, uint16_t height); static uint32_t parse_be_u32(const uint8_t* data); +static uint32_t parse_be_u24(const uint8_t* data); static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState); static PartialStreamContext partialCtx = {}; #define AXP2101_SLAVE_ADDRESS 0x34 @@ -1286,19 +1284,18 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { if (directWriteActive) cleanupDirectWriteState(false); if (partialCtx.active) cleanup_partial_write_state(); - if (len < 19 || data[0] != PARTIAL_WRITE_PROTOCOL_V1) { - send_direct_write_nack(0x76, ERR_PARTIAL_VERSION, false); + if (len < 16) { + send_direct_write_nack(0x76, ERR_PARTIAL_SIZE, false); return; } - uint16_t flags = ((uint16_t)data[1] << 8) | data[2]; - uint32_t oldEtag = parse_be_u32(data + 3); - uint16_t rectX = ((uint16_t)data[7] << 8) | data[8]; - uint16_t rectY = ((uint16_t)data[9] << 8) | data[10]; - uint16_t rectW = ((uint16_t)data[11] << 8) | data[12]; - uint16_t rectH = ((uint16_t)data[13] << 8) | data[14]; - uint32_t uncompSize; - memcpy(&uncompSize, data + 15, 4); // 4-byte LE + uint8_t flags = data[0]; + uint32_t oldEtag = parse_be_u32(data + 1); + uint16_t rectX = ((uint16_t)data[5] << 8) | data[6]; + uint16_t rectY = ((uint16_t)data[7] << 8) | data[8]; + uint16_t rectW = ((uint16_t)data[9] << 8) | data[10]; + uint16_t rectH = ((uint16_t)data[11] << 8) | data[12]; + uint32_t uncompSize = parse_be_u24(data + 13); if ((flags & ~PARTIAL_ALLOWED_FLAGS) != 0) { send_direct_write_nack(0x76, ERR_PARTIAL_FLAGS, false); @@ -1311,8 +1308,8 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { return; } - if ((flags & PARTIAL_FLAG_STORE_ETAG) != 0 && - (oldEtag == 0 || oldEtag != displayed_etag)) { + bool useEtag = oldEtag != 0; + if (useEtag && oldEtag != displayed_etag) { send_direct_write_nack(0x76, ERR_ETAG_MISMATCH, false); return; } @@ -1340,7 +1337,7 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { } uint32_t planeBytes = calc_controller_plane_bytes(rectW, rectH); - uint32_t expectedLogicalSize = ((flags & PARTIAL_FLAG_STORE_ETAG) != 0) ? planeBytes * 2u : planeBytes; + uint32_t expectedLogicalSize = useEtag ? planeBytes * 2u : planeBytes; if (uncompSize != expectedLogicalSize) { send_direct_write_nack(0x76, ERR_PARTIAL_SIZE, false); return; @@ -1361,7 +1358,7 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { memset(&partialCtx, 0, sizeof(partialCtx)); partialCtx.active = true; partialCtx.compressed = (flags & PARTIAL_FLAG_COMPRESSED) != 0; - partialCtx.store_etag = (flags & PARTIAL_FLAG_STORE_ETAG) != 0; + partialCtx.store_etag = useEtag; partialCtx.flags = flags; partialCtx.x = rectX; partialCtx.y = rectY; @@ -1373,9 +1370,9 @@ void handlePartialWriteStart(uint8_t* data, uint16_t len) { directWriteCompressedReceived = 0; // Process optional initial stream bytes before ACK - if (len > 19) { - uint16_t initLen = len - 19; - if (!partial_consume_bytes(data + 19, (uint32_t)initLen)) { + if (len > 16) { + uint16_t initLen = len - 16; + if (!partial_consume_bytes(data + 16, (uint32_t)initLen)) { send_direct_write_nack(0x76, ERR_PARTIAL_STREAM, true); return; } @@ -1631,6 +1628,10 @@ static uint32_t parse_be_u32(const uint8_t* data) { ((uint32_t)data[2] << 8) | (uint32_t)data[3]; } +static uint32_t parse_be_u24(const uint8_t* data) { + return ((uint32_t)data[0] << 16) | ((uint32_t)data[1] << 8) | (uint32_t)data[2]; +} + static void send_direct_write_nack(uint8_t opcode, uint8_t error, bool cleanupState) { displayed_etag = 0; if (cleanupState) { From 8bee8acca977bd05c229a8e52b7aab1e82e8130d Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 29 Apr 2026 22:05:46 +0200 Subject: [PATCH 31/32] Move error constants --- src/display_service.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index ec8647d..d8b782d 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -58,11 +58,11 @@ extern uint32_t displayed_etag; static const uint8_t ERR_ETAG_MISMATCH = 0x01u; static const uint8_t ERR_RECT_OOB = 0x03u; -static const uint8_t ERR_RECT_ALIGN = 0x05u; -static const uint8_t ERR_PARTIAL_FLAGS = 0x06u; -static const uint8_t ERR_PARTIAL_SIZE = 0x07u; -static const uint8_t ERR_PARTIAL_STREAM = 0x08u; -static const uint8_t ERR_PARTIAL_UNSUPPORTED = 0x09u; +static const uint8_t ERR_RECT_ALIGN = 0x04u; +static const uint8_t ERR_PARTIAL_FLAGS = 0x05u; +static const uint8_t ERR_PARTIAL_SIZE = 0x06u; +static const uint8_t ERR_PARTIAL_STREAM = 0x07u; +static const uint8_t ERR_PARTIAL_UNSUPPORTED = 0x08u; static const uint8_t PARTIAL_FLAG_COMPRESSED = 0x01u; static const uint8_t PARTIAL_ALLOWED_FLAGS = PARTIAL_FLAG_COMPRESSED; From 13259580b165ed5a44166712e48ab98ef516a3d7 Mon Sep 17 00:00:00 2001 From: Michael Bisbjerg Date: Wed, 29 Apr 2026 22:05:53 +0200 Subject: [PATCH 32/32] Use updated fill method --- src/display_service.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/display_service.cpp b/src/display_service.cpp index d8b782d..0aa94ee 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -108,7 +108,7 @@ void bbepSleep(BBEPDISP *pBBEP, int iMode); void bbepSetAddrWindow(BBEPDISP *pBBEP, int x, int y, int cx, int cy); void bbepStartWrite(BBEPDISP *pBBEP, int iPlane); void bbepWriteData(BBEPDISP *pBBEP, uint8_t *pData, int iLen); -void bbepFill(BBEPDISP *pBBEP, uint8_t pattern); +void bbepFill(BBEPDISP *pBBEP, unsigned char ucColor, int iPlane); bool bbepIsBusy(BBEPDISP *pBBEP); void flashLed(uint8_t color, uint8_t brightness); static void cleanup_partial_write_state(void); @@ -1576,7 +1576,8 @@ static void partial_prepare_panel_ram(void) { bbepInitIO(&bbep, globalConfig.displays[0].dc_pin, globalConfig.displays[0].reset_pin, globalConfig.displays[0].busy_pin, globalConfig.displays[0].cs_pin, globalConfig.displays[0].data_pin, globalConfig.displays[0].clk_pin, 8000000); bbepWakeUp(&bbep); bbepSendCMDSequence(&bbep, bbep.pInitFull); - bbepFill(&bbep, 0xF7); + bbepFill(&bbep, BBEP_WHITE, PLANE_1); + bbepFill(&bbep, BBEP_WHITE, PLANE_0); } static bool partial_write_to_panel(int refreshMode) {