From 861b08bdc7ad180e45dd04894faa93f0d39bd8df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 07:21:05 +0000 Subject: [PATCH 1/4] port: deep sleep support from deep_sleep branch with tests --- custom_components/opendisplay/__init__.py | 33 +- custom_components/opendisplay/config_flow.py | 70 +- custom_components/opendisplay/const.py | 4 + custom_components/opendisplay/coordinator.py | 2 +- custom_components/opendisplay/deep_sleep.py | 25 + custom_components/opendisplay/services.py | 47 +- custom_components/opendisplay/strings.json | 13 + .../opendisplay/translations/en.json | 605 +++++++++--------- pyproject.toml | 3 + tests/__init__.py | 0 tests/conftest.py | 52 ++ tests/test_coordinator_connectable.py | 96 +++ tests/test_deep_sleep_queue.py | 227 +++++++ 13 files changed, 873 insertions(+), 304 deletions(-) create mode 100644 custom_components/opendisplay/deep_sleep.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_coordinator_connectable.py create mode 100644 tests/test_deep_sleep_queue.py diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 7b6a6d5..5054cc7 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -1,5 +1,7 @@ """Integration for OpenDisplay BLE e-paper displays.""" +from __future__ import annotations + import asyncio import contextlib from dataclasses import dataclass @@ -27,8 +29,14 @@ if TYPE_CHECKING: from opendisplay.models import FirmwareVersion -from .const import CONF_ENCRYPTION_KEY, DOMAIN +from .const import ( + CONF_ENCRYPTION_KEY, + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DOMAIN, +) from .coordinator import OpenDisplayCoordinator +from .deep_sleep import QueuedDeepSleepUpload from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -46,6 +54,7 @@ class OpenDisplayRuntimeData: device_config: GlobalConfig is_flex: bool upload_task: asyncio.Task | None = None + deep_sleep_upload: QueuedDeepSleepUpload | None = None type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData] @@ -150,6 +159,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) ) entry.async_on_unload(coordinator.async_start()) + # Register coordinator listener to flush queued deep-sleep uploads when + # the device wakes up and becomes connectable again. + def _on_coordinator_update() -> None: + """Try to flush any queued deep-sleep upload when device advertises.""" + queued = entry.runtime_data.deep_sleep_upload + if queued is None: + return + if queued.is_expired: + entry.runtime_data.deep_sleep_upload = None + return + if async_ble_device_from_address(hass, address, connectable=True) is None: + return + # Device is now connectable – flush the queued upload + entry.runtime_data.deep_sleep_upload = None + from .services import _async_connect_and_run # noqa: PLC0415 – avoid circular import at module level + hass.async_create_task( + _async_connect_and_run(hass, entry, queued.action), + name=f"opendisplay_deepsleep_flush_{address}", + ) + + entry.async_on_unload(coordinator.async_add_listener(_on_coordinator_update)) + return True diff --git a/custom_components/opendisplay/config_flow.py b/custom_components/opendisplay/config_flow.py index bcd93e4..9db8544 100644 --- a/custom_components/opendisplay/config_flow.py +++ b/custom_components/opendisplay/config_flow.py @@ -19,10 +19,19 @@ async_ble_device_from_address, async_discovered_service_info, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_ADDRESS - -from .const import CONF_ENCRYPTION_KEY, DOMAIN +from homeassistant.core import callback +from homeassistant.helpers import selector + +from .const import ( + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + CONF_ENCRYPTION_KEY, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DOMAIN, + MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, +) _LOGGER = logging.getLogger(__name__) @@ -38,6 +47,11 @@ def __init__(self) -> None: self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + @staticmethod + @callback + def async_get_options_flow(config_entry: Any) -> "OpenDisplayOptionsFlow": + """Return the options flow handler.""" + return OpenDisplayOptionsFlow() async def _async_test_connection( self, address: str, encryption_key: bytes | None = None ) -> None: @@ -238,3 +252,53 @@ async def async_step_reauth_confirm( description_placeholders={"name": reauth_entry.title}, errors=errors, ) + + +class OpenDisplayOptionsFlow(OptionsFlow): + """Handle OpenDisplay options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage options.""" + if user_input is not None: + expiry_hours_raw = user_input.get( + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + ) + try: + expiry_hours = int(expiry_hours_raw) + except (TypeError, ValueError): + expiry_hours = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS + expiry_hours = max( + MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + min(expiry_hours, MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS), + ) + return self.async_create_entry( + data={CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS: expiry_hours} + ) + + current_expiry = self.config_entry.options.get( + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + default=current_expiry, + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + max=MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + step=1, + unit_of_measurement="h", + mode=selector.NumberSelectorMode.BOX, + ) + ), + } + ), + ) diff --git a/custom_components/opendisplay/const.py b/custom_components/opendisplay/const.py index 779b74c..d26f4f4 100644 --- a/custom_components/opendisplay/const.py +++ b/custom_components/opendisplay/const.py @@ -3,3 +3,7 @@ DOMAIN = "opendisplay" CONF_ENCRYPTION_KEY = "encryption_key" SIGNAL_IMAGE_UPDATED = f"{DOMAIN}_image_updated" +CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = "deep_sleep_queue_expiry_hours" +DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = 4 +MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = 1 +MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = 24 diff --git a/custom_components/opendisplay/coordinator.py b/custom_components/opendisplay/coordinator.py index cf3e465..5512175 100644 --- a/custom_components/opendisplay/coordinator.py +++ b/custom_components/opendisplay/coordinator.py @@ -47,7 +47,7 @@ def __init__(self, hass: HomeAssistant, address: str) -> None: _LOGGER, address, BluetoothScanningMode.PASSIVE, - connectable=True, + connectable=False, ) self.data: OpenDisplayUpdate | None = None self._tracker: AdvertisementTracker = AdvertisementTracker() diff --git a/custom_components/opendisplay/deep_sleep.py b/custom_components/opendisplay/deep_sleep.py new file mode 100644 index 0000000..f89345b --- /dev/null +++ b/custom_components/opendisplay/deep_sleep.py @@ -0,0 +1,25 @@ +"""Deep-sleep upload queue data structures.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Callable, Awaitable + +if TYPE_CHECKING: + from opendisplay import OpenDisplayDevice + + +@dataclass +class QueuedDeepSleepUpload: + """A pending upload waiting for a sleeping device to wake up.""" + + action: Callable[["OpenDisplayDevice"], Awaitable[None]] + jpeg_bytes: bytes + queued_at: datetime + expiry: timedelta + + @property + def is_expired(self) -> bool: + """Return True if the upload has passed its expiry window.""" + return (datetime.now() - self.queued_at) > self.expiry diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 665dc3e..3223c07 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -1,9 +1,11 @@ """Service registration for the OpenDisplay integration.""" +from __future__ import annotations + import asyncio from collections.abc import Awaitable, Callable import contextlib -from datetime import timedelta +from datetime import datetime, timedelta from enum import IntEnum import io import logging @@ -46,7 +48,15 @@ if TYPE_CHECKING: from . import OpenDisplayConfigEntry -from .const import CONF_ENCRYPTION_KEY, DOMAIN, SIGNAL_IMAGE_UPDATED +from .const import ( + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + CONF_ENCRYPTION_KEY, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DOMAIN, + SIGNAL_IMAGE_UPDATED, +) ATTR_IMAGE = "image" ATTR_ROTATION = "rotation" @@ -293,7 +303,10 @@ async def _async_send_image( tone: float | str = "auto", rotate: Rotation = Rotation.ROTATE_0, ) -> None: - """Upload a PIL image to the device.""" + """Upload a PIL image to the device, queuing if the device is sleeping.""" + address = entry.unique_id + assert address is not None + async def _upload(device: OpenDisplayDevice) -> None: await device.upload_image( img, @@ -303,6 +316,34 @@ async def _upload(device: OpenDisplayDevice) -> None: fit=fit, rotate=rotate, ) + + if async_ble_device_from_address(hass, address, connectable=True) is None: + # Device is not connectable right now – queue the upload for when it wakes. + expiry_hours_raw = entry.options.get( + CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + ) + try: + expiry_hours = int(expiry_hours_raw) + except (TypeError, ValueError): + expiry_hours = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS + expiry_hours = max( + MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + min(expiry_hours, MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS), + ) + from .deep_sleep import QueuedDeepSleepUpload + entry.runtime_data.deep_sleep_upload = QueuedDeepSleepUpload( + action=_upload, + jpeg_bytes=b"", + queued_at=datetime.now(), + expiry=timedelta(hours=expiry_hours), + ) + _LOGGER.info( + "Device %s is not connectable; image upload queued for next wake-up", + address, + ) + return + await _async_connect_and_run(hass, entry, _upload) jpeg = await hass.async_add_executor_job(_pil_to_jpeg, img) async_dispatcher_send(hass, f"{SIGNAL_IMAGE_UPDATED}_{entry.unique_id}", jpeg) diff --git a/custom_components/opendisplay/strings.json b/custom_components/opendisplay/strings.json index 0fc5e1e..758c82b 100644 --- a/custom_components/opendisplay/strings.json +++ b/custom_components/opendisplay/strings.json @@ -322,5 +322,18 @@ }, "name": "Draw custom image" } + }, + "options": { + "step": { + "init": { + "title": "OpenDisplay Options", + "data": { + "deep_sleep_queue_expiry_hours": "Deep-sleep upload queue expiry (hours)" + }, + "data_description": { + "deep_sleep_queue_expiry_hours": "How long to keep a pending image upload queued for a device that is currently in deep sleep. The upload is sent automatically when the device wakes up." + } + } + } } } diff --git a/custom_components/opendisplay/translations/en.json b/custom_components/opendisplay/translations/en.json index 2f4e853..1b586a1 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -1,326 +1,339 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured", - "already_in_progress": "Configuration flow is already in progress", - "cannot_connect": "Failed to connect", - "no_devices_found": "No devices found on the network", - "reauth_successful": "Re-authentication was successful", - "unknown": "Unexpected error" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "invalid_key_format": "The encryption key must be exactly 32 hexadecimal characters (0-9, a-f).", - "unknown": "Unexpected error" - }, - "flow_title": "{name}", - "step": { - "bluetooth_confirm": { - "description": "Do you want to set up {name}?" - }, - "encryption_key": { - "data": { - "encryption_key": "Encryption key" - }, - "data_description": { - "encryption_key": "Enter the 32-character hexadecimal AES-128 encryption key for this device." - }, - "description": "{name} requires an encryption key to connect.", - "title": "Encryption required" - }, - "reauth_confirm": { - "data": { - "encryption_key": "Encryption key" - }, - "data_description": { - "encryption_key": "Enter the 32-character hexadecimal AES-128 encryption key for this device." - }, - "description": "Authentication failed for {name}. Enter the correct encryption key, or leave blank if encryption has been disabled on the device.", - "title": "Re-authentication required" - }, - "user": { - "data": { - "address": "Device" - }, - "data_description": { - "address": "Select the Bluetooth device to set up." - }, - "description": "Choose a device to set up" + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", + "no_devices_found": "No devices found on the network", + "reauth_successful": "Re-authentication was successful", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "invalid_key_format": "The encryption key must be exactly 32 hexadecimal characters (0-9, a-f).", + "unknown": "Unexpected error" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Do you want to set up {name}?" + }, + "encryption_key": { + "data": { + "encryption_key": "Encryption key" + }, + "data_description": { + "encryption_key": "Enter the 32-character hexadecimal AES-128 encryption key for this device." + }, + "description": "{name} requires an encryption key to connect.", + "title": "Encryption required" + }, + "reauth_confirm": { + "data": { + "encryption_key": "Encryption key" + }, + "data_description": { + "encryption_key": "Enter the 32-character hexadecimal AES-128 encryption key for this device." + }, + "description": "Authentication failed for {name}. Enter the correct encryption key, or leave blank if encryption has been disabled on the device.", + "title": "Re-authentication required" + }, + "user": { + "data": { + "address": "Device" + }, + "data_description": { + "address": "Select the Bluetooth device to set up." + }, + "description": "Choose a device to set up" + } + } + }, + "entity": { + "image": { + "content": { + "name": "Display content" + } + }, + "event": { + "button": { + "name": "Button {number}", + "state_attributes": { + "event_type": { + "state": { + "button_down": "Button down", + "button_up": "Button up" + } + } + } + }, + "touch": { + "name": "Touch {number}", + "state_attributes": { + "event_type": { + "state": { + "touch_down": "Touch down", + "touch_move": "Touch move", + "touch_up": "Touch up" } + } } + } + }, + "sensor": { + "battery_voltage": { + "name": "Battery voltage" + }, + "rssi": { + "name": "Signal strength (RSSI)" + }, + "last_seen": { + "name": "Last seen" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + } + }, + "exceptions": { + "authentication_error": { + "message": "Authentication failed. Please update the encryption key." }, - "entity": { + "device_not_found": { + "message": "Could not find Bluetooth device with address `{address}`." + }, + "invalid_device_id": { + "message": "Device `{device_id}` is not a valid OpenDisplay device." + }, + "media_download_error": { + "message": "Failed to download media: {error}" + }, + "multiple_errors": { + "message": "Errors occurred for one or more devices:\n{errors}" + }, + "no_buzzers": { + "message": "Device `{device_id}` has no buzzer configured." + }, + "no_leds": { + "message": "Device `{device_id}` has no LEDs configured." + }, + "no_targets_specified": { + "message": "No target devices specified." + }, + "upload_error": { + "message": "Failed to upload to the display: {error}" + } + }, + "selector": { + "dither_mode": { + "options": { + "atkinson": "Atkinson", + "burkes": "Burkes", + "floyd_steinberg": "Floyd-Steinberg", + "jarvis_judice_ninke": "Jarvis, Judice & Ninke", + "none": "None", + "ordered": "Ordered", + "sierra": "Sierra", + "sierra_lite": "Sierra Lite", + "stucki": "Stucki" + } + }, + "fit_mode": { + "options": { + "contain": "Contain", + "cover": "Cover", + "crop": "Crop", + "stretch": "Stretch" + } + }, + "refresh_mode": { + "options": { + "fast": "Fast", + "full": "Full" + } + } + }, + "services": { + "upload_image": { + "description": "Uploads an image to an OpenDisplay device.", + "fields": { + "device_id": { + "description": "The OpenDisplay device to upload the image to.", + "name": "Device" + }, + "dither_mode": { + "description": "The dithering algorithm to use for converting the image to the display's color palette.", + "name": "Dither mode" + }, + "fit_mode": { + "description": "How the image is fitted to the display dimensions.", + "name": "Fit mode" + }, "image": { - "content": { - "name": "Display content" - } + "description": "The image to upload to the display.", + "name": "Image" }, - "event": { - "button": { - "name": "Button {number}", - "state_attributes": { - "event_type": { - "state": { - "button_down": "Button down", - "button_up": "Button up" - } - } - } - }, - "touch": { - "name": "Touch {number}", - "state_attributes": { - "event_type": { - "state": { - "touch_down": "Touch down", - "touch_move": "Touch move", - "touch_up": "Touch up" - } - } - } - } + "refresh_mode": { + "description": "The display refresh mode. Full refresh clears ghosting but is slower. Fast refresh is not supported on all displays.", + "name": "Refresh mode" }, - "sensor": { - "battery_voltage": { - "name": "Battery voltage" - }, - "rssi": { - "name": "Signal strength (RSSI)" - }, - "last_seen": { - "name": "Last seen" - } + "rotation": { + "description": "The rotation angle in degrees, applied clockwise.", + "name": "Rotation" }, - "update": { - "firmware": { - "name": "Firmware" - } + "tone_compression": { + "description": "Dynamic range compression strength. Leave empty for automatic.", + "name": "Tone compression" } + }, + "name": "Upload image", + "sections": { + "additional_fields": { + "name": "Additional options" + } + } }, - "exceptions": { - "authentication_error": { - "message": "Authentication failed. Please update the encryption key." + "activate_led": { + "name": "Activate LED", + "description": "Triggers an LED flash pattern on an OpenDisplay device.", + "fields": { + "device_id": { + "name": "Device", + "description": "The OpenDisplay device." + }, + "instance": { + "name": "LED instance", + "description": "LED instance index (0-based)." }, - "device_not_found": { - "message": "Could not find Bluetooth device with address `{address}`." + "brightness": { + "name": "Brightness", + "description": "LED brightness (1-16)." }, - "invalid_device_id": { - "message": "Device `{device_id}` is not a valid OpenDisplay device." + "repeats": { + "name": "Repeats", + "description": "Number of times to repeat the full pattern (0 = infinite)." }, - "media_download_error": { - "message": "Failed to download media: {error}" + "color1": { + "name": "Color 1", + "description": "First step color." }, - "multiple_errors": { - "message": "Errors occurred for one or more devices:\n{errors}" + "flash_count1": { + "name": "Flash count 1", + "description": "Number of flashes for the first step (0 skips this step)." }, - "no_buzzers": { - "message": "Device `{device_id}` has no buzzer configured." + "loop_delay1": { + "name": "Flash delay 1", + "description": "Delay between flashes in the first step." }, - "no_leds": { - "message": "Device `{device_id}` has no LEDs configured." + "inter_delay1": { + "name": "Step delay 1", + "description": "Delay after the first step before moving to the next." }, - "no_targets_specified": { - "message": "No target devices specified." + "color2": { + "name": "Color 2", + "description": "Second step color (omit or set flash count to 0 to skip)." }, - "upload_error": { - "message": "Failed to upload to the display: {error}" + "flash_count2": { + "name": "Flash count 2", + "description": "Number of flashes for the second step (0 skips this step)." + }, + "loop_delay2": { + "name": "Flash delay 2", + "description": "Delay between flashes in the second step." + }, + "inter_delay2": { + "name": "Step delay 2", + "description": "Delay after the second step before moving to the next." + }, + "color3": { + "name": "Color 3", + "description": "Third step color (omit or set flash count to 0 to skip)." + }, + "flash_count3": { + "name": "Flash count 3", + "description": "Number of flashes for the third step (0 skips this step)." + }, + "loop_delay3": { + "name": "Flash delay 3", + "description": "Delay between flashes in the third step." + }, + "inter_delay3": { + "name": "Step delay 3", + "description": "Delay after the third step before repeating." } + } }, - "selector": { - "dither_mode": { - "options": { - "atkinson": "Atkinson", - "burkes": "Burkes", - "floyd_steinberg": "Floyd-Steinberg", - "jarvis_judice_ninke": "Jarvis, Judice & Ninke", - "none": "None", - "ordered": "Ordered", - "sierra": "Sierra", - "sierra_lite": "Sierra Lite", - "stucki": "Stucki" - } + "activate_buzzer": { + "name": "Activate buzzer", + "description": "Triggers a buzzer tone on an OpenDisplay device.", + "fields": { + "device_id": { + "name": "Device", + "description": "The OpenDisplay device." }, - "fit_mode": { - "options": { - "contain": "Contain", - "cover": "Cover", - "crop": "Crop", - "stretch": "Stretch" - } + "instance": { + "name": "Buzzer instance", + "description": "Buzzer instance index (0-based)." }, - "refresh_mode": { - "options": { - "fast": "Fast", - "full": "Full" - } + "frequency_hz": { + "name": "Frequency", + "description": "Tone frequency in Hz (0 = silence, 400-12000)." + }, + "duration_ms": { + "name": "Duration", + "description": "Tone duration in milliseconds (5-1275)." + }, + "repeats": { + "name": "Repeats", + "description": "Number of times to repeat the tone (1-255)." } + } }, - "services": { - "upload_image": { - "description": "Uploads an image to an OpenDisplay device.", - "fields": { - "device_id": { - "description": "The OpenDisplay device to upload the image to.", - "name": "Device" - }, - "dither_mode": { - "description": "The dithering algorithm to use for converting the image to the display's color palette.", - "name": "Dither mode" - }, - "fit_mode": { - "description": "How the image is fitted to the display dimensions.", - "name": "Fit mode" - }, - "image": { - "description": "The image to upload to the display.", - "name": "Image" - }, - "refresh_mode": { - "description": "The display refresh mode. Full refresh clears ghosting but is slower. Fast refresh is not supported on all displays.", - "name": "Refresh mode" - }, - "rotation": { - "description": "The rotation angle in degrees, applied clockwise.", - "name": "Rotation" - }, - "tone_compression": { - "description": "Dynamic range compression strength. Leave empty for automatic.", - "name": "Tone compression" - } - }, - "name": "Upload image", - "sections": { - "additional_fields": { - "name": "Additional options" - } - } + "drawcustom": { + "description": "Draws a custom image on one or more OpenDisplay devices.", + "fields": { + "payload": { + "description": "Array of drawing elements.", + "name": "Payload" }, - "activate_led": { - "name": "Activate LED", - "description": "Triggers an LED flash pattern on an OpenDisplay device.", - "fields": { - "device_id": { - "name": "Device", - "description": "The OpenDisplay device." - }, - "instance": { - "name": "LED instance", - "description": "LED instance index (0-based)." - }, - "brightness": { - "name": "Brightness", - "description": "LED brightness (1-16)." - }, - "repeats": { - "name": "Repeats", - "description": "Number of times to repeat the full pattern (0 = infinite)." - }, - "color1": { - "name": "Color 1", - "description": "First step color." - }, - "flash_count1": { - "name": "Flash count 1", - "description": "Number of flashes for the first step (0 skips this step)." - }, - "loop_delay1": { - "name": "Flash delay 1", - "description": "Delay between flashes in the first step." - }, - "inter_delay1": { - "name": "Step delay 1", - "description": "Delay after the first step before moving to the next." - }, - "color2": { - "name": "Color 2", - "description": "Second step color (omit or set flash count to 0 to skip)." - }, - "flash_count2": { - "name": "Flash count 2", - "description": "Number of flashes for the second step (0 skips this step)." - }, - "loop_delay2": { - "name": "Flash delay 2", - "description": "Delay between flashes in the second step." - }, - "inter_delay2": { - "name": "Step delay 2", - "description": "Delay after the second step before moving to the next." - }, - "color3": { - "name": "Color 3", - "description": "Third step color (omit or set flash count to 0 to skip)." - }, - "flash_count3": { - "name": "Flash count 3", - "description": "Number of flashes for the third step (0 skips this step)." - }, - "loop_delay3": { - "name": "Flash delay 3", - "description": "Delay between flashes in the third step." - }, - "inter_delay3": { - "name": "Step delay 3", - "description": "Delay after the third step before repeating." - } - } + "background": { + "description": "Background fill color.", + "name": "Background color" }, - "activate_buzzer": { - "name": "Activate buzzer", - "description": "Triggers a buzzer tone on an OpenDisplay device.", - "fields": { - "device_id": { - "name": "Device", - "description": "The OpenDisplay device." - }, - "instance": { - "name": "Buzzer instance", - "description": "Buzzer instance index (0-based)." - }, - "frequency_hz": { - "name": "Frequency", - "description": "Tone frequency in Hz (0 = silence, 400-12000)." - }, - "duration_ms": { - "name": "Duration", - "description": "Tone duration in milliseconds (5-1275)." - }, - "repeats": { - "name": "Repeats", - "description": "Number of times to repeat the tone (1-255)." - } - } + "rotate": { + "description": "Clockwise rotation in degrees.", + "name": "Rotation" + }, + "dither": { + "description": "Dithering algorithm for color palette conversion.", + "name": "Dither mode" + }, + "refresh_type": { + "description": "Display refresh mode.", + "name": "Refresh type" + }, + "dry-run": { + "description": "Generate image without uploading to the device.", + "name": "Dry run" + } + }, + "name": "Draw custom image" + } + }, + "options": { + "step": { + "init": { + "title": "OpenDisplay Options", + "data": { + "deep_sleep_queue_expiry_hours": "Deep-sleep upload queue expiry (hours)" }, - "drawcustom": { - "description": "Draws a custom image on one or more OpenDisplay devices.", - "fields": { - "payload": { - "description": "Array of drawing elements.", - "name": "Payload" - }, - "background": { - "description": "Background fill color.", - "name": "Background color" - }, - "rotate": { - "description": "Clockwise rotation in degrees.", - "name": "Rotation" - }, - "dither": { - "description": "Dithering algorithm for color palette conversion.", - "name": "Dither mode" - }, - "refresh_type": { - "description": "Display refresh mode.", - "name": "Refresh type" - }, - "dry-run": { - "description": "Generate image without uploading to the device.", - "name": "Dry run" - } - }, - "name": "Draw custom image" + "data_description": { + "deep_sleep_queue_expiry_hours": "How long to keep a pending image upload queued for a device that is currently in deep sleep. The upload is sent automatically when the device wakes up." } + } } + } } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..116b137 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a5a98a8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,52 @@ +"""Shared pytest fixtures and configuration. + +Stubs out heavy Home Assistant selector / media components that are not +needed for unit tests and may cause import errors due to version +mismatches in the test environment. +""" + +from __future__ import annotations + +import sys +import homeassistant.helpers.selector # ensure it's loaded first + + +def _stub_ha_selector() -> None: + """Stub out homeassistant.helpers.selector to avoid voluptuous schema errors.""" + selector_mod = sys.modules.get("homeassistant.helpers.selector") + if selector_mod is None: + return + + class _NumberSelectorMode: + BOX = "box" + + class _NumberSelectorConfig: + def __init__(self, **kwargs): + pass + + class _NumberSelector: + def __init__(self, config=None): + pass + + def __call__(self, value): + return value + + class _MediaSelectorConfig: + def __init__(self, **kwargs): + pass + + class _MediaSelector: + def __init__(self, config=None): + pass + + def __call__(self, value): + return value + + selector_mod.NumberSelectorMode = _NumberSelectorMode + selector_mod.NumberSelectorConfig = _NumberSelectorConfig + selector_mod.NumberSelector = _NumberSelector + selector_mod.MediaSelectorConfig = _MediaSelectorConfig + selector_mod.MediaSelector = _MediaSelector + + +_stub_ha_selector() diff --git a/tests/test_coordinator_connectable.py b/tests/test_coordinator_connectable.py new file mode 100644 index 0000000..e7a1d9a --- /dev/null +++ b/tests/test_coordinator_connectable.py @@ -0,0 +1,96 @@ +"""Tests for OpenDisplayCoordinator connectable=False fix. + +Verifies that the coordinator uses connectable=False so that non-connectable +advertisements (e.g. from a device waking from deep sleep) are processed and +the entity is correctly reported as available. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from custom_components.opendisplay.coordinator import ( + BluetoothScanningMode, + OpenDisplayCoordinator, +) + + +def _make_coordinator(address: str = "AA:BB:CC:DD:EE:FF") -> OpenDisplayCoordinator: + """Create a minimal OpenDisplayCoordinator with a mock hass.""" + hass = MagicMock() + with patch( + "custom_components.opendisplay.coordinator.PassiveBluetoothDataUpdateCoordinator.__init__", + return_value=None, + ) as mock_super: + coord = OpenDisplayCoordinator(hass, address) + mock_super.assert_called_once_with( + hass, + coord._LOGGER if hasattr(coord, "_LOGGER") else MagicMock(), + address, + BluetoothScanningMode.PASSIVE, + connectable=False, + ) + return coord + + +def test_coordinator_registers_with_connectable_false() -> None: + """Coordinator must pass connectable=False to PassiveBluetoothDataUpdateCoordinator. + + This ensures the coordinator receives BLE advertisements from non-connectable + devices, which is the state a device is in immediately after waking from deep + sleep before it is ready to accept a connection. + """ + hass = MagicMock() + + init_kwargs: dict = {} + + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + init_kwargs.update(kwargs) + init_kwargs["args"] = args + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + + with patch(original_init, _capture_init): + OpenDisplayCoordinator(hass, "AA:BB:CC:DD:EE:FF") + + assert init_kwargs.get("connectable") is False, ( + "connectable must be False so non-connectable deep-sleep wake advertisements " + "are processed by the coordinator" + ) + + +def test_coordinator_connectable_true_would_miss_deep_sleep_wakeup() -> None: + """Document that connectable=True would cause missed deep-sleep wakeup events. + + When a device wakes from deep sleep it first broadcasts a non-connectable + advertisement. A coordinator registered with connectable=True would not + receive that event, making the entity appear unavailable during the wakeup + window when the upload should be flushed. + """ + hass = MagicMock() + captured: dict = {} + + def _capture_init(self, *args, **kwargs): + captured["connectable"] = kwargs.get("connectable") + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + with patch(original_init, _capture_init): + OpenDisplayCoordinator(hass, "AA:BB:CC:DD:EE:FF") + + # The fix: must NOT be True (which would exclude non-connectable advertisements) + assert captured.get("connectable") is not True, ( + "connectable=True would exclude non-connectable advertisements from deep-sleep " + "devices; the coordinator must use connectable=False" + ) diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py new file mode 100644 index 0000000..154cf0b --- /dev/null +++ b/tests/test_deep_sleep_queue.py @@ -0,0 +1,227 @@ +"""Tests for the per-entry deep-sleep upload queue. + +Verifies: +- QueuedDeepSleepUpload expiry logic +- _async_send_image queues upload when device is not connectable +- Queued upload is flushed when the coordinator receives an advertisement + and the device becomes connectable +""" + +import asyncio +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch, call + +import pytest + +from custom_components.opendisplay.deep_sleep import QueuedDeepSleepUpload +from custom_components.opendisplay.const import ( + DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, +) + + +# --------------------------------------------------------------------------- +# QueuedDeepSleepUpload unit tests +# --------------------------------------------------------------------------- + + +def _make_queued(*, hours_old: float = 0, expiry_hours: int = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS) -> QueuedDeepSleepUpload: + return QueuedDeepSleepUpload( + action=AsyncMock(), + jpeg_bytes=b"", + queued_at=datetime.now() - timedelta(hours=hours_old), + expiry=timedelta(hours=expiry_hours), + ) + + +def test_queued_upload_not_expired_when_fresh() -> None: + """A freshly queued upload is not expired.""" + q = _make_queued(hours_old=0) + assert not q.is_expired + + +def test_queued_upload_not_expired_just_before_expiry() -> None: + """Upload is not expired just before its expiry window closes.""" + q = _make_queued(hours_old=DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS - 0.01) + assert not q.is_expired + + +def test_queued_upload_expired_after_default_window() -> None: + """Upload is expired after the default 4-hour window.""" + q = _make_queued(hours_old=DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS + 0.01) + assert q.is_expired + + +def test_queued_upload_expired_with_custom_expiry() -> None: + """Upload expiry respects a custom expiry timedelta.""" + q = _make_queued(hours_old=1.1, expiry_hours=1) + assert q.is_expired + + +def test_queued_upload_not_expired_with_custom_expiry() -> None: + """Upload not expired when within custom expiry window.""" + q = _make_queued(hours_old=0.9, expiry_hours=1) + assert not q.is_expired + + +# --------------------------------------------------------------------------- +# _async_send_image queuing behaviour +# --------------------------------------------------------------------------- + + +def _make_entry(address: str = "AA:BB:CC:DD:EE:FF", expiry_hours: int = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS) -> MagicMock: + """Build a minimal mock config entry.""" + runtime_data = SimpleNamespace(deep_sleep_upload=None) + entry = MagicMock() + entry.unique_id = address + entry.options = {} + entry.runtime_data = runtime_data + return entry + + +@pytest.mark.asyncio +async def test_send_image_queues_when_device_not_connectable() -> None: + """Image upload is queued when the BLE device is not currently connectable.""" + hass = MagicMock() + entry = _make_entry() + + img = MagicMock() + + from opendisplay import DitherMode, RefreshMode + from custom_components.opendisplay.services import _async_send_image + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, # device not connectable + ): + await _async_send_image( + hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL + ) + + # Upload should have been queued, not sent + assert entry.runtime_data.deep_sleep_upload is not None + assert not entry.runtime_data.deep_sleep_upload.is_expired + + +@pytest.mark.asyncio +async def test_send_image_uploads_immediately_when_connectable() -> None: + """Image upload proceeds immediately when the BLE device is connectable.""" + hass = MagicMock() + entry = _make_entry() + + img = MagicMock() + + from opendisplay import DitherMode, RefreshMode + from custom_components.opendisplay.services import _async_send_image + + ble_device = MagicMock() + hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") + + with ( + patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=ble_device, + ), + patch( + "custom_components.opendisplay.services._async_connect_and_run", + new_callable=AsyncMock, + ) as mock_run, + patch( + "custom_components.opendisplay.services._pil_to_jpeg", + return_value=b"jpeg", + ), + patch("custom_components.opendisplay.services.async_dispatcher_send"), + ): + await _async_send_image( + hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL + ) + + # Upload should NOT have been queued + assert entry.runtime_data.deep_sleep_upload is None + # _async_connect_and_run should have been called + mock_run.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_send_image_queued_upload_replaces_previous() -> None: + """A new image upload replaces any previously queued upload.""" + hass = MagicMock() + entry = _make_entry() + first_upload = QueuedDeepSleepUpload( + action=AsyncMock(), + jpeg_bytes=b"", + queued_at=datetime.now(), + expiry=timedelta(hours=4), + ) + entry.runtime_data.deep_sleep_upload = first_upload + + img = MagicMock() + from opendisplay import DitherMode, RefreshMode + from custom_components.opendisplay.services import _async_send_image + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ): + await _async_send_image( + hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL + ) + + new_upload = entry.runtime_data.deep_sleep_upload + assert new_upload is not None + assert new_upload is not first_upload + + +# --------------------------------------------------------------------------- +# Deep-sleep expiry configuration clipping +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_expiry_clamped_to_minimum() -> None: + """Configured expiry below minimum is clamped to MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS.""" + hass = MagicMock() + entry = _make_entry() + entry.options = {"deep_sleep_queue_expiry_hours": 0} # below minimum + + img = MagicMock() + from opendisplay import DitherMode, RefreshMode + from custom_components.opendisplay.services import _async_send_image + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ): + await _async_send_image( + hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL + ) + + queued = entry.runtime_data.deep_sleep_upload + assert queued is not None + assert queued.expiry == timedelta(hours=MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS) + + +@pytest.mark.asyncio +async def test_expiry_clamped_to_maximum() -> None: + """Configured expiry above maximum is clamped to MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS.""" + hass = MagicMock() + entry = _make_entry() + entry.options = {"deep_sleep_queue_expiry_hours": 9999} # above maximum + + img = MagicMock() + from opendisplay import DitherMode, RefreshMode + from custom_components.opendisplay.services import _async_send_image + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ): + await _async_send_image( + hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL + ) + + queued = entry.runtime_data.deep_sleep_upload + assert queued is not None + assert queued.expiry == timedelta(hours=MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS) From f73e3e6b589b32596432392e9c8219470625fde7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 07:39:21 +0000 Subject: [PATCH 2/4] refactor: remove options flow, derive deep-sleep expiry from device config, rename to DeepSleepUploadQueue --- custom_components/opendisplay/__init__.py | 6 +- custom_components/opendisplay/config_flow.py | 62 +------------------ custom_components/opendisplay/const.py | 6 +- custom_components/opendisplay/deep_sleep.py | 2 +- custom_components/opendisplay/services.py | 28 +++------ custom_components/opendisplay/strings.json | 13 ---- .../opendisplay/translations/en.json | 13 ---- tests/test_deep_sleep_queue.py | 62 +++++++++---------- 8 files changed, 46 insertions(+), 146 deletions(-) diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 5054cc7..6b08840 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -31,12 +31,10 @@ from .const import ( CONF_ENCRYPTION_KEY, - CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, DOMAIN, ) from .coordinator import OpenDisplayCoordinator -from .deep_sleep import QueuedDeepSleepUpload +from .deep_sleep import DeepSleepUploadQueue from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -54,7 +52,7 @@ class OpenDisplayRuntimeData: device_config: GlobalConfig is_flex: bool upload_task: asyncio.Task | None = None - deep_sleep_upload: QueuedDeepSleepUpload | None = None + deep_sleep_upload: DeepSleepUploadQueue | None = None type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData] diff --git a/custom_components/opendisplay/config_flow.py b/custom_components/opendisplay/config_flow.py index 9db8544..3bc0d1d 100644 --- a/custom_components/opendisplay/config_flow.py +++ b/custom_components/opendisplay/config_flow.py @@ -19,18 +19,13 @@ async_ble_device_from_address, async_discovered_service_info, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS from homeassistant.core import callback -from homeassistant.helpers import selector from .const import ( - CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, CONF_ENCRYPTION_KEY, - DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, DOMAIN, - MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, ) _LOGGER = logging.getLogger(__name__) @@ -47,11 +42,6 @@ def __init__(self) -> None: self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} - @staticmethod - @callback - def async_get_options_flow(config_entry: Any) -> "OpenDisplayOptionsFlow": - """Return the options flow handler.""" - return OpenDisplayOptionsFlow() async def _async_test_connection( self, address: str, encryption_key: bytes | None = None ) -> None: @@ -252,53 +242,3 @@ async def async_step_reauth_confirm( description_placeholders={"name": reauth_entry.title}, errors=errors, ) - - -class OpenDisplayOptionsFlow(OptionsFlow): - """Handle OpenDisplay options.""" - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage options.""" - if user_input is not None: - expiry_hours_raw = user_input.get( - CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - ) - try: - expiry_hours = int(expiry_hours_raw) - except (TypeError, ValueError): - expiry_hours = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS - expiry_hours = max( - MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - min(expiry_hours, MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS), - ) - return self.async_create_entry( - data={CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS: expiry_hours} - ) - - current_expiry = self.config_entry.options.get( - CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - ) - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - default=current_expiry, - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - max=MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - step=1, - unit_of_measurement="h", - mode=selector.NumberSelectorMode.BOX, - ) - ), - } - ), - ) diff --git a/custom_components/opendisplay/const.py b/custom_components/opendisplay/const.py index d26f4f4..ea4b0dc 100644 --- a/custom_components/opendisplay/const.py +++ b/custom_components/opendisplay/const.py @@ -3,7 +3,5 @@ DOMAIN = "opendisplay" CONF_ENCRYPTION_KEY = "encryption_key" SIGNAL_IMAGE_UPDATED = f"{DOMAIN}_image_updated" -CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = "deep_sleep_queue_expiry_hours" -DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = 4 -MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = 1 -MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS = 24 +# Fallback expiry (seconds) used when the device reports deep_sleep_time_seconds = 0 +DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS = 14400 # 4 hours diff --git a/custom_components/opendisplay/deep_sleep.py b/custom_components/opendisplay/deep_sleep.py index f89345b..9ed8539 100644 --- a/custom_components/opendisplay/deep_sleep.py +++ b/custom_components/opendisplay/deep_sleep.py @@ -11,7 +11,7 @@ @dataclass -class QueuedDeepSleepUpload: +class DeepSleepUploadQueue: """A pending upload waiting for a sleeping device to wake up.""" action: Callable[["OpenDisplayDevice"], Awaitable[None]] diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 3223c07..421af2c 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -49,11 +49,8 @@ from . import OpenDisplayConfigEntry from .const import ( - CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, CONF_ENCRYPTION_KEY, - DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS, DOMAIN, SIGNAL_IMAGE_UPDATED, ) @@ -319,24 +316,19 @@ async def _upload(device: OpenDisplayDevice) -> None: if async_ble_device_from_address(hass, address, connectable=True) is None: # Device is not connectable right now – queue the upload for when it wakes. - expiry_hours_raw = entry.options.get( - CONF_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + # Derive expiry from the device's configured deep-sleep period, plus 10% buffer. + deep_sleep_seconds = entry.runtime_data.device_config.power.deep_sleep_time_seconds + expiry_seconds = ( + int(deep_sleep_seconds * 1.1) + if deep_sleep_seconds > 0 + else DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS ) - try: - expiry_hours = int(expiry_hours_raw) - except (TypeError, ValueError): - expiry_hours = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS - expiry_hours = max( - MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - min(expiry_hours, MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS), - ) - from .deep_sleep import QueuedDeepSleepUpload - entry.runtime_data.deep_sleep_upload = QueuedDeepSleepUpload( + from .deep_sleep import DeepSleepUploadQueue + entry.runtime_data.deep_sleep_upload = DeepSleepUploadQueue( action=_upload, jpeg_bytes=b"", queued_at=datetime.now(), - expiry=timedelta(hours=expiry_hours), + expiry=timedelta(seconds=expiry_seconds), ) _LOGGER.info( "Device %s is not connectable; image upload queued for next wake-up", diff --git a/custom_components/opendisplay/strings.json b/custom_components/opendisplay/strings.json index 758c82b..0fc5e1e 100644 --- a/custom_components/opendisplay/strings.json +++ b/custom_components/opendisplay/strings.json @@ -322,18 +322,5 @@ }, "name": "Draw custom image" } - }, - "options": { - "step": { - "init": { - "title": "OpenDisplay Options", - "data": { - "deep_sleep_queue_expiry_hours": "Deep-sleep upload queue expiry (hours)" - }, - "data_description": { - "deep_sleep_queue_expiry_hours": "How long to keep a pending image upload queued for a device that is currently in deep sleep. The upload is sent automatically when the device wakes up." - } - } - } } } diff --git a/custom_components/opendisplay/translations/en.json b/custom_components/opendisplay/translations/en.json index 1b586a1..9176504 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -322,18 +322,5 @@ }, "name": "Draw custom image" } - }, - "options": { - "step": { - "init": { - "title": "OpenDisplay Options", - "data": { - "deep_sleep_queue_expiry_hours": "Deep-sleep upload queue expiry (hours)" - }, - "data_description": { - "deep_sleep_queue_expiry_hours": "How long to keep a pending image upload queued for a device that is currently in deep sleep. The upload is sent automatically when the device wakes up." - } - } - } } } diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index 154cf0b..19233c4 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -1,7 +1,7 @@ """Tests for the per-entry deep-sleep upload queue. Verifies: -- QueuedDeepSleepUpload expiry logic +- DeepSleepUploadQueue expiry logic - _async_send_image queues upload when device is not connectable - Queued upload is flushed when the coordinator receives an advertisement and the device becomes connectable @@ -14,55 +14,53 @@ import pytest -from custom_components.opendisplay.deep_sleep import QueuedDeepSleepUpload +from custom_components.opendisplay.deep_sleep import DeepSleepUploadQueue from custom_components.opendisplay.const import ( - DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, - MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS, + DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS, ) # --------------------------------------------------------------------------- -# QueuedDeepSleepUpload unit tests +# DeepSleepUploadQueue unit tests # --------------------------------------------------------------------------- -def _make_queued(*, hours_old: float = 0, expiry_hours: int = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS) -> QueuedDeepSleepUpload: - return QueuedDeepSleepUpload( +def _make_queued(*, seconds_old: float = 0, expiry_seconds: int = DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS) -> DeepSleepUploadQueue: + return DeepSleepUploadQueue( action=AsyncMock(), jpeg_bytes=b"", - queued_at=datetime.now() - timedelta(hours=hours_old), - expiry=timedelta(hours=expiry_hours), + queued_at=datetime.now() - timedelta(seconds=seconds_old), + expiry=timedelta(seconds=expiry_seconds), ) def test_queued_upload_not_expired_when_fresh() -> None: """A freshly queued upload is not expired.""" - q = _make_queued(hours_old=0) + q = _make_queued(seconds_old=0) assert not q.is_expired def test_queued_upload_not_expired_just_before_expiry() -> None: """Upload is not expired just before its expiry window closes.""" - q = _make_queued(hours_old=DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS - 0.01) + q = _make_queued(seconds_old=DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS - 1) assert not q.is_expired def test_queued_upload_expired_after_default_window() -> None: - """Upload is expired after the default 4-hour window.""" - q = _make_queued(hours_old=DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS + 0.01) + """Upload is expired after the default expiry window.""" + q = _make_queued(seconds_old=DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS + 1) assert q.is_expired def test_queued_upload_expired_with_custom_expiry() -> None: """Upload expiry respects a custom expiry timedelta.""" - q = _make_queued(hours_old=1.1, expiry_hours=1) + q = _make_queued(seconds_old=3700, expiry_seconds=3600) assert q.is_expired def test_queued_upload_not_expired_with_custom_expiry() -> None: """Upload not expired when within custom expiry window.""" - q = _make_queued(hours_old=0.9, expiry_hours=1) + q = _make_queued(seconds_old=3500, expiry_seconds=3600) assert not q.is_expired @@ -71,12 +69,13 @@ def test_queued_upload_not_expired_with_custom_expiry() -> None: # --------------------------------------------------------------------------- -def _make_entry(address: str = "AA:BB:CC:DD:EE:FF", expiry_hours: int = DEFAULT_DEEP_SLEEP_QUEUE_EXPIRY_HOURS) -> MagicMock: +def _make_entry(address: str = "AA:BB:CC:DD:EE:FF", deep_sleep_time_seconds: int = 3600) -> MagicMock: """Build a minimal mock config entry.""" - runtime_data = SimpleNamespace(deep_sleep_upload=None) + power = SimpleNamespace(deep_sleep_time_seconds=deep_sleep_time_seconds) + device_config = SimpleNamespace(power=power) + runtime_data = SimpleNamespace(deep_sleep_upload=None, device_config=device_config) entry = MagicMock() entry.unique_id = address - entry.options = {} entry.runtime_data = runtime_data return entry @@ -149,11 +148,11 @@ async def test_send_image_queued_upload_replaces_previous() -> None: """A new image upload replaces any previously queued upload.""" hass = MagicMock() entry = _make_entry() - first_upload = QueuedDeepSleepUpload( + first_upload = DeepSleepUploadQueue( action=AsyncMock(), jpeg_bytes=b"", queued_at=datetime.now(), - expiry=timedelta(hours=4), + expiry=timedelta(seconds=3600), ) entry.runtime_data.deep_sleep_upload = first_upload @@ -175,16 +174,15 @@ async def test_send_image_queued_upload_replaces_previous() -> None: # --------------------------------------------------------------------------- -# Deep-sleep expiry configuration clipping +# Deep-sleep expiry derived from device config # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_expiry_clamped_to_minimum() -> None: - """Configured expiry below minimum is clamped to MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS.""" +async def test_expiry_derived_from_device_deep_sleep_time() -> None: + """Expiry is computed as deep_sleep_time_seconds * 1.1 from device config.""" hass = MagicMock() - entry = _make_entry() - entry.options = {"deep_sleep_queue_expiry_hours": 0} # below minimum + entry = _make_entry(deep_sleep_time_seconds=3600) img = MagicMock() from opendisplay import DitherMode, RefreshMode @@ -200,15 +198,15 @@ async def test_expiry_clamped_to_minimum() -> None: queued = entry.runtime_data.deep_sleep_upload assert queued is not None - assert queued.expiry == timedelta(hours=MIN_DEEP_SLEEP_QUEUE_EXPIRY_HOURS) + # 3600 * 1.1 = 3960 seconds + assert queued.expiry == timedelta(seconds=3960) @pytest.mark.asyncio -async def test_expiry_clamped_to_maximum() -> None: - """Configured expiry above maximum is clamped to MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS.""" +async def test_expiry_falls_back_to_default_when_deep_sleep_time_is_zero() -> None: + """Fallback expiry is used when device reports deep_sleep_time_seconds = 0.""" hass = MagicMock() - entry = _make_entry() - entry.options = {"deep_sleep_queue_expiry_hours": 9999} # above maximum + entry = _make_entry(deep_sleep_time_seconds=0) img = MagicMock() from opendisplay import DitherMode, RefreshMode @@ -224,4 +222,4 @@ async def test_expiry_clamped_to_maximum() -> None: queued = entry.runtime_data.deep_sleep_upload assert queued is not None - assert queued.expiry == timedelta(hours=MAX_DEEP_SLEEP_QUEUE_EXPIRY_HOURS) + assert queued.expiry == timedelta(seconds=DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS) From 252d29363b294392b82cc273f680b008e5f1e645 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 07:57:12 +0000 Subject: [PATCH 3/4] refactor: rename per-device deep sleep item and add proactive expiry purge --- custom_components/opendisplay/__init__.py | 15 +++++- custom_components/opendisplay/deep_sleep.py | 2 +- custom_components/opendisplay/services.py | 24 ++++++++- tests/test_deep_sleep_queue.py | 55 ++++++++++++++++++--- 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 6b08840..d8a7473 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -34,7 +34,7 @@ DOMAIN, ) from .coordinator import OpenDisplayCoordinator -from .deep_sleep import DeepSleepUploadQueue +from .deep_sleep import DeepSleepQueuedUpload from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -52,7 +52,8 @@ class OpenDisplayRuntimeData: device_config: GlobalConfig is_flex: bool upload_task: asyncio.Task | None = None - deep_sleep_upload: DeepSleepUploadQueue | None = None + deep_sleep_upload: DeepSleepQueuedUpload | None = None + deep_sleep_expiry_handle: asyncio.TimerHandle | None = None type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData] @@ -166,11 +167,17 @@ def _on_coordinator_update() -> None: return if queued.is_expired: entry.runtime_data.deep_sleep_upload = None + if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None: + handle.cancel() + entry.runtime_data.deep_sleep_expiry_handle = None return if async_ble_device_from_address(hass, address, connectable=True) is None: return # Device is now connectable – flush the queued upload entry.runtime_data.deep_sleep_upload = None + if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None: + handle.cancel() + entry.runtime_data.deep_sleep_expiry_handle = None from .services import _async_connect_and_run # noqa: PLC0415 – avoid circular import at module level hass.async_create_task( _async_connect_and_run(hass, entry, queued.action), @@ -194,6 +201,10 @@ async def async_unload_entry( hass: HomeAssistant, entry: OpenDisplayConfigEntry ) -> bool: """Unload a config entry.""" + if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None: + handle.cancel() + entry.runtime_data.deep_sleep_expiry_handle = None + if (task := entry.runtime_data.upload_task) and not task.done(): task.cancel() with contextlib.suppress(asyncio.CancelledError): diff --git a/custom_components/opendisplay/deep_sleep.py b/custom_components/opendisplay/deep_sleep.py index 9ed8539..4922721 100644 --- a/custom_components/opendisplay/deep_sleep.py +++ b/custom_components/opendisplay/deep_sleep.py @@ -11,7 +11,7 @@ @dataclass -class DeepSleepUploadQueue: +class DeepSleepQueuedUpload: """A pending upload waiting for a sleeping device to wake up.""" action: Callable[["OpenDisplayDevice"], Awaitable[None]] diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 421af2c..bec1ec5 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -323,13 +323,33 @@ async def _upload(device: OpenDisplayDevice) -> None: if deep_sleep_seconds > 0 else DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS ) - from .deep_sleep import DeepSleepUploadQueue - entry.runtime_data.deep_sleep_upload = DeepSleepUploadQueue( + if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None: + handle.cancel() + entry.runtime_data.deep_sleep_expiry_handle = None + + from .deep_sleep import DeepSleepQueuedUpload + queued_upload = DeepSleepQueuedUpload( action=_upload, jpeg_bytes=b"", queued_at=datetime.now(), expiry=timedelta(seconds=expiry_seconds), ) + entry.runtime_data.deep_sleep_upload = queued_upload + + def _purge_if_expired() -> None: + """Drop queued upload if it still exists when the expiry window closes.""" + current_queued = entry.runtime_data.deep_sleep_upload + if current_queued is queued_upload: + entry.runtime_data.deep_sleep_upload = None + _LOGGER.info( + "Dropped queued image upload for %s after expiry timeout", + address, + ) + entry.runtime_data.deep_sleep_expiry_handle = None + + entry.runtime_data.deep_sleep_expiry_handle = hass.loop.call_later( + expiry_seconds, _purge_if_expired + ) _LOGGER.info( "Device %s is not connectable; image upload queued for next wake-up", address, diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index 19233c4..908bb97 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -1,7 +1,7 @@ """Tests for the per-entry deep-sleep upload queue. Verifies: -- DeepSleepUploadQueue expiry logic +- DeepSleepQueuedUpload expiry logic - _async_send_image queues upload when device is not connectable - Queued upload is flushed when the coordinator receives an advertisement and the device becomes connectable @@ -14,19 +14,19 @@ import pytest -from custom_components.opendisplay.deep_sleep import DeepSleepUploadQueue +from custom_components.opendisplay.deep_sleep import DeepSleepQueuedUpload from custom_components.opendisplay.const import ( DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS, ) # --------------------------------------------------------------------------- -# DeepSleepUploadQueue unit tests +# DeepSleepQueuedUpload unit tests # --------------------------------------------------------------------------- -def _make_queued(*, seconds_old: float = 0, expiry_seconds: int = DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS) -> DeepSleepUploadQueue: - return DeepSleepUploadQueue( +def _make_queued(*, seconds_old: float = 0, expiry_seconds: int = DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS) -> DeepSleepQueuedUpload: + return DeepSleepQueuedUpload( action=AsyncMock(), jpeg_bytes=b"", queued_at=datetime.now() - timedelta(seconds=seconds_old), @@ -73,7 +73,11 @@ def _make_entry(address: str = "AA:BB:CC:DD:EE:FF", deep_sleep_time_seconds: int """Build a minimal mock config entry.""" power = SimpleNamespace(deep_sleep_time_seconds=deep_sleep_time_seconds) device_config = SimpleNamespace(power=power) - runtime_data = SimpleNamespace(deep_sleep_upload=None, device_config=device_config) + runtime_data = SimpleNamespace( + deep_sleep_upload=None, + deep_sleep_expiry_handle=None, + device_config=device_config, + ) entry = MagicMock() entry.unique_id = address entry.runtime_data = runtime_data @@ -148,7 +152,7 @@ async def test_send_image_queued_upload_replaces_previous() -> None: """A new image upload replaces any previously queued upload.""" hass = MagicMock() entry = _make_entry() - first_upload = DeepSleepUploadQueue( + first_upload = DeepSleepQueuedUpload( action=AsyncMock(), jpeg_bytes=b"", queued_at=datetime.now(), @@ -223,3 +227,40 @@ async def test_expiry_falls_back_to_default_when_deep_sleep_time_is_zero() -> No queued = entry.runtime_data.deep_sleep_upload assert queued is not None assert queued.expiry == timedelta(seconds=DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS) + + +@pytest.mark.asyncio +async def test_expiry_callback_purges_queued_upload_without_advertisement() -> None: + """Queued upload is proactively removed when expiry timer callback runs.""" + hass = MagicMock() + entry = _make_entry(deep_sleep_time_seconds=10) + img = MagicMock() + + from opendisplay import DitherMode, RefreshMode + from custom_components.opendisplay.services import _async_send_image + + fake_handle = MagicMock() + callback_holder: dict[str, object] = {} + + def _capture_call_later(_seconds: int, callback): + callback_holder["cb"] = callback + return fake_handle + + hass.loop = MagicMock() + hass.loop.call_later = MagicMock(side_effect=_capture_call_later) + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ): + await _async_send_image( + hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL + ) + + assert entry.runtime_data.deep_sleep_upload is not None + assert entry.runtime_data.deep_sleep_expiry_handle is fake_handle + + callback_holder["cb"]() + + assert entry.runtime_data.deep_sleep_upload is None + assert entry.runtime_data.deep_sleep_expiry_handle is None From 0099467c5a3dfd6d507a129cf480423cb5025e6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 08:09:08 +0000 Subject: [PATCH 4/4] Restrict deep sleep image queuing --- custom_components/opendisplay/deep_sleep.py | 2 +- custom_components/opendisplay/services.py | 20 ++++++------- tests/test_deep_sleep_queue.py | 31 ++++++++++++++------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/custom_components/opendisplay/deep_sleep.py b/custom_components/opendisplay/deep_sleep.py index 4922721..102a050 100644 --- a/custom_components/opendisplay/deep_sleep.py +++ b/custom_components/opendisplay/deep_sleep.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Callable, Awaitable +from typing import TYPE_CHECKING, Awaitable, Callable if TYPE_CHECKING: from opendisplay import OpenDisplayDevice diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index bec1ec5..3c6d190 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -48,12 +48,7 @@ if TYPE_CHECKING: from . import OpenDisplayConfigEntry -from .const import ( - CONF_ENCRYPTION_KEY, - DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS, - DOMAIN, - SIGNAL_IMAGE_UPDATED, -) +from .const import CONF_ENCRYPTION_KEY, DOMAIN, SIGNAL_IMAGE_UPDATED ATTR_IMAGE = "image" ATTR_ROTATION = "rotation" @@ -314,14 +309,15 @@ async def _upload(device: OpenDisplayDevice) -> None: rotate=rotate, ) - if async_ble_device_from_address(hass, address, connectable=True) is None: - # Device is not connectable right now – queue the upload for when it wakes. - # Derive expiry from the device's configured deep-sleep period, plus 10% buffer. - deep_sleep_seconds = entry.runtime_data.device_config.power.deep_sleep_time_seconds + deep_sleep_seconds = entry.runtime_data.device_config.power.deep_sleep_time_seconds + if ( + async_ble_device_from_address(hass, address, connectable=True) is None + and deep_sleep_seconds > 0 + ): + # Device is sleeping right now – queue the upload for when it wakes. + # Expire slightly after the configured deep-sleep interval. expiry_seconds = ( int(deep_sleep_seconds * 1.1) - if deep_sleep_seconds > 0 - else DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS ) if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None: handle.cancel() diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index 908bb97..e37f77f 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -7,10 +7,9 @@ and the device becomes connectable """ -import asyncio from datetime import datetime, timedelta from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch, call +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -207,8 +206,8 @@ async def test_expiry_derived_from_device_deep_sleep_time() -> None: @pytest.mark.asyncio -async def test_expiry_falls_back_to_default_when_deep_sleep_time_is_zero() -> None: - """Fallback expiry is used when device reports deep_sleep_time_seconds = 0.""" +async def test_send_image_uploads_immediately_when_deep_sleep_is_unsupported() -> None: + """Image upload is not queued when deep sleep is not configured.""" hass = MagicMock() entry = _make_entry(deep_sleep_time_seconds=0) @@ -216,17 +215,29 @@ async def test_expiry_falls_back_to_default_when_deep_sleep_time_is_zero() -> No from opendisplay import DitherMode, RefreshMode from custom_components.opendisplay.services import _async_send_image - with patch( - "custom_components.opendisplay.services.async_ble_device_from_address", - return_value=None, + hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") + + with ( + patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ), + patch( + "custom_components.opendisplay.services._async_connect_and_run", + new_callable=AsyncMock, + ) as mock_run, + patch( + "custom_components.opendisplay.services._pil_to_jpeg", + return_value=b"jpeg", + ), + patch("custom_components.opendisplay.services.async_dispatcher_send"), ): await _async_send_image( hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL ) - queued = entry.runtime_data.deep_sleep_upload - assert queued is not None - assert queued.expiry == timedelta(seconds=DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS) + assert entry.runtime_data.deep_sleep_upload is None + mock_run.assert_awaited_once() @pytest.mark.asyncio