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
15 changes: 8 additions & 7 deletions custom_components/opendisplay/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ class PendingUpload:
preview_jpeg: bytes
device_id: str | None
queued_at: float
expires_at: float
expires_at: float | None
attempts: int = 0
# Set when a delivery hit an auth failure; suppresses further doomed
# attempts until the entry reloads after reauth.
Expand Down Expand Up @@ -193,13 +193,14 @@ def submit_upload(
use_measured_palettes: bool,
preview_jpeg: bytes,
device_id: str | None,
expire_seconds: float | None,
) -> DeliveryReceipt:
"""Queue a prepared image for delivery at the next wake (latest-wins)."""
now = time.time()
# Latest-wins: drop any previously queued image and its deadline timer.
if self._pending_upload is not None and self._pending_upload.cancel_deadline:
self._pending_upload.cancel_deadline()
expires_at = now + self._profile.queue_timeout_s
expires_at = None if expire_seconds is None else now + expire_seconds
slot = PendingUpload(
prepared=prepared,
refresh_mode=refresh_mode,
Expand All @@ -210,7 +211,8 @@ def submit_upload(
queued_at=now,
expires_at=expires_at,
)
self._schedule_expiry(slot)
if expire_seconds is not None:
self._schedule_expiry(slot, expire_seconds)
self._pending_upload = slot
self._last_error = None
# Show the intended frame immediately (the image entity now reflects
Expand Down Expand Up @@ -383,7 +385,7 @@ async def _drain_resync(self, device: OpenDisplayDevice) -> None:
# -- expiry -------------------------------------------------------------

@callback
def _schedule_expiry(self, slot: PendingUpload) -> None:
def _schedule_expiry(self, slot: PendingUpload, expire_seconds: float) -> None:
"""Arm the deadline timer that expires this queued upload."""

@callback
Expand All @@ -393,7 +395,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, expire_seconds, _expired
)

@callback
Expand All @@ -402,9 +404,8 @@ def _expire_upload(self, slot: PendingUpload) -> None:
self._pending_upload = None
self._last_error = "expired"
_LOGGER.warning(
"%s: queued content expired after %s h without a wake",
"%s: queued content expired without a wake",
self._address,
self._profile.queue_timeout_hours,
)
self._fire_content_event(EVENT_CONTENT_EXPIRED, slot)
self._notify_state()
Expand Down
28 changes: 24 additions & 4 deletions custom_components/opendisplay/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
ATTR_FIT_MODE = "fit_mode"
ATTR_TONE_COMPRESSION = "tone_compression"
ATTR_USE_MEASURED_PALETTES = "measured_palette"
ATTR_EXPIRE = "expire"


def _str_to_int_enum(enum_class: type[IntEnum]) -> Callable[[str], Any]:
Expand Down Expand Up @@ -142,6 +143,7 @@ def _refresh_type_value(value: Any) -> RefreshMode:
vol.Coerce(float), vol.Range(min=0.0, max=100.0)
),
vol.Optional(ATTR_USE_MEASURED_PALETTES, default=True): cv.boolean,
vol.Optional(ATTR_EXPIRE): cv.positive_time_period,
}
)

