diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 7b6a6d5..d8a7473 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,12 @@ if TYPE_CHECKING: from opendisplay.models import FirmwareVersion -from .const import CONF_ENCRYPTION_KEY, DOMAIN +from .const import ( + CONF_ENCRYPTION_KEY, + DOMAIN, +) from .coordinator import OpenDisplayCoordinator +from .deep_sleep import DeepSleepQueuedUpload from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -46,6 +52,8 @@ class OpenDisplayRuntimeData: device_config: GlobalConfig is_flex: bool upload_task: asyncio.Task | None = None + deep_sleep_upload: DeepSleepQueuedUpload | None = None + deep_sleep_expiry_handle: asyncio.TimerHandle | None = None type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData] @@ -150,6 +158,34 @@ 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 + 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), + name=f"opendisplay_deepsleep_flush_{address}", + ) + + entry.async_on_unload(coordinator.async_add_listener(_on_coordinator_update)) + return True @@ -165,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/config_flow.py b/custom_components/opendisplay/config_flow.py index bcd93e4..3bc0d1d 100644 --- a/custom_components/opendisplay/config_flow.py +++ b/custom_components/opendisplay/config_flow.py @@ -21,8 +21,12 @@ ) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS +from homeassistant.core import callback -from .const import CONF_ENCRYPTION_KEY, DOMAIN +from .const import ( + CONF_ENCRYPTION_KEY, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/opendisplay/const.py b/custom_components/opendisplay/const.py index 779b74c..ea4b0dc 100644 --- a/custom_components/opendisplay/const.py +++ b/custom_components/opendisplay/const.py @@ -3,3 +3,5 @@ DOMAIN = "opendisplay" CONF_ENCRYPTION_KEY = "encryption_key" SIGNAL_IMAGE_UPDATED = f"{DOMAIN}_image_updated" +# 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/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..102a050 --- /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, Awaitable, Callable + +if TYPE_CHECKING: + from opendisplay import OpenDisplayDevice + + +@dataclass +class DeepSleepQueuedUpload: + """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..3c6d190 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 @@ -293,7 +295,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 +308,50 @@ async def _upload(device: OpenDisplayDevice) -> None: fit=fit, rotate=rotate, ) + + 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 (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, + ) + 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/translations/en.json b/custom_components/opendisplay/translations/en.json index 2f4e853..9176504 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -1,326 +1,326 @@ { - "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)." + }, + "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." }, - "device_not_found": { - "message": "Could not find Bluetooth device with address `{address}`." + "inter_delay1": { + "name": "Step delay 1", + "description": "Delay after the first step before moving to the next." }, - "invalid_device_id": { - "message": "Device `{device_id}` is not a valid OpenDisplay device." + "color2": { + "name": "Color 2", + "description": "Second step color (omit or set flash count to 0 to skip)." }, - "media_download_error": { - "message": "Failed to download media: {error}" + "flash_count2": { + "name": "Flash count 2", + "description": "Number of flashes for the second step (0 skips this step)." }, - "multiple_errors": { - "message": "Errors occurred for one or more devices:\n{errors}" + "loop_delay2": { + "name": "Flash delay 2", + "description": "Delay between flashes in the second step." }, - "no_buzzers": { - "message": "Device `{device_id}` has no buzzer configured." + "inter_delay2": { + "name": "Step delay 2", + "description": "Delay after the second step before moving to the next." }, - "no_leds": { - "message": "Device `{device_id}` has no LEDs configured." + "color3": { + "name": "Color 3", + "description": "Third step color (omit or set flash count to 0 to skip)." }, - "no_targets_specified": { - "message": "No target devices specified." + "flash_count3": { + "name": "Flash count 3", + "description": "Number of flashes for the third step (0 skips this step)." }, - "upload_error": { - "message": "Failed to upload to the display: {error}" + "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" }, - "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" + "dry-run": { + "description": "Generate image without uploading to the device.", + "name": "Dry run" } + }, + "name": "Draw custom image" } + } } 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..e37f77f --- /dev/null +++ b/tests/test_deep_sleep_queue.py @@ -0,0 +1,277 @@ +"""Tests for the per-entry deep-sleep upload queue. + +Verifies: +- 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 +""" + +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.opendisplay.deep_sleep import DeepSleepQueuedUpload +from custom_components.opendisplay.const import ( + DEFAULT_DEEP_SLEEP_EXPIRY_SECONDS, +) + + +# --------------------------------------------------------------------------- +# DeepSleepQueuedUpload unit tests +# --------------------------------------------------------------------------- + + +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), + expiry=timedelta(seconds=expiry_seconds), + ) + + +def test_queued_upload_not_expired_when_fresh() -> None: + """A freshly queued upload is not expired.""" + 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(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 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(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(seconds_old=3500, expiry_seconds=3600) + assert not q.is_expired + + +# --------------------------------------------------------------------------- +# _async_send_image queuing behaviour +# --------------------------------------------------------------------------- + + +def _make_entry(address: str = "AA:BB:CC:DD:EE:FF", deep_sleep_time_seconds: int = 3600) -> MagicMock: + """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, + deep_sleep_expiry_handle=None, + device_config=device_config, + ) + entry = MagicMock() + entry.unique_id = address + 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 = DeepSleepQueuedUpload( + action=AsyncMock(), + jpeg_bytes=b"", + queued_at=datetime.now(), + expiry=timedelta(seconds=3600), + ) + 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 derived from device config +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +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(deep_sleep_time_seconds=3600) + + 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 + # 3600 * 1.1 = 3960 seconds + assert queued.expiry == timedelta(seconds=3960) + + +@pytest.mark.asyncio +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) + + img = MagicMock() + from opendisplay import DitherMode, RefreshMode + from custom_components.opendisplay.services import _async_send_image + + 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 + ) + + assert entry.runtime_data.deep_sleep_upload is None + mock_run.assert_awaited_once() + + +@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