From c01e46e9c487a0a0f27091aaa914c490e38fc751 Mon Sep 17 00:00:00 2001 From: gabriel Date: Mon, 6 Jul 2026 15:42:17 +0200 Subject: [PATCH] feat: partial refresh support via py-opendisplay 7.11.1 Adds "partial" as a refresh mode for upload_image and drawcustom. A PartialState per config entry tracks the last uploaded frame + etag; partial refreshes diff against it and update flicker-free via the 0x76 protocol, while full/fast refreshes re-baseline the state so the next partial diffs against the frame actually on the panel. All panel handling lives in the library: automatic fallback to a full upload when partial is not possible, automatic full-frame region expansion on panels that require it (partial_update_support=2, OpenDisplay/Firmware#80), and identical frames skip the transfer entirely. Legacy numeric refresh_type 2/3 map to partial. Also fixes the compression gate for streaming-decompression-only configs (bit 0x01 without ZIP): such panels uploaded every frame uncompressed. Requires py-opendisplay 7.11.1 (partial support, compression gates, and the epaper-dithering 5.0.8 tone=auto NaN fix without which bilevel drawcustom frames render all-black). --- custom_components/opendisplay/__init__.py | 5 +++ custom_components/opendisplay/manifest.json | 2 +- custom_components/opendisplay/services.py | 45 ++++++++++++------- custom_components/opendisplay/services.yaml | 2 + custom_components/opendisplay/strings.json | 7 +-- .../opendisplay/translations/en.json | 7 +-- 6 files changed, 46 insertions(+), 22 deletions(-) diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 2a0b18d..e024a8f 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -13,6 +13,7 @@ GlobalConfig, OpenDisplayDevice, OpenDisplayError, + PartialState, ) from homeassistant.components.bluetooth import ( @@ -55,6 +56,10 @@ class OpenDisplayRuntimeData: # library, so drawcustom/upload_image, LED, buzzer and OTA must not open # overlapping connections or they race and surface a confusing upload_error. ble_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + # Tracks the last uploaded frame + etag for differential partial updates + # (0x76). Replaced with a fresh instance on every full/fast refresh so the + # next partial diffs against the frame actually on the panel. + partial_state: PartialState = field(default_factory=PartialState) type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData] diff --git a/custom_components/opendisplay/manifest.json b/custom_components/opendisplay/manifest.json index d4dd05a..ade3dee 100644 --- a/custom_components/opendisplay/manifest.json +++ b/custom_components/opendisplay/manifest.json @@ -15,6 +15,6 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/OpenDisplay/Home_Assistant_Integration/issues", "loggers": ["opendisplay"], - "requirements": ["py-opendisplay[silabs-ota]==7.9.0", "odl-renderer==0.5.10"], + "requirements": ["py-opendisplay[silabs-ota]==7.11.1", "odl-renderer==0.5.10"], "version": "3.0.0-beta.7" } diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index e60b7b9..0a6b898 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -24,6 +24,7 @@ BuzzerActivateConfig, OpenDisplayDevice, OpenDisplayError, + PartialState, RefreshMode, Rotation, prepare_image, @@ -87,26 +88,24 @@ def _dither_value(value: Any) -> DitherMode: def _refresh_type_value(value: Any) -> RefreshMode: - """Accept names ("full"/"fast") and legacy numeric values. + """Accept names ("full"/"fast"/"partial") and legacy numeric values. - `partial` is not implemented yet, so it is not offered and any partial-ish - input (legacy 2/3, or an explicit "partial") falls back to fast. + Legacy value 3 ("partial2"/full-frame partial) maps to PARTIAL: the library + reads the panel's partial_update_support config and expands the region to + the full frame automatically where required, so the distinction is obsolete. """ if isinstance(value, (int, float)) or ( isinstance(value, str) and value.lstrip("-").isdigit() ): n = int(value) - if n in (2, 3): # legacy partial / partial2 -> fast (partial not implemented) - return RefreshMode.FAST + if n == 3: # legacy partial2 / full-frame partial + return RefreshMode.PARTIAL try: mode = RefreshMode(n) except ValueError as err: raise vol.Invalid(f"Invalid refresh_type: {value}") from err - else: - mode = _str_to_int_enum(RefreshMode)(value) - if mode is RefreshMode.PARTIAL: # reserved for the future, not implemented yet - return RefreshMode.FAST - return mode + return mode + return _str_to_int_enum(RefreshMode)(value) SCHEMA_UPLOAD_IMAGE = vol.Schema( @@ -119,7 +118,7 @@ def _refresh_type_value(value: Any) -> RefreshMode: vol.Coerce(int), vol.Coerce(Rotation) ), vol.Optional(ATTR_DITHER_MODE, default="burkes"): _str_to_int_enum(DitherMode), - vol.Optional(ATTR_REFRESH_MODE, default="full"): _str_to_int_enum(RefreshMode), + vol.Optional(ATTR_REFRESH_MODE, default="full"): _refresh_type_value, vol.Optional(ATTR_FIT_MODE, default="contain"): _str_to_int_enum(FitMode), vol.Optional(ATTR_TONE_COMPRESSION): vol.All( vol.Coerce(float), vol.Range(min=0.0, max=100.0) @@ -362,9 +361,14 @@ async def _async_send_image( config = entry.runtime_data.device_config display_cfg = config.displays[0] if config and config.displays else None # Match upload_image(): only ask prepare_image() to build compressed data - # when the panel actually supports zip. upload_prepared_image() then falls - # back to the uncompressed protocol when compressed_data is None. - supports_compression = display_cfg.supports_zip if display_cfg else True + # when the panel accepts compressed uploads (plain ZIP bit or the + # streaming-decompression bit). upload_prepared_image() then falls back to + # the uncompressed protocol when compressed_data is None. + supports_compression = ( + (display_cfg.supports_zip or display_cfg.supports_streaming_decompression) + if display_cfg + else True + ) prepared = await hass.async_add_executor_job( functools.partial( prepare_image, @@ -378,8 +382,19 @@ async def _async_send_image( ) ) + # Partial refreshes diff against the entry's tracked frame; full/fast + # refreshes re-baseline the panel, so start a fresh state that this upload + # seeds (etag + frame) for the next partial. The library handles all + # fallbacks (unsupported panel, etag mismatch, firmware NACK) and expands + # the region to the full frame on panels that require it + # (partial_update_support=2, see OpenDisplay/Firmware#80). + runtime = entry.runtime_data + if refresh_mode is not RefreshMode.PARTIAL: + runtime.partial_state = PartialState() + state = runtime.partial_state + async def _upload(device: OpenDisplayDevice) -> None: - await device.upload_prepared_image(prepared, refresh_mode=refresh_mode) + await device.upload_prepared_image(prepared, refresh_mode=refresh_mode, state=state) await _async_connect_and_run(hass, entry, _upload) jpeg = await hass.async_add_executor_job(_pil_to_jpeg, img) diff --git a/custom_components/opendisplay/services.yaml b/custom_components/opendisplay/services.yaml index de88a43..15d38c8 100644 --- a/custom_components/opendisplay/services.yaml +++ b/custom_components/opendisplay/services.yaml @@ -48,6 +48,7 @@ upload_image: options: - "full" - "fast" + - "partial" fit_mode: required: false default: "contain" @@ -308,6 +309,7 @@ drawcustom: options: - "full" - "fast" + - "partial" tone_compression: name: Tone compression description: Tone compression strength (0–100%). Omit to use automatic tone mapping. diff --git a/custom_components/opendisplay/strings.json b/custom_components/opendisplay/strings.json index 350d82b..280b2d6 100644 --- a/custom_components/opendisplay/strings.json +++ b/custom_components/opendisplay/strings.json @@ -158,7 +158,8 @@ "refresh_mode": { "options": { "fast": "Fast", - "full": "Full" + "full": "Full", + "partial": "Partial" } } }, @@ -183,7 +184,7 @@ "name": "Image" }, "refresh_mode": { - "description": "The display refresh mode. Full refresh clears ghosting but is slower. Fast refresh is not supported on all displays.", + "description": "The display refresh mode. Full refresh clears ghosting but is slower. Fast refresh is not supported on all displays. Partial updates only the changed region without flashing (B/W panels with partial support; falls back to a full refresh automatically when not possible).", "name": "Refresh mode" }, "rotation": { @@ -318,7 +319,7 @@ "name": "Dither mode" }, "refresh_type": { - "description": "Display refresh mode.", + "description": "Display refresh mode. Partial updates only the changed region without flashing; falls back to a full refresh automatically when not possible.", "name": "Refresh type" }, "dry-run": { diff --git a/custom_components/opendisplay/translations/en.json b/custom_components/opendisplay/translations/en.json index ca55005..f9c2181 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -155,7 +155,8 @@ "refresh_mode": { "options": { "fast": "Fast", - "full": "Full" + "full": "Full", + "partial": "Partial" } } }, @@ -180,7 +181,7 @@ "name": "Image" }, "refresh_mode": { - "description": "The display refresh mode. Full refresh clears ghosting but is slower. Fast refresh is not supported on all displays.", + "description": "The display refresh mode. Full refresh clears ghosting but is slower. Fast refresh is not supported on all displays. Partial updates only the changed region without flashing (B/W panels with partial support; falls back to a full refresh automatically when not possible).", "name": "Refresh mode" }, "rotation": { @@ -315,7 +316,7 @@ "name": "Dither mode" }, "refresh_type": { - "description": "Display refresh mode.", + "description": "Display refresh mode. Partial updates only the changed region without flashing; falls back to a full refresh automatically when not possible.", "name": "Refresh type" }, "dry-run": {