diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index c4d777a..2b553fe 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -38,6 +38,7 @@ from .delivery import DeliveryManager from .services import async_setup_services from .sleep import SleepProfile +from .storage import OpenDisplayContentStore if TYPE_CHECKING: from opendisplay.models import FirmwareVersion @@ -83,6 +84,8 @@ class OpenDisplayRuntimeData: # Set when the entry was set up from cache without connecting; the delivery # manager re-reads firmware/config at the next wake and refreshes the cache. config_resync_pending: bool = False + # Persists the displayed content preview and one latest-wins queued upload. + content_store: OpenDisplayContentStore | None = None # Owns queued work delivered at the next wake (set in async_setup_entry). delivery: DeliveryManager | None = None @@ -302,6 +305,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) configuration_url=landing_url, ) + content_store = OpenDisplayContentStore(hass, entry.entry_id) + await content_store.async_load() + entry.runtime_data = OpenDisplayRuntimeData( coordinator=coordinator, firmware=fw, @@ -309,6 +315,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) is_flex=is_flex, sleep_profile=profile, config_resync_pending=from_cache, + content_store=content_store, ) # Persist a fresh interrogation so the next dark startup can set up from @@ -326,6 +333,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) manager = DeliveryManager(hass, entry) entry.runtime_data.delivery = manager + manager.restore_pending_upload(content_store.pending_upload) await hass.config_entries.async_forward_entry_setups( entry, _get_platforms(entry.runtime_data) diff --git a/custom_components/opendisplay/delivery.py b/custom_components/opendisplay/delivery.py index 8378b89..2511369 100644 --- a/custom_components/opendisplay/delivery.py +++ b/custom_components/opendisplay/delivery.py @@ -14,7 +14,8 @@ pending work drains over a single session in priority order (upload first). * Transport-agnostic trigger — `notify_device_seen(source)` is the entry point; today the BLE coordinator calls it, a future WiFi presence tracker could too. -* Memory-only in v1 — a HA restart drops a pending upload (documented). +* Persistent queued upload — a HA restart reloads the latest queued image and + keeps waiting for the next wake, respecting the original expiry timestamp. """ from __future__ import annotations @@ -55,6 +56,7 @@ SIGNAL_IMAGE_UPDATED, SIGNAL_PENDING_STATE, ) +from .storage import StoredPendingUpload if TYPE_CHECKING: from . import OpenDisplayConfigEntry @@ -133,7 +135,10 @@ def __init__(self, hass: HomeAssistant, entry: OpenDisplayConfigEntry) -> None: self._pending_upload: PendingUpload | None = None self._pending_config_resync: bool = False - self._last_error: str | None = None + content_store = getattr(runtime, "content_store", None) + self._last_error: str | None = ( + content_store.content.last_error if content_store is not None else None + ) self._delivering: bool = False self._delivery_task: asyncio.Task[None] | None = None @@ -213,6 +218,7 @@ def submit_upload( self._schedule_expiry(slot) self._pending_upload = slot self._last_error = None + self._store_pending_upload(slot) # Show the intended frame immediately (the image entity now reflects # what will be delivered, not what is on the panel). async_dispatcher_send( @@ -221,6 +227,30 @@ def submit_upload( self._notify_state() return DeliveryReceipt(status="queued", expires_at=expires_at) + @callback + def restore_pending_upload(self, upload: StoredPendingUpload | None) -> None: + """Restore a persisted queued upload, if it has not expired.""" + if upload is None: + return + if upload.expires_at <= time.time(): + self._last_error = "expired" + self._persist_pending_snapshot() + return + self._pending_upload = PendingUpload( + prepared=upload.prepared, + refresh_mode=upload.refresh_mode, + partial_state=upload.partial_state, + use_measured_palettes=upload.use_measured_palettes, + preview_jpeg=upload.preview_jpeg, + device_id=upload.device_id, + queued_at=upload.queued_at, + expires_at=upload.expires_at, + attempts=upload.attempts, + paused=upload.paused, + ) + self._schedule_expiry(self._pending_upload) + self._notify_state() + @callback def request_config_resync(self) -> None: """Request a firmware/config re-read at the next wake.""" @@ -287,9 +317,11 @@ async def _deliver(self) -> None: except (AuthenticationFailedError, AuthenticationRequiredError): # Bad/rotated key: pause the upload and prompt the user to reauth. _LOGGER.warning("%s: delivery auth failed; starting reauth", self._address) + self._last_error = "auth" if self._pending_upload is not None: self._pending_upload.paused = True - self._last_error = "auth" + self._store_pending_upload(self._pending_upload) + self._persist_pending_snapshot() self._entry.async_start_reauth(self._hass) self._notify_state() except OpenDisplayError as err: @@ -350,8 +382,12 @@ async def _drain_upload( ) if upload.cancel_deadline: upload.cancel_deadline() - self._pending_upload = None + delivered_current = self._pending_upload is upload + if delivered_current: + self._pending_upload = None self._last_error = None + if delivered_current: + self._clear_pending_upload() # Bump the image entity's timestamp to when the frame actually landed. async_dispatcher_send( self._hass, f"{SIGNAL_IMAGE_UPDATED}_{self._address}", upload.preview_jpeg @@ -393,7 +429,7 @@ def _expired(_now: Any) -> None: self._expire_upload(slot) slot.cancel_deadline = async_call_later( - self._hass, self._profile.queue_timeout_s, _expired + self._hass, max(0.0, slot.expires_at - time.time()), _expired ) @callback @@ -401,6 +437,7 @@ def _expire_upload(self, slot: PendingUpload) -> None: """Drop an expired queued upload and fire the expired event.""" self._pending_upload = None self._last_error = "expired" + self._clear_pending_upload(last_error="expired") _LOGGER.warning( "%s: queued content expired after %s h without a wake", self._address, @@ -423,6 +460,7 @@ def _give_up_upload(self, slot: PendingUpload, reason: str) -> None: slot.cancel_deadline = None self._pending_upload = None self._last_error = "failed" + self._clear_pending_upload(last_error="failed") _LOGGER.error( "%s: giving up queued content after %s failed delivery attempts (%s)", self._address, @@ -447,7 +485,9 @@ def _register_attempt_failure(self, reason: str) -> None: if upload.attempts >= MAX_DELIVERY_ATTEMPTS: self._give_up_upload(upload, reason) return + self._store_pending_upload(upload) self._last_error = reason + self._persist_pending_snapshot() _LOGGER.debug( "%s: delivery attempt %s/%s failed (%s); will retry next wake", self._address, @@ -457,6 +497,48 @@ def _register_attempt_failure(self, reason: str) -> None: ) self._notify_state() + @callback + def _store_pending_upload(self, slot: PendingUpload) -> None: + """Persist the current queued upload slot.""" + store = getattr(self._entry.runtime_data, "content_store", None) + if store is None: + return + store.store_pending_upload( + StoredPendingUpload( + prepared=slot.prepared, + refresh_mode=slot.refresh_mode, + partial_state=slot.partial_state, + use_measured_palettes=slot.use_measured_palettes, + preview_jpeg=slot.preview_jpeg, + device_id=slot.device_id, + queued_at=slot.queued_at, + expires_at=slot.expires_at, + attempts=slot.attempts, + paused=slot.paused, + ) + ) + + @callback + def _clear_pending_upload(self, *, last_error: str | None = None) -> None: + """Clear the persisted queued upload slot.""" + store = getattr(self._entry.runtime_data, "content_store", None) + if store is not None: + store.clear_pending_upload(last_error=last_error) + + @callback + def _persist_pending_snapshot(self) -> None: + """Persist queue attributes for entity restore.""" + store = getattr(self._entry.runtime_data, "content_store", None) + if store is not None: + state = self.state + store.update_pending_snapshot( + pending=state.pending, + queued_at=state.queued_at, + expires_at=state.expires_at, + attempts=state.attempts, + last_error=state.last_error, + ) + def _resolve_key(self) -> bytes | None | object: """Return the encryption key bytes, None, or a sentinel on bad format.""" raw = self._entry.data.get(CONF_ENCRYPTION_KEY) diff --git a/custom_components/opendisplay/image.py b/custom_components/opendisplay/image.py index fbb0428..9dd36ab 100644 --- a/custom_components/opendisplay/image.py +++ b/custom_components/opendisplay/image.py @@ -12,8 +12,8 @@ from . import OpenDisplayConfigEntry from .const import SIGNAL_IMAGE_UPDATED, SIGNAL_PENDING_STATE -from .coordinator import OpenDisplayCoordinator from .delivery import DeliverySnapshot +from .storage import OpenDisplayContentStore PARALLEL_UPDATES = 0 @@ -25,7 +25,7 @@ async def async_setup_entry( ) -> None: """Set up the OpenDisplay image entity.""" async_add_entities( - [OpenDisplayImageEntity(hass, entry.runtime_data.coordinator)] + [OpenDisplayImageEntity(hass, entry)] ) @@ -43,20 +43,29 @@ class OpenDisplayImageEntity(ImageEntity): _attr_translation_key = "content" _attr_content_type = "image/jpeg" - def __init__(self, hass: HomeAssistant, coordinator: OpenDisplayCoordinator) -> None: + def __init__(self, hass: HomeAssistant, entry: OpenDisplayConfigEntry) -> None: """Initialize the image entity.""" super().__init__(hass) + coordinator = entry.runtime_data.coordinator self._coordinator = coordinator + self._store: OpenDisplayContentStore | None = entry.runtime_data.content_store self._attr_unique_id = f"{coordinator.address}-display_content" self._attr_device_info = DeviceInfo( connections={(CONNECTION_BLUETOOTH, coordinator.address)}, ) - self._image_bytes: bytes | None = None + stored = self._store.content if self._store is not None else None + self._image_bytes: bytes | None = stored.image_jpeg if stored else None # When True the shown frame is queued for the next wake, not yet on the # panel (D6). - self._pending: bool = False - self._queued_at: float | None = None - self._last_error: str | None = None + self._pending: bool = stored.pending if stored else False + self._queued_at: float | None = stored.queued_at if stored else None + self._expires_at: float | None = stored.expires_at if stored else None + self._attempts: int = stored.attempts if stored else 0 + self._last_error: str | None = stored.last_error if stored else None + if stored and stored.image_last_updated is not None: + self._attr_image_last_updated = datetime.fromtimestamp( + stored.image_last_updated, tz=timezone.utc + ) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -94,6 +103,7 @@ def _handle_image_update(self, image_bytes: bytes) -> None: """Handle a new image from a completed or queued upload.""" self._image_bytes = image_bytes self._attr_image_last_updated = dt_util.utcnow() + self._store_content() self.async_write_ha_state() @callback @@ -101,5 +111,26 @@ def _handle_pending_state(self, snapshot: DeliverySnapshot) -> None: """Reflect the delivery manager's pending state on the entity.""" self._pending = snapshot.pending self._queued_at = snapshot.queued_at + self._expires_at = snapshot.expires_at + self._attempts = snapshot.attempts self._last_error = snapshot.last_error + self._store_content() self.async_write_ha_state() + + @callback + def _store_content(self) -> None: + """Persist the current image entity state.""" + if self._store is None or self._image_bytes is None: + return + last_updated = self._attr_image_last_updated + self._store.store_content( + self._image_bytes, + image_last_updated=last_updated.timestamp() + if last_updated is not None + else datetime.now(tz=timezone.utc).timestamp(), + pending=self._pending, + queued_at=self._queued_at, + expires_at=self._expires_at, + attempts=self._attempts, + last_error=self._last_error, + ) diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index 8107ed0..46a8270 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -19,11 +19,15 @@ PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + STATE_UNAVAILABLE, + STATE_UNKNOWN, UnitOfElectricPotential, UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +import homeassistant.util.dt as dt_util from . import OpenDisplayConfigEntry from .coordinator import OpenDisplayUpdate @@ -138,9 +142,23 @@ def native_value(self) -> float | int | str | datetime | None: return self.entity_description.value_fn(self.coordinator.data) -class OpenDisplayLastSeenSensor(OpenDisplaySensorEntity): +class OpenDisplayLastSeenSensor(OpenDisplaySensorEntity, RestoreEntity): """last_seen sourced from the bluetooth stack, not the gated callback.""" + def __init__( + self, + coordinator, + description: OpenDisplaySensorEntityDescription, + ) -> None: + """Initialize the last_seen sensor.""" + super().__init__(coordinator, description) + self._last_seen: datetime | None = None + + @property + def available(self) -> bool: + """Stay available once a last_seen value has been observed.""" + return self._last_seen is not None or super().available + @property def native_value(self) -> datetime | None: # connectable=False matches the Bluetooth advertisement monitor's @@ -150,8 +168,18 @@ def native_value(self) -> datetime | None: self.hass, self.coordinator.address, connectable=False ) if info is None: - return None + return self._last_seen # info.time is a monotonic clock (monotonic_time_coarse); convert to # wall time with the same offset the advertisement monitor uses. wall = info.time + (time.time() - time.monotonic()) - return datetime.fromtimestamp(wall, tz=timezone.utc) + self._last_seen = datetime.fromtimestamp(wall, tz=timezone.utc) + return self._last_seen + + async def async_added_to_hass(self) -> None: + """Restore the previous last_seen timestamp until Bluetooth has one.""" + await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is None: + return + if last_state.state in {STATE_UNKNOWN, STATE_UNAVAILABLE}: + return + self._last_seen = dt_util.parse_datetime(last_state.state) diff --git a/custom_components/opendisplay/storage.py b/custom_components/opendisplay/storage.py new file mode 100644 index 0000000..10654f7 --- /dev/null +++ b/custom_components/opendisplay/storage.py @@ -0,0 +1,285 @@ +"""Persistent content storage for OpenDisplay entries.""" + +from __future__ import annotations + +from base64 import b64decode, b64encode +from dataclasses import dataclass, replace +from io import BytesIO +import time +from typing import Any + +from opendisplay import PartialState, RefreshMode +from PIL import Image as PILImage + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.storage import Store + +from .const import DOMAIN + +STORAGE_VERSION = 1 +# Always-on devices usually drain a queued image quickly; delaying the save avoids +# persisting transient queue state that is cleared almost immediately. Sleeping +# devices keep the queue long enough that the delayed write makes it durable. +CONTENT_IMAGE_SAVE_DELAY = 60.0 +# Clearing an already-persisted queue slot is cheap and should land promptly so a +# crash does not resurrect already-delivered content on the next startup. +CONTENT_EMPTY_SAVE_DELAY = 1.0 + + +@dataclass(frozen=True) +class StoredContent: + """Last image shown by the content image entity.""" + + image_jpeg: bytes | None = None + image_last_updated: float | None = None + pending: bool = False + queued_at: float | None = None + expires_at: float | None = None + attempts: int = 0 + last_error: str | None = None + + +@dataclass(frozen=True) +class StoredPendingUpload: + """Queued upload state that can be restored after HA restarts.""" + + prepared: tuple[bytes, bytes | None, PILImage.Image] + refresh_mode: RefreshMode + partial_state: PartialState + use_measured_palettes: bool + preview_jpeg: bytes + device_id: str | None + queued_at: float + expires_at: float + attempts: int = 0 + paused: bool = False + + +class OpenDisplayContentStore: + """Versioned Store wrapper for the entry's content and queued upload.""" + + def __init__(self, hass: HomeAssistant, entry_id: str) -> None: + """Initialize the store.""" + self._store: Store[dict[str, Any]] = Store( + hass, + STORAGE_VERSION, + f"{DOMAIN}.{entry_id}.content", + private=True, + atomic_writes=True, + serialize_in_event_loop=False, + ) + self.content = StoredContent() + self.pending_upload: StoredPendingUpload | None = None + + async def async_load(self) -> None: + """Load persisted content and drop an expired pending upload.""" + raw = await self._store.async_load() + if not raw: + return + + self.content = _decode_content(raw.get("content")) + self.pending_upload = _decode_pending_upload(raw.get("pending_upload")) + + now = time.time() + if self.pending_upload is not None and self.pending_upload.expires_at <= now: + self.pending_upload = None + self.content = replace( + self.content, + pending=False, + queued_at=None, + expires_at=None, + attempts=0, + last_error="expired", + ) + await self._store.async_save(self._serialize()) + + @callback + def store_content( + self, + image_jpeg: bytes, + *, + image_last_updated: float, + pending: bool, + queued_at: float | None, + expires_at: float | None, + attempts: int, + last_error: str | None, + ) -> None: + """Persist the image entity's current content state.""" + content = StoredContent( + image_jpeg=image_jpeg, + image_last_updated=image_last_updated, + pending=pending, + queued_at=queued_at, + expires_at=expires_at, + attempts=attempts, + last_error=last_error, + ) + if content == self.content: + return + self.content = content + self._schedule_save() + + @callback + def store_pending_upload(self, upload: StoredPendingUpload) -> None: + """Persist a queued upload.""" + if upload == self.pending_upload: + return + self.pending_upload = upload + self._schedule_save() + + @callback + def clear_pending_upload(self, *, last_error: str | None = None) -> None: + """Clear the persisted upload slot and mark content no longer pending.""" + self.pending_upload = None + self.content = replace( + self.content, + pending=False, + queued_at=None, + expires_at=None, + attempts=0, + last_error=last_error, + ) + self._schedule_save(CONTENT_EMPTY_SAVE_DELAY) + + @callback + def update_pending_snapshot( + self, + *, + pending: bool, + queued_at: float | None, + expires_at: float | None, + attempts: int, + last_error: str | None, + ) -> None: + """Persist queue attributes without changing the stored image bytes.""" + content = replace( + self.content, + pending=pending, + queued_at=queued_at, + expires_at=expires_at, + attempts=attempts, + last_error=last_error, + ) + if content == self.content: + return + self.content = content + self._schedule_save() + + @callback + def _schedule_save(self, delay: float = CONTENT_IMAGE_SAVE_DELAY) -> None: + """Schedule a storage write for the current state.""" + self._store.async_delay_save(self._serialize, delay) + + def _serialize(self) -> dict[str, Any]: + """Serialize the current store state.""" + return { + "content": _encode_content(self.content), + "pending_upload": _encode_pending_upload(self.pending_upload), + } + + +def _b64(data: bytes | None) -> str | None: + """Encode bytes to a JSON string.""" + return b64encode(data).decode("ascii") if data is not None else None + + +def _unb64(data: str | None) -> bytes | None: + """Decode bytes from a JSON string.""" + return b64decode(data.encode("ascii")) if data is not None else None + + +def _image_to_png(image: PILImage.Image) -> str: + """Serialize a processed image losslessly.""" + buf = BytesIO() + image.save(buf, format="PNG") + return _b64(buf.getvalue()) or "" + + +def _image_from_png(data: str) -> PILImage.Image: + """Load a processed image from storage.""" + image = PILImage.open(BytesIO(b64decode(data.encode("ascii")))) + image.load() + return image + + +def _encode_content(content: StoredContent) -> dict[str, Any]: + """Encode StoredContent to JSON-compatible data.""" + return { + "image_jpeg": _b64(content.image_jpeg), + "image_last_updated": content.image_last_updated, + "pending": content.pending, + "queued_at": content.queued_at, + "expires_at": content.expires_at, + "attempts": content.attempts, + "last_error": content.last_error, + } + + +def _decode_content(raw: Any) -> StoredContent: + """Decode StoredContent from storage.""" + if not isinstance(raw, dict): + return StoredContent() + try: + return StoredContent( + image_jpeg=_unb64(raw.get("image_jpeg")), + image_last_updated=raw.get("image_last_updated"), + pending=bool(raw.get("pending", False)), + queued_at=raw.get("queued_at"), + expires_at=raw.get("expires_at"), + attempts=int(raw.get("attempts", 0)), + last_error=raw.get("last_error"), + ) + except (TypeError, ValueError): + return StoredContent() + + +def _encode_pending_upload(upload: StoredPendingUpload | None) -> dict[str, Any] | None: + """Encode a queued upload to JSON-compatible data.""" + if upload is None: + return None + uncompressed, compressed, processed = upload.prepared + return { + "uncompressed": _b64(uncompressed), + "compressed": _b64(compressed), + "processed_png": _image_to_png(processed), + "refresh_mode": int(upload.refresh_mode), + "partial_state": _b64(upload.partial_state.to_bytes()), + "use_measured_palettes": upload.use_measured_palettes, + "preview_jpeg": _b64(upload.preview_jpeg), + "device_id": upload.device_id, + "queued_at": upload.queued_at, + "expires_at": upload.expires_at, + "attempts": upload.attempts, + "paused": upload.paused, + } + + +def _decode_pending_upload(raw: Any) -> StoredPendingUpload | None: + """Decode a queued upload from storage.""" + if not isinstance(raw, dict): + return None + try: + uncompressed = _unb64(raw.get("uncompressed")) + preview_jpeg = _unb64(raw.get("preview_jpeg")) + partial_state = _unb64(raw.get("partial_state")) + if uncompressed is None or preview_jpeg is None or partial_state is None: + return None + return StoredPendingUpload( + prepared=( + uncompressed, + _unb64(raw.get("compressed")), + _image_from_png(raw["processed_png"]), + ), + refresh_mode=RefreshMode(raw["refresh_mode"]), + partial_state=PartialState.from_bytes(partial_state), + use_measured_palettes=bool(raw["use_measured_palettes"]), + preview_jpeg=preview_jpeg, + device_id=raw.get("device_id"), + queued_at=float(raw["queued_at"]), + expires_at=float(raw["expires_at"]), + attempts=int(raw.get("attempts", 0)), + paused=bool(raw.get("paused", False)), + ) + except (KeyError, TypeError, ValueError): + return None diff --git a/tests/test_delivery.py b/tests/test_delivery.py index 994d752..6ce7730 100644 --- a/tests/test_delivery.py +++ b/tests/test_delivery.py @@ -9,7 +9,12 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch -from opendisplay import AuthenticationFailedError, BLEConnectionError, RefreshMode +from opendisplay import ( + AuthenticationFailedError, + BLEConnectionError, + PartialState, + RefreshMode, +) import pytest from homeassistant.exceptions import HomeAssistantError @@ -25,6 +30,7 @@ ) from custom_components.opendisplay.delivery import DeliveryManager from custom_components.opendisplay.sleep import SleepProfile +from custom_components.opendisplay.storage import StoredPendingUpload ADDRESS = "AA:BB:CC:DD:EE:FF" @@ -171,6 +177,38 @@ def test_request_config_resync_sets_flags(): assert mgr._has_pending_work() is True +def test_restore_pending_upload_rearms_existing_expiry(): + hass, entry, _ = _make_env() + expires_at = 1234.0 + upload = StoredPendingUpload( + prepared=(b"img", None, MagicMock()), + refresh_mode=RefreshMode.FAST, + partial_state=PartialState(), + use_measured_palettes=True, + preview_jpeg=b"jpeg", + device_id="dev1", + queued_at=1000.0, + expires_at=expires_at, + attempts=2, + ) + + with ( + patch.object(delivery_mod.time, "time", return_value=1200.0), + patch.object(delivery_mod, "async_call_later", return_value=MagicMock()) as later, + patch.object(delivery_mod, "async_dispatcher_send") as dispatch, + ): + mgr = DeliveryManager(hass, entry) + mgr.restore_pending_upload(upload) + + assert mgr.state.pending is True + assert mgr.state.queued_at == 1000.0 + assert mgr.state.expires_at == expires_at + assert mgr.state.attempts == 2 + later.assert_called_once() + assert later.call_args.args[1] == 34.0 + dispatch.assert_called_once() + + def test_notify_device_seen_starts_one_delivery(): hass, entry, _ = _make_env() diff --git a/tests/test_last_seen.py b/tests/test_last_seen.py index 94af071..e9d3f6c 100644 --- a/tests/test_last_seen.py +++ b/tests/test_last_seen.py @@ -59,6 +59,20 @@ def test_native_value_none_when_no_service_info(): assert entity.native_value is None +def test_native_value_keeps_previous_timestamp_when_service_info_disappears(): + entity = _make_sensor() + mono = time.monotonic() + with patch.object( + sensor_mod, + "async_last_service_info", + return_value=SimpleNamespace(time=mono), + ): + first = entity.native_value + + with patch.object(sensor_mod, "async_last_service_info", return_value=None): + assert entity.native_value == first + + if __name__ == "__main__": import pytest diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..449fa20 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,70 @@ +"""Unit tests for OpenDisplay persisted content encoding.""" + +from opendisplay import PartialState, RefreshMode +from PIL import Image + +from custom_components.opendisplay.storage import ( + StoredContent, + StoredPendingUpload, + _decode_content, + _decode_pending_upload, + _encode_content, + _encode_pending_upload, +) + + +def test_pending_upload_round_trips_prepared_image_and_partial_state(): + """Persisted uploads keep enough data to resume without re-rendering.""" + processed = Image.new("P", (2, 2)) + partial = PartialState( + etag=123, + last_image=b"\x00\x01\x02\x03", + width=2, + height=2, + bytes_per_pixel=1, + ) + upload = StoredPendingUpload( + prepared=(b"raw", b"zip", processed), + refresh_mode=RefreshMode.PARTIAL, + partial_state=partial, + use_measured_palettes=True, + preview_jpeg=b"jpeg", + device_id="device-id", + queued_at=1000.0, + expires_at=2000.0, + attempts=3, + paused=True, + ) + + restored = _decode_pending_upload(_encode_pending_upload(upload)) + + assert restored is not None + assert restored.prepared[0] == b"raw" + assert restored.prepared[1] == b"zip" + assert restored.prepared[2].mode == "P" + assert restored.prepared[2].size == (2, 2) + assert restored.refresh_mode is RefreshMode.PARTIAL + assert restored.partial_state == partial + assert restored.use_measured_palettes is True + assert restored.preview_jpeg == b"jpeg" + assert restored.device_id == "device-id" + assert restored.queued_at == 1000.0 + assert restored.expires_at == 2000.0 + assert restored.attempts == 3 + assert restored.paused is True + + +def test_content_round_trips_image_entity_attributes(): + content = StoredContent( + image_jpeg=b"jpeg", + image_last_updated=1000.0, + pending=True, + queued_at=1000.0, + expires_at=2000.0, + attempts=2, + last_error="boom", + ) + + restored = _decode_content(_encode_content(content)) + + assert restored == content