diff --git a/custom_components/opendisplay/delivery.py b/custom_components/opendisplay/delivery.py index 8378b89..dee3599 100644 --- a/custom_components/opendisplay/delivery.py +++ b/custom_components/opendisplay/delivery.py @@ -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. @@ -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, @@ -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 @@ -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 @@ -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 @@ -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() diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index b8dcd0a..022f02a 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -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]: @@ -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, } ) @@ -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, ...) @@ -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. @@ -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: @@ -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) @@ -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. @@ -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: @@ -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 @@ -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, @@ -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, ) @@ -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) \ No newline at end of file + hass.services.async_register(DOMAIN, "activate_buzzer", _async_activate_buzzer, schema=SCHEMA_ACTIVATE_BUZZER) diff --git a/custom_components/opendisplay/services.yaml b/custom_components/opendisplay/services.yaml index d4f101c..3fb5a1d 100644 --- a/custom_components/opendisplay/services.yaml +++ b/custom_components/opendisplay/services.yaml @@ -74,6 +74,10 @@ upload_image: default: false selector: boolean: + expire: + required: false + selector: + duration: activate_led: fields: @@ -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 diff --git a/custom_components/opendisplay/strings.json b/custom_components/opendisplay/strings.json index 86a4c25..7cb3cc9 100644 --- a/custom_components/opendisplay/strings.json +++ b/custom_components/opendisplay/strings.json @@ -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", @@ -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" diff --git a/custom_components/opendisplay/translations/en.json b/custom_components/opendisplay/translations/en.json index 0093ea9..ab12b07 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -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", @@ -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" diff --git a/tests/test_delivery.py b/tests/test_delivery.py index 994d752..84cc18c 100644 --- a/tests/test_delivery.py +++ b/tests/test_delivery.py @@ -67,7 +67,7 @@ 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, @@ -75,6 +75,7 @@ def _submit(mgr, device_id="dev1"): use_measured_palettes=False, preview_jpeg=b"jpeg", device_id=device_id, + expire_seconds=expire_seconds, ) @@ -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()] @@ -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 = {} @@ -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() diff --git a/tests/test_services.py b/tests/test_services.py index 2065e97..39c6ad3 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -7,6 +7,7 @@ """ import asyncio +from datetime import timedelta import time from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -14,6 +15,7 @@ from opendisplay import BLEConnectionError, RefreshMode, DitherMode from PIL import Image as PILImage import pytest +import voluptuous as vol from custom_components.opendisplay import services as services_mod from custom_components.opendisplay.const import ( @@ -26,7 +28,11 @@ from custom_components.opendisplay.services import ( PROBE_CONNECT_TIMEOUT_S, PROBE_MAX_ATTEMPTS, + SCHEMA_DRAWCUSTOM, + SCHEMA_UPLOAD_IMAGE, + _async_drawcustom, _async_send_image, + _expire_seconds, ) from custom_components.opendisplay.sleep import SleepProfile @@ -99,7 +105,7 @@ async def __aexit__(self, *exc_info): return MagicMock(side_effect=lambda **kwargs: _Ctx()) -def _send(hass, entry): +def _send(hass, entry, expire_seconds=86400.0): img = PILImage.new("RGB", (1, 1)) return _async_send_image( hass, @@ -107,6 +113,7 @@ def _send(hass, entry): img, dither_mode=DitherMode.NONE, refresh_mode=RefreshMode.FULL, + expire_seconds=expire_seconds, ) @@ -156,6 +163,73 @@ async def test_probe_failure_queues(): assert receipt.status == "queued" od.assert_called_once() manager.submit_upload.assert_called_once() + assert manager.submit_upload.call_args.kwargs["expire_seconds"] == 86400.0 + + +def test_expire_schema_accepts_seconds_zero_and_duration_strings(): + upload = SCHEMA_UPLOAD_IMAGE( + { + "device_id": "device", + "image": "https://example.com/image.png", + "expire": 60, + } + ) + draw = SCHEMA_DRAWCUSTOM( + { + "device_id": ["device"], + "payload": [], + "expire": 0, + } + ) + duration = SCHEMA_DRAWCUSTOM( + { + "device_id": ["device"], + "payload": [], + "expire": "00:05:00", + } + ) + + assert upload["expire"] == timedelta(seconds=60) + assert draw["expire"] == timedelta(0) + assert duration["expire"] == timedelta(minutes=5) + + +def test_expire_schema_rejects_negative_values(): + with pytest.raises(vol.Invalid): + SCHEMA_UPLOAD_IMAGE( + { + "device_id": "device", + "image": "https://example.com/image.png", + "expire": -1, + } + ) + + +def test_expire_seconds_normalizes_default_positive_and_infinite(): + _, entry, _, _ = _make_env(profile=_profile(queue_timeout_hours=2)) + + assert _expire_seconds(entry, SimpleNamespace(data={})) == 7200 + assert _expire_seconds( + entry, SimpleNamespace(data={"expire": timedelta(seconds=30)}) + ) == 30 + assert _expire_seconds(entry, SimpleNamespace(data={"expire": timedelta(0)})) is None + + +@pytest.mark.asyncio +async def test_drawcustom_response_all_infinite_queued_expiry_is_none(): + call = SimpleNamespace( + hass=MagicMock(), + data={"device_id": ["a", "b"], "label_id": [], "area_id": []}, + ) + with patch.object( + services_mod, + "_drawcustom_for_device", + return_value=DeliveryReceipt(status="queued", expires_at=None), + ): + response = await _async_drawcustom(call) + + assert response["status"] == "queued" + assert response["expires_at"] is None @pytest.mark.asyncio