Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions custom_components/opendisplay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,37 @@ def _write_cache(
)


async def _refresh_config_from_device(
hass: HomeAssistant,
entry: OpenDisplayConfigEntry,
device: OpenDisplayDevice,
) -> GlobalConfig | None:
"""Interrogate an already-connected device and refresh runtime + cache.

``OpenDisplayDevice`` skips live interrogation whenever it was constructed
with a pre-supplied ``config=`` which every connection in this
integration does. This avoids a redundant read when the cached config is
still accurate. ``interrogate()`` is cheap and idempotent, so calling it
here on every connection is the only way to catch config drift that
happened without a device reboot (the only other trigger for a refresh).
Returns the fresh config, or None if the device didn't return one.
"""
await device.interrogate()
live_config = device.config
if live_config is None:
return None
fw = await device.read_firmware_version()
is_flex = device.is_flex
landing_url = device.landing_url()
runtime = entry.runtime_data
runtime.firmware = fw
runtime.device_config = live_config
runtime.is_flex = is_flex
runtime.config_resync_pending = False
_write_cache(hass, entry, live_config, fw, is_flex, landing_url)
return live_config


def _cache_setup_if_sleepy(
entry: OpenDisplayConfigEntry,
) -> _CachedState | None:
Expand Down
3 changes: 3 additions & 0 deletions custom_components/opendisplay/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,6 @@
# --- Bus events -------------------------------------------------------------
EVENT_CONTENT_DELIVERED = f"{DOMAIN}_content_delivered"
EVENT_CONTENT_EXPIRED = f"{DOMAIN}_content_expired"
# Fired when a queued upload is dropped because the device's live display
# config no longer matches the config the image was prepared against.
EVENT_CONTENT_CONFIG_MISMATCH = f"{DOMAIN}_content_config_mismatch"
112 changes: 88 additions & 24 deletions custom_components/opendisplay/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
AuthenticationRequiredError,
BLEConnectionError,
BLETimeoutError,
GlobalConfig,
OpenDisplayDevice,
OpenDisplayError,
PartialState,
Expand All @@ -50,6 +51,7 @@
CONF_MAX_QUEUE_SIZE,
DEFAULT_BLOCKS_PER_ACK,
DEFAULT_MAX_QUEUE_SIZE,
EVENT_CONTENT_CONFIG_MISMATCH,
EVENT_CONTENT_DELIVERED,
EVENT_CONTENT_EXPIRED,
SIGNAL_IMAGE_UPDATED,
Expand Down Expand Up @@ -80,6 +82,36 @@
_KEY_INVALID = object()


@dataclass(frozen=True)
class DisplayFingerprint:
"""The subset of a display's config that determines image encoding.

Used to detect drift between the config an image was prepared against and
the device's current live config, e.g. a `color_scheme` change made
without a reboot. A reboot-triggered resync would otherwise miss this.
Deliberately narrow: comparing the whole `GlobalConfig` would false-positive
on unrelated field changes (LEDs, WiFi, sensors, ...) and drop good uploads.
"""

pixel_width: int
pixel_height: int
color_scheme: int
panel_ic_type: int
rotation: int


def display_fingerprint(config: GlobalConfig) -> DisplayFingerprint:
"""Extract the encoding-relevant fingerprint from a device's config."""
display = config.displays[0]
return DisplayFingerprint(
pixel_width=display.pixel_width,
pixel_height=display.pixel_height,
color_scheme=display.color_scheme,
panel_ic_type=display.panel_ic_type,
rotation=display.rotation,
)


@dataclass
class PendingUpload:
"""A prepared image queued for delivery at the next wake."""
Expand All @@ -94,6 +126,9 @@ class PendingUpload:
device_id: str | None
queued_at: float
expires_at: float
# The display config `prepared` was built against — compared against the
# live device at drain time so a stale-encoded frame is never sent.
fingerprint: DisplayFingerprint
attempts: int = 0
cancel_deadline: CALLBACK_TYPE | None = None

Expand Down Expand Up @@ -202,6 +237,7 @@ def submit_upload(
use_measured_palettes: bool,
preview_jpeg: bytes,
device_id: str | None,
fingerprint: DisplayFingerprint,
) -> DeliveryReceipt:
"""Queue a prepared image for delivery at the next wake (latest-wins)."""
now = time.time()
Expand All @@ -218,6 +254,7 @@ def submit_upload(
device_id=device_id,
queued_at=now,
expires_at=expires_at,
fingerprint=fingerprint,
)
self._schedule_expiry(slot)
self._pending_upload = slot
Expand Down Expand Up @@ -347,14 +384,32 @@ async def _drain_once(self) -> None:
}

async def _run(device: OpenDisplayDevice) -> None:
# Always refresh first, unconditionally: a config change made
# without a device reboot is otherwise never picked up (the only
# other trigger is the reboot-advertised resync flag), and
# draining a stale-encoded upload before a pending resync ran was
# the original ordering bug this replaces. Local import avoids an
# import cycle (__init__ imports this module).
from . import _refresh_config_from_device

live_config = await _refresh_config_from_device(
self._hass, self._entry, device
)
if live_config is not None:
self._pending_config_resync = False

# Re-read pending state each invocation: if a WiFi attempt uploaded
# then failed on resync, the BLE fallback re-runs this and must not
# re-upload an already-delivered frame (``_drain_upload`` clears it).
pending = self._pending_upload
if pending is not None:
await self._drain_upload(device, pending)
if self._pending_config_resync:
await self._drain_resync(device)
if pending is None:
return
if live_config is not None:
live_fingerprint = display_fingerprint(live_config)
if live_fingerprint != pending.fingerprint:
self._drop_mismatched_upload(pending, live_fingerprint)
return
await self._drain_upload(device, pending)