Expand All @@ -160,6 +162,7 @@ def _refresh_type_value(value: Any) -> RefreshMode:
vol.Coerce(float), vol.Range(min=0.0, max=100.0)
),
vol.Optional(ATTR_USE_MEASURED_PALETTES, default=False): cv.boolean,
vol.Optional(ATTR_EXPIRE): cv.positive_time_period,
vol.Optional("dry-run", default=False): cv.boolean,
},
extra=vol.REMOVE_EXTRA, # silently drop legacy keys (ttl, preload_type, preload_lut, ...)
Expand Down Expand Up @@ -462,6 +465,7 @@ async def _async_send_image(
tone: float | str = "auto",
rotate: Rotation = Rotation.ROTATE_0,
use_measured_palettes: bool = False,
expire_seconds: float | None,
) -> DeliveryReceipt:
"""Upload a PIL image, delivering live or queuing it for the next wake.

Expand Down Expand Up @@ -526,6 +530,7 @@ def _queue() -> DeliveryReceipt:
use_measured_palettes=use_measured_palettes,
preview_jpeg=jpeg,
device_id=device_id,
expire_seconds=expire_seconds,
)

async def _upload(device: OpenDisplayDevice) -> None:
Expand Down Expand Up @@ -596,6 +601,15 @@ def _receipt_response(receipt: DeliveryReceipt) -> ServiceResponse:
return {"status": receipt.status, "expires_at": expires_at}


def _expire_seconds(entry: "OpenDisplayConfigEntry", call: ServiceCall) -> float | None:
"""Resolve the service expiry option to seconds, or None for no expiry."""
expire = call.data.get(ATTR_EXPIRE)
if expire is None:
return entry.runtime_data.sleep_profile.queue_timeout_s
seconds = expire.total_seconds()
return None if seconds == 0 else seconds


async def _async_upload_image(call: ServiceCall) -> ServiceResponse:
"""Handle the upload_image service call."""
entry = _get_entry_for_device(call)
Expand All @@ -611,6 +625,7 @@ async def _async_upload_image(call: ServiceCall) -> ServiceResponse:
tone_compression_pct / 100.0 if tone_compression_pct is not None else "auto"
)
use_measured_palettes: bool = call.data[ATTR_USE_MEASURED_PALETTES]
expire_seconds = _expire_seconds(entry, call)

# A plain URL (e.g. an automation pushing a rendered snapshot) must be
# explicitly allowlisted; media-source items are already trusted.
Expand Down Expand Up @@ -662,6 +677,7 @@ async def _async_upload_image(call: ServiceCall) -> ServiceResponse:
tone=tone_compression,
rotate=rotation,
use_measured_palettes=use_measured_palettes,
expire_seconds=expire_seconds,
)
return _receipt_response(receipt)
except asyncio.CancelledError:
Expand Down Expand Up @@ -806,11 +822,13 @@ async def _async_drawcustom(call: ServiceCall) -> ServiceResponse:
)

# Summarize across all targets: any queued device makes the batch "queued"
# (with the soonest expiry); otherwise delivered (or dry_run).
queued = [r for r in receipts if r.status == "queued" and r.expires_at is not None]
# (with the soonest finite expiry, or None if all queued uploads are infinite);
# otherwise delivered (or dry_run).
queued = [r for r in receipts if r.status == "queued"]
if queued:
status = "queued"
expires_epoch: float | None = min(r.expires_at for r in queued) # type: ignore[type-var]
finite_expiries = [r.expires_at for r in queued if r.expires_at is not None]
expires_epoch: float | None = min(finite_expiries) if finite_expiries else None
elif receipts and all(r.status == "dry_run" for r in receipts):
status = "dry_run"
expires_epoch = None
Expand Down Expand Up @@ -881,6 +899,7 @@ async def _drawcustom_for_device(
tone_compression_pct / 100.0 if tone_compression_pct is not None else "auto"
)
use_measured_palettes: bool = call.data[ATTR_USE_MEASURED_PALETTES]
expire_seconds = _expire_seconds(entry, call)

return await _async_send_image(
hass,
Expand All @@ -892,6 +911,7 @@ async def _drawcustom_for_device(
tone=tone_compression,
rotate=Rotation(rotate),
use_measured_palettes=use_measured_palettes,
expire_seconds=expire_seconds,
)


Expand Down Expand Up @@ -997,4 +1017,4 @@ def async_setup_services(hass: HomeAssistant) -> None:
supports_response=SupportsResponse.OPTIONAL,
)
hass.services.async_register(DOMAIN, "activate_led", _async_activate_led, schema=SCHEMA_ACTIVATE_LED)
hass.services.async_register(DOMAIN, "activate_buzzer", _async_activate_buzzer, schema=SCHEMA_ACTIVATE_BUZZER)
hass.services.async_register(DOMAIN, "activate_buzzer", _async_activate_buzzer, schema=SCHEMA_ACTIVATE_BUZZER)
10 changes: 10 additions & 0 deletions custom_components/opendisplay/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ upload_image:
default: false
selector:
boolean:
expire:
required: false
selector:
duration:

activate_led:
fields:
Expand Down Expand Up @@ -333,6 +337,12 @@ drawcustom:
default: false
selector:
boolean:
expire:
name: Expire
description: How long queued content remains valid. Omit to use the integration default; set to 0 for no time-based expiry.
required: false
selector:
duration:
dry-run:
name: Dry run
description: Generate image without sending to device
Expand Down
8 changes: 8 additions & 0 deletions custom_components/opendisplay/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@
"measured_palette": {
"description": "Use the panel's measured color palette when available. Turn off to dither against the theoretical color scheme. No effect on panels without measured data.",
"name": "Use measured palette"
},
"expire": {
"description": "How long queued content remains valid. Leave empty to use the integration default; set to 0 for no time-based expiry.",
"name": "Expire"
}
},
"name": "Upload image",
Expand Down Expand Up @@ -370,6 +374,10 @@
"description": "Use the panel's measured color palette when available. Turn off to dither against the theoretical color scheme. No effect on panels without measured data.",
"name": "Use measured palette"
},
"expire": {
"description": "How long queued content remains valid. Leave empty to use the integration default; set to 0 for no time-based expiry.",
"name": "Expire"
},
"dry-run": {
"description": "Generate image without uploading to the device.",
"name": "Dry run"
Expand Down
8 changes: 8 additions & 0 deletions custom_components/opendisplay/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@
"measured_palette": {
"description": "Use the panel's measured color palette when available. Turn off to dither against the theoretical color scheme. No effect on panels without measured data.",
"name": "Use measured palette"
},
"expire": {
"description": "How long queued content remains valid. Leave empty to use the integration default; set to 0 for no time-based expiry.",
"name": "Expire"
}
},
"name": "Upload image",
Expand Down Expand Up @@ -367,6 +371,10 @@
"description": "Use the panel's measured color palette when available. Turn off to dither against the theoretical color scheme. No effect on panels without measured data.",
"name": "Use measured palette"
},
"expire": {
"description": "How long queued content remains valid. Leave empty to use the integration default; set to 0 for no time-based expiry.",
"name": "Expire"
},
"dry-run": {
"description": "Generate image without uploading to the device.",
"name": "Dry run"
Expand Down
38 changes: 37 additions & 1 deletion tests/test_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,15 @@ def _make_env(profile=None, entry_data=None, last_seen=None, options=None):
return hass, entry, coordinator


def _submit(mgr, device_id="dev1"):
def _submit(mgr, device_id="dev1", expire_seconds=86400.0):
return mgr.submit_upload(
prepared=(b"img", None, object()),
refresh_mode=RefreshMode.FULL,
partial_state=MagicMock(),
use_measured_palettes=False,
preview_jpeg=b"jpeg",
device_id=device_id,
expire_seconds=expire_seconds,
)


Expand Down Expand Up @@ -118,10 +119,27 @@ def test_submit_upload_queues_and_reports():
assert snap.queued_at is not None
assert snap.attempts == 0
later.assert_called_once() # deadline armed
assert later.call_args.args[1] == 86400.0
# Both the image preview and the pending-state signals were dispatched.
assert dispatch.call_count == 2


def test_submit_upload_with_infinite_expiry_queues_without_deadline():
hass, entry, _ = _make_env()
with (
patch.object(delivery_mod, "async_call_later") as later,
patch.object(delivery_mod, "async_dispatcher_send"),
):
mgr = DeliveryManager(hass, entry)
receipt = _submit(mgr, expire_seconds=None)

assert receipt.status == "queued"
assert receipt.expires_at is None
assert mgr.state.pending is True
assert mgr.state.expires_at is None
later.assert_not_called()


def test_latest_wins_cancels_previous_deadline():
hass, entry, _ = _make_env()
cancels = [MagicMock(), MagicMock()]
Expand All @@ -137,6 +155,23 @@ def test_latest_wins_cancels_previous_deadline():
assert mgr.state.pending is True


def test_latest_wins_replaces_finite_with_infinite_without_new_deadline():
hass, entry, _ = _make_env()
cancel = MagicMock()
with (
patch.object(delivery_mod, "async_call_later", return_value=cancel) as later,
patch.object(delivery_mod, "async_dispatcher_send"),
):
mgr = DeliveryManager(hass, entry)
_submit(mgr, device_id="a", expire_seconds=60)
_submit(mgr, device_id="b", expire_seconds=None)

cancel.assert_called_once()
assert later.call_count == 1
assert mgr.state.pending is True
assert mgr.state.expires_at is None


def test_expiry_clears_slot_and_fires_event():
hass, entry, _ = _make_env()
captured = {}
Expand Down Expand Up @@ -456,6 +491,7 @@ async def test_drain_passes_state_and_refresh_mode():
use_measured_palettes=False,
preview_jpeg=b"jpeg",
device_id="dev1",
expire_seconds=86400.0,
)
await mgr._deliver()

Expand Down
Loading
Loading