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
8 changes: 8 additions & 0 deletions custom_components/opendisplay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -302,13 +305,17 @@ 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,
device_config=device_config,
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
Expand All @@ -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)
Expand Down
92 changes: 87 additions & 5 deletions custom_components/opendisplay/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,6 +56,7 @@
SIGNAL_IMAGE_UPDATED,
SIGNAL_PENDING_STATE,
)
from .storage import StoredPendingUpload

if TYPE_CHECKING:
from . import OpenDisplayConfigEntry
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -393,14 +429,15 @@ 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
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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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)
Expand Down
45 changes: 38 additions & 7 deletions custom_components/opendisplay/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)]
)


Expand All @@ -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]:
Expand Down Expand Up @@ -94,12 +103,34 @@ 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
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,
)
34 changes: 31 additions & 3 deletions custom_components/opendisplay/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Loading
Loading