# Prefer WiFi when the entry has a fresh mDNS host; fall back to BLE on any
# WiFi failure. All inside the per-MAC lock (MAC-keyed, transport-neutral).
Expand Down Expand Up @@ -394,26 +449,6 @@ async def _drain_upload(
self._notify_state()
_LOGGER.info("%s: queued content delivered", self._address)

async def _drain_resync(self, device: OpenDisplayDevice) -> None:
"""Re-read firmware/config over the open link and refresh the cache."""
# Local import avoids an import cycle (__init__ imports this module).
from . import _write_cache

fw = await device.read_firmware_version()
is_flex = device.is_flex
landing_url = device.landing_url()
device_config = device.config
if device_config is None:
return
runtime = self._entry.runtime_data
runtime.firmware = fw
runtime.device_config = device_config
runtime.is_flex = is_flex
runtime.config_resync_pending = False
self._pending_config_resync = False
_write_cache(self._hass, self._entry, device_config, fw, is_flex, landing_url)
_LOGGER.debug("%s: config resync complete", self._address)

# -- expiry -------------------------------------------------------------

@callback
Expand Down Expand Up @@ -466,6 +501,35 @@ def _give_up_upload(self, slot: PendingUpload, reason: str) -> None:
self._fire_content_event(EVENT_CONTENT_EXPIRED, slot)
self._notify_state()

@callback
def _drop_mismatched_upload(
self, slot: PendingUpload, live_fingerprint: DisplayFingerprint
) -> None:
"""Drop a queued upload whose fingerprint no longer matches the live device.

The device's display config changed (e.g. `color_scheme` edited
without a reboot) since this frame was prepared, so its encoded bytes
no longer match what the panel expects — sending it would corrupt the
display. Unlike a live/immediate upload, there is no source image
available here to re-render from (``PendingUpload`` only retains the
already-encoded/dithered result), so the safest option is to drop it
and let the next scheduled push re-prepare against the current config.
"""
if slot.cancel_deadline:
slot.cancel_deadline()
slot.cancel_deadline = None
self._pending_upload = None
self._last_error = "config_mismatch"
_LOGGER.warning(
"%s: dropping queued upload - device config changed since the image "
"was prepared (prepared for %s, device is now %s)",
self._address,
slot.fingerprint,
live_fingerprint,
)
self._fire_content_event(EVENT_CONTENT_CONFIG_MISMATCH, slot)
self._notify_state()

# -- helpers ------------------------------------------------------------

@callback
Expand Down
47 changes: 45 additions & 2 deletions custom_components/opendisplay/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
DOMAIN,
SIGNAL_IMAGE_UPDATED,
)
from .delivery import DELIVERY_DEADLINE_S, DeliveryReceipt
from .delivery import DELIVERY_DEADLINE_S, DeliveryReceipt, display_fingerprint
from .transport import async_run_with_fallback

ATTR_IMAGE = "image"
Expand Down Expand Up @@ -624,6 +624,10 @@ async def _async_send_image(
use_measured_palettes=use_measured_palettes,
)
)
# The config `prepared` was built against — compared against the live
# device right before sending, since it can drift without a reboot (the
# only event that otherwise triggers a resync).
fingerprint = display_fingerprint(config)

# Partial refreshes diff against the entry's tracked frame; full/fast
# refreshes re-baseline the panel, so start a fresh state that this upload
Expand Down Expand Up @@ -653,11 +657,50 @@ def _queue() -> DeliveryReceipt:
use_measured_palettes=use_measured_palettes,
preview_jpeg=jpeg,
device_id=device_id,
fingerprint=fingerprint,
)

async def _upload(device: OpenDisplayDevice) -> None:
# Refresh from this live connection (config drift is otherwise only
# caught on a device reboot) and, if the device's config no longer
# matches what `prepared` was encoded for, re-render against the
# live config rather than send a corrupted frame. Unlike the queued
# path, the original `img` is still in scope here, so this can be
# corrected transparently instead of failing the upload.
from . import _refresh_config_from_device

live_config = await _refresh_config_from_device(hass, entry, device)
to_send = prepared
if live_config is not None:
live_fingerprint = display_fingerprint(live_config)
if live_fingerprint != fingerprint:
live_display_cfg = live_config.displays[0]
live_supports_compression = (
live_display_cfg.supports_zip
or live_display_cfg.supports_streaming_decompression
)
_LOGGER.warning(
"%s: device config changed since the image was prepared "
"(prepared for %s, now %s); re-rendering before upload",
entry.unique_id,
fingerprint,
live_fingerprint,
)
to_send = await hass.async_add_executor_job(
functools.partial(
prepare_image,
img,
config=live_config,
dither_mode=dither_mode,
compress=live_supports_compression,
tone=tone,
fit=fit,
rotate=rotate,
use_measured_palettes=use_measured_palettes,
)
)
await device.upload_prepared_image(
prepared, refresh_mode=refresh_mode, state=state
to_send, refresh_mode=refresh_mode, state=state
)

# Freshness gate: a probably-asleep tag will not usually answer a live
Expand Down
2 changes: 2 additions & 0 deletions tests/test_binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ async def test_update_pending_turns_on_when_content_is_queued(
use_measured_palettes=False,
preview_jpeg=b"jpeg",
device_id=None,
fingerprint=MagicMock(),
)
await hass.async_block_till_done()

Expand Down Expand Up @@ -87,6 +88,7 @@ async def test_update_pending_survives_a_dark_device(
use_measured_palettes=False,
preview_jpeg=b"jpeg",
device_id=None,
fingerprint=MagicMock(),
)
await hass.async_block_till_done()

Expand Down
Loading
Loading