From 0daecc40b76cdefb3b1ad03a3439f8799dc1775c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 07:58:29 +0000 Subject: [PATCH 01/16] Handle 'none' sentinel in numeric service schema fields --- custom_components/opendisplay/services.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 3c6d190..8b636b7 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -70,6 +70,17 @@ def validate(value: str) -> IntEnum: return validate +def _coerce_none_to_default(default: Any) -> Callable[[Any], Any]: + """Map Home Assistant's 'none' sentinel to a schema default value.""" + + def validate(value: Any) -> Any: + if isinstance(value, str) and value.lower() == "none": + return default + return value + + return validate + + SCHEMA_UPLOAD_IMAGE = vol.Schema( { vol.Required(ATTR_DEVICE_ID): cv.string, @@ -77,7 +88,7 @@ def validate(value: str) -> IntEnum: MediaSelectorConfig(accept=["image/*"]) ), vol.Optional(ATTR_ROTATION, default=Rotation.ROTATE_0): vol.All( - vol.Coerce(int), vol.Coerce(Rotation) + _coerce_none_to_default(Rotation.ROTATE_0), vol.Coerce(int), vol.Coerce(Rotation) ), vol.Optional(ATTR_DITHER_MODE, default="burkes"): _str_to_int_enum(DitherMode), vol.Optional(ATTR_REFRESH_MODE, default="full"): _str_to_int_enum(RefreshMode), @@ -96,9 +107,13 @@ def validate(value: str) -> IntEnum: vol.Optional("area_id", default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Required("payload"): list, vol.Optional("background", default="white"): cv.string, - vol.Optional("rotate", default=0): vol.All(vol.Coerce(int), vol.In([0, 90, 180, 270])), + vol.Optional("rotate", default=0): vol.All( + _coerce_none_to_default(0), vol.Coerce(int), vol.In([0, 90, 180, 270]) + ), vol.Optional("dither", default="ordered"): _str_to_int_enum(DitherMode), - vol.Optional("refresh_type", default=0): vol.All(vol.Coerce(int), vol.In([0, 1])), + vol.Optional("refresh_type", default=0): vol.All( + _coerce_none_to_default(0), vol.Coerce(int), vol.In([0, 1]) + ), vol.Optional("dry-run", default=False): cv.boolean, } ) From 9427328aafa6f93712caa3948204094ae3f1e64b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 07:59:49 +0000 Subject: [PATCH 02/16] Add regression tests for 'none' numeric schema inputs --- tests/test_deep_sleep_queue.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index e37f77f..e489669 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -63,6 +63,38 @@ def test_queued_upload_not_expired_with_custom_expiry() -> None: assert not q.is_expired +def test_upload_image_schema_accepts_none_rotation() -> None: + """Upload image schema should map the frontend 'none' sentinel to default rotation.""" + from opendisplay import Rotation + from custom_components.opendisplay.services import SCHEMA_UPLOAD_IMAGE + + validated = SCHEMA_UPLOAD_IMAGE( + { + "device_id": "device_1", + "image": {"media_content_id": "media-source://image"}, + "rotation": "none", + } + ) + + assert validated["rotation"] == Rotation.ROTATE_0 + + +def test_drawcustom_schema_accepts_none_numeric_fields() -> None: + """Drawcustom schema should map 'none' to default numeric values.""" + from custom_components.opendisplay.services import SCHEMA_DRAWCUSTOM + + validated = SCHEMA_DRAWCUSTOM( + { + "payload": [], + "rotate": "none", + "refresh_type": "none", + } + ) + + assert validated["rotate"] == 0 + assert validated["refresh_type"] == 0 + + # --------------------------------------------------------------------------- # _async_send_image queuing behaviour # --------------------------------------------------------------------------- From e753d190f9a2a7d7aaff87af7e373e4f0cea7718 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 08:14:25 +0000 Subject: [PATCH 03/16] Queue image upload on BLE timeout during deep sleep --- custom_components/opendisplay/services.py | 54 ++++++++++++++++++----- tests/test_deep_sleep_queue.py | 36 +++++++++++++++ 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 8b636b7..7354938 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -18,6 +18,8 @@ from opendisplay import ( AuthenticationFailedError, AuthenticationRequiredError, + BLEConnectionError, + BLETimeoutError, DitherMode, FitMode, LedFlashConfig, @@ -252,6 +254,8 @@ async def _async_connect_and_run( hass: HomeAssistant, entry: "OpenDisplayConfigEntry", action: Callable[[OpenDisplayDevice], Awaitable[None]], + *, + wrap_connection_errors: bool = True, ) -> None: """Resolve BLE device, open a connection, run action, handle auth errors.""" address = entry.unique_id @@ -291,6 +295,14 @@ async def _async_connect_and_run( raise HomeAssistantError( translation_domain=DOMAIN, translation_key="authentication_error" ) from err + except (BLEConnectionError, BLETimeoutError) as err: + if not wrap_connection_errors: + raise + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_error", + translation_placeholders={"error": str(err)}, + ) from err except OpenDisplayError as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -324,21 +336,15 @@ async def _upload(device: OpenDisplayDevice) -> None: 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) - ) + def _queue_for_deep_sleep() -> None: + """Queue upload until the sleeping device becomes connectable again.""" + 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"", @@ -365,9 +371,35 @@ def _purge_if_expired() -> None: "Device %s is not connectable; image upload queued for next wake-up", address, ) + + 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. + _queue_for_deep_sleep() return - await _async_connect_and_run(hass, entry, _upload) + try: + await _async_connect_and_run( + hass, entry, _upload, wrap_connection_errors=False + ) + except (BLEConnectionError, BLETimeoutError) as err: + if deep_sleep_seconds > 0: + _LOGGER.info( + "Connection to %s failed; queued image upload for next wake-up: %s", + address, + err, + ) + _queue_for_deep_sleep() + return + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_error", + translation_placeholders={"error": str(err)}, + ) from err + 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/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index e489669..802d470 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -178,6 +178,42 @@ async def test_send_image_uploads_immediately_when_connectable() -> None: mock_run.assert_awaited_once() +@pytest.mark.asyncio +async def test_send_image_queues_when_connection_times_out() -> None: + """Image upload is queued when connection fails but deep sleep is enabled.""" + 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 + + class _FakeBLETimeoutError(Exception): + """Synthetic timeout exception for fallback queue testing.""" + + with ( + patch( + "custom_components.opendisplay.services.BLETimeoutError", + _FakeBLETimeoutError, + ), + patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=MagicMock(), # appears connectable + ), + patch( + "custom_components.opendisplay.services._async_connect_and_run", + new_callable=AsyncMock, + side_effect=_FakeBLETimeoutError("timeout"), + ) as mock_run, + ): + 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 + 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.""" From 5596671a35406feebe056c4bac9d06f1b835b120 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 08:46:30 +0000 Subject: [PATCH 04/16] Simplify deep-sleep queue logging --- custom_components/opendisplay/services.py | 25 +++++---- tests/test_deep_sleep_queue.py | 68 +++++++++++++++++------ 2 files changed, 64 insertions(+), 29 deletions(-) diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 7354938..143cca2 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -326,6 +326,8 @@ async def _async_send_image( address = entry.unique_id assert address is not None + deep_sleep_seconds = entry.runtime_data.device_config.power.deep_sleep_time_seconds + async def _upload(device: OpenDisplayDevice) -> None: await device.upload_image( img, @@ -336,7 +338,7 @@ async def _upload(device: OpenDisplayDevice) -> None: rotate=rotate, ) - def _queue_for_deep_sleep() -> None: + def _queue_for_deep_sleep(*, reason: str, error: Exception | None = None) -> None: """Queue upload until the sleeping device becomes connectable again.""" expiry_seconds = int(deep_sleep_seconds * 1.1) if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None: @@ -359,8 +361,10 @@ def _purge_if_expired() -> None: if current_queued is queued_upload: entry.runtime_data.deep_sleep_upload = None _LOGGER.info( - "Dropped queued image upload for %s after expiry timeout", + "Dropped queued image upload for %s (sleep=%ss, ttl=%ss)", address, + deep_sleep_seconds, + expiry_seconds, ) entry.runtime_data.deep_sleep_expiry_handle = None @@ -368,17 +372,21 @@ def _purge_if_expired() -> None: expiry_seconds, _purge_if_expired ) _LOGGER.info( - "Device %s is not connectable; image upload queued for next wake-up", + "Queued image upload for %s (%s, sleep=%ss, ttl=%ss)", address, + reason, + deep_sleep_seconds, + expiry_seconds, ) + if error is not None: + _LOGGER.debug("Queue trigger details for %s: %s", address, error) - 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. - _queue_for_deep_sleep() + _queue_for_deep_sleep(reason="device not connectable") return try: @@ -387,12 +395,7 @@ def _purge_if_expired() -> None: ) except (BLEConnectionError, BLETimeoutError) as err: if deep_sleep_seconds > 0: - _LOGGER.info( - "Connection to %s failed; queued image upload for next wake-up: %s", - address, - err, - ) - _queue_for_deep_sleep() + _queue_for_deep_sleep(reason="connection failed", error=err) return raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index 802d470..922e8c4 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -8,6 +8,7 @@ """ from datetime import datetime, timedelta +import logging from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -139,6 +140,30 @@ async def test_send_image_queues_when_device_not_connectable() -> None: assert not entry.runtime_data.deep_sleep_upload.is_expired +@pytest.mark.asyncio +async def test_send_image_queue_log_includes_sleep_and_ttl(caplog: pytest.LogCaptureFixture) -> None: + """Queue log includes deep sleep and TTL when device is not connectable.""" + hass = MagicMock() + entry = _make_entry(deep_sleep_time_seconds=300) + 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, + ), + caplog.at_level(logging.INFO), + ): + await _async_send_image( + hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL + ) + + assert "Queued image upload for AA:BB:CC:DD:EE:FF (device not connectable, sleep=300s, ttl=330s)" in caplog.text + + @pytest.mark.asyncio async def test_send_image_uploads_immediately_when_connectable() -> None: """Image upload proceeds immediately when the BLE device is connectable.""" @@ -191,27 +216,34 @@ async def test_send_image_queues_when_connection_times_out() -> None: class _FakeBLETimeoutError(Exception): """Synthetic timeout exception for fallback queue testing.""" - with ( - patch( - "custom_components.opendisplay.services.BLETimeoutError", - _FakeBLETimeoutError, - ), - patch( - "custom_components.opendisplay.services.async_ble_device_from_address", - return_value=MagicMock(), # appears connectable - ), - patch( - "custom_components.opendisplay.services._async_connect_and_run", - new_callable=AsyncMock, - side_effect=_FakeBLETimeoutError("timeout"), - ) as mock_run, - ): - await _async_send_image( - hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL - ) + with caplog.at_level(logging.INFO): + with ( + patch( + "custom_components.opendisplay.services.BLETimeoutError", + _FakeBLETimeoutError, + ), + patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=MagicMock(), # appears connectable + ), + patch( + "custom_components.opendisplay.services._async_connect_and_run", + new_callable=AsyncMock, + side_effect=_FakeBLETimeoutError("timeout"), + ) as mock_run, + ): + 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 mock_run.assert_awaited_once() + queue_logs = [ + rec.getMessage() for rec in caplog.records if "Queued image upload for" in rec.getMessage() + ] + assert queue_logs == [ + "Queued image upload for AA:BB:CC:DD:EE:FF (connection failed, sleep=3600s, ttl=3960s)" + ] @pytest.mark.asyncio From 57f92fbf3581a30dd35ebafd38f70d0644447210 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 08:47:45 +0000 Subject: [PATCH 05/16] Add concise deep-sleep queue log details --- tests/test_deep_sleep_queue.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index 922e8c4..3ff39bf 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -204,7 +204,9 @@ async def test_send_image_uploads_immediately_when_connectable() -> None: @pytest.mark.asyncio -async def test_send_image_queues_when_connection_times_out() -> None: +async def test_send_image_queues_when_connection_times_out( + caplog: pytest.LogCaptureFixture, +) -> None: """Image upload is queued when connection fails but deep sleep is enabled.""" hass = MagicMock() entry = _make_entry(deep_sleep_time_seconds=3600) From 6dc7656e5362cfa21aceb384a52381d70a432f4d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 09:11:27 +0000 Subject: [PATCH 06/16] Add deep-sleep startup fallback and wake-up logs --- custom_components/opendisplay/__init__.py | 197 +++++++++++++++++++--- custom_components/opendisplay/const.py | 3 + 2 files changed, 173 insertions(+), 27 deletions(-) diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index d8a7473..ca79197 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -4,8 +4,9 @@ import asyncio import contextlib -from dataclasses import dataclass -from typing import TYPE_CHECKING +from dataclasses import asdict, dataclass, is_dataclass +import logging +from typing import TYPE_CHECKING, Any from opendisplay import ( AuthenticationFailedError, @@ -21,7 +22,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.typing import ConfigType @@ -30,6 +35,9 @@ from opendisplay.models import FirmwareVersion from .const import ( + CONF_CACHED_DEVICE_CONFIG, + CONF_CACHED_FIRMWARE, + CONF_CACHED_IS_FLEX, CONF_ENCRYPTION_KEY, DOMAIN, ) @@ -38,6 +46,7 @@ from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) _BASE_PLATFORMS: list[Platform] = [Platform.IMAGE, Platform.SENSOR] _FLEX_PLATFORMS = [Platform.EVENT, Platform.IMAGE, Platform.SENSOR, Platform.UPDATE] @@ -59,6 +68,92 @@ class OpenDisplayRuntimeData: type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData] +def _serialize_device_config(device_config: GlobalConfig) -> dict[str, Any] | None: + """Serialize GlobalConfig into plain dict for ConfigEntry storage.""" + if hasattr(device_config, "model_dump"): + dumped = device_config.model_dump() + if isinstance(dumped, dict): + return dumped + if hasattr(device_config, "dict"): + dumped = device_config.dict() + if isinstance(dumped, dict): + return dumped + if hasattr(device_config, "to_dict"): + dumped = device_config.to_dict() + if isinstance(dumped, dict): + return dumped + if is_dataclass(device_config): + dumped = asdict(device_config) + if isinstance(dumped, dict): + return dumped + return None + + +def _deserialize_device_config(raw: object) -> GlobalConfig | None: + """Deserialize plain dict into GlobalConfig.""" + if not isinstance(raw, dict): + return None + if hasattr(GlobalConfig, "model_validate"): + try: + return GlobalConfig.model_validate(raw) + except Exception: + pass + if hasattr(GlobalConfig, "parse_obj"): + try: + return GlobalConfig.parse_obj(raw) + except Exception: + pass + if hasattr(GlobalConfig, "from_dict"): + try: + return GlobalConfig.from_dict(raw) + except Exception: + pass + try: + return GlobalConfig(**raw) + except Exception: + return None + + +def _cached_runtime_data( + entry: OpenDisplayConfigEntry, +) -> tuple[FirmwareVersion, GlobalConfig, bool] | None: + """Return cached runtime metadata if valid.""" + raw_firmware = entry.data.get(CONF_CACHED_FIRMWARE) + raw_device_config = entry.data.get(CONF_CACHED_DEVICE_CONFIG) + raw_is_flex = entry.data.get(CONF_CACHED_IS_FLEX) + if not isinstance(raw_firmware, dict) or not isinstance(raw_is_flex, bool): + return None + device_config = _deserialize_device_config(raw_device_config) + if device_config is None: + return None + return raw_firmware, device_config, raw_is_flex + + +def _deep_sleep_seconds(device_config: GlobalConfig) -> int: + """Return deep sleep duration from device config.""" + return int(device_config.power.deep_sleep_time_seconds) + + +def _cache_runtime_data( + hass: HomeAssistant, + entry: OpenDisplayConfigEntry, + firmware: FirmwareVersion, + device_config: GlobalConfig, + is_flex: bool, +) -> None: + """Persist runtime metadata so sleeping devices can restore quickly.""" + if not isinstance(firmware, dict): + return + serialized = _serialize_device_config(device_config) + if serialized is None: + return + data = dict(entry.data) + data[CONF_CACHED_FIRMWARE] = firmware + data[CONF_CACHED_DEVICE_CONFIG] = serialized + data[CONF_CACHED_IS_FLEX] = is_flex + hass.config_entries.async_update_entry(entry, data=data) + + def _get_encryption_key(entry: OpenDisplayConfigEntry) -> bytes | None: """Return the encryption key bytes from entry data, or None.""" raw = entry.data.get(CONF_ENCRYPTION_KEY) @@ -88,31 +183,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) if TYPE_CHECKING: assert address is not None + cached_runtime = _cached_runtime_data(entry) ble_device = async_ble_device_from_address(hass, address, connectable=True) - if ble_device is None: - raise ConfigEntryNotReady( - f"Could not find OpenDisplay device with address {address}" - ) - encryption_key = _get_encryption_key(entry) + fw: FirmwareVersion + device_config: GlobalConfig + is_flex: bool - try: - async with OpenDisplayDevice( - mac_address=address, ble_device=ble_device, encryption_key=encryption_key - ) as device: - fw = await device.read_firmware_version() - is_flex = device.is_flex - except (AuthenticationFailedError, AuthenticationRequiredError) as err: - raise ConfigEntryAuthFailed( - f"Encryption key rejected by OpenDisplay device: {err}" - ) from err - except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err: - raise ConfigEntryNotReady( - f"Failed to connect to OpenDisplay device: {err}" - ) from err - device_config = device.config - if TYPE_CHECKING: - assert device_config is not None + if ble_device is None: + if cached_runtime is None or _deep_sleep_seconds(cached_runtime[1]) <= 0: + raise ConfigEntryNotReady( + f"Could not find OpenDisplay device with address {address}" + ) + fw, device_config, is_flex = cached_runtime + _LOGGER.info( + "%s: Device not connectable at startup; using cached config " + "(deep sleep=%ss, assumed state)", + address, + _deep_sleep_seconds(device_config), + ) + else: + try: + async with OpenDisplayDevice( + mac_address=address, + ble_device=ble_device, + encryption_key=encryption_key, + ) as device: + fw = await device.read_firmware_version() + is_flex = device.is_flex + device_config = device.config + if TYPE_CHECKING: + assert device_config is not None + except (AuthenticationFailedError, AuthenticationRequiredError) as err: + raise ConfigEntryAuthFailed( + f"Encryption key rejected by OpenDisplay device: {err}" + ) from err + except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err: + if cached_runtime is None or _deep_sleep_seconds(cached_runtime[1]) <= 0: + raise ConfigEntryNotReady( + f"Failed to connect to OpenDisplay device: {err}" + ) from err + fw, device_config, is_flex = cached_runtime + _LOGGER.info( + "%s: Startup connection failed (%s); using cached config " + "(deep sleep=%ss, assumed state)", + address, + err, + _deep_sleep_seconds(device_config), + ) + else: + _cache_runtime_data(hass, entry, fw, device_config, is_flex) coordinator = OpenDisplayCoordinator(hass, address) @@ -178,9 +298,32 @@ def _on_coordinator_update() -> 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 + + _LOGGER.info( + "%s: Device is online again; sending queued image " + "(sleep=%ss, ttl=%ss)", + address, + _deep_sleep_seconds(entry.runtime_data.device_config), + int(queued.expiry.total_seconds()), + ) + + async def _flush_queued_upload() -> None: + """Send queued upload once the device wakes up.""" + from .services import _async_connect_and_run # noqa: PLC0415 – avoid circular import at module level + + try: + await _async_connect_and_run(hass, entry, queued.action) + except HomeAssistantError as err: + _LOGGER.warning( + "%s: Failed to send queued image after wake-up: %s", + address, + err, + ) + else: + _LOGGER.info("%s: Queued image sent to display", address) + hass.async_create_task( - _async_connect_and_run(hass, entry, queued.action), + _flush_queued_upload(), name=f"opendisplay_deepsleep_flush_{address}", ) diff --git a/custom_components/opendisplay/const.py b/custom_components/opendisplay/const.py index ea4b0dc..048d743 100644 --- a/custom_components/opendisplay/const.py +++ b/custom_components/opendisplay/const.py @@ -2,6 +2,9 @@ DOMAIN = "opendisplay" CONF_ENCRYPTION_KEY = "encryption_key" +CONF_CACHED_DEVICE_CONFIG = "cached_device_config" +CONF_CACHED_FIRMWARE = "cached_firmware" +CONF_CACHED_IS_FLEX = "cached_is_flex" 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 From f98814e7ffa20a01942181327e4213c6c859b05b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 09:34:34 +0000 Subject: [PATCH 07/16] Handle deep-sleep support and runtime config sync --- custom_components/opendisplay/__init__.py | 75 ++++++++++++++++++++- custom_components/opendisplay/deep_sleep.py | 21 ++++++ custom_components/opendisplay/entity.py | 16 +++++ custom_components/opendisplay/services.py | 16 +++-- 4 files changed, 120 insertions(+), 8 deletions(-) diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index ca79197..f5fdcab 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -42,7 +42,7 @@ DOMAIN, ) from .coordinator import OpenDisplayCoordinator -from .deep_sleep import DeepSleepQueuedUpload +from .deep_sleep import DeepSleepQueuedUpload, deep_sleep_seconds from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -61,6 +61,7 @@ class OpenDisplayRuntimeData: device_config: GlobalConfig is_flex: bool upload_task: asyncio.Task | None = None + config_sync_task: asyncio.Task | None = None deep_sleep_upload: DeepSleepQueuedUpload | None = None deep_sleep_expiry_handle: asyncio.TimerHandle | None = None @@ -131,7 +132,30 @@ def _cached_runtime_data( def _deep_sleep_seconds(device_config: GlobalConfig) -> int: """Return deep sleep duration from device config.""" - return int(device_config.power.deep_sleep_time_seconds) + return deep_sleep_seconds(device_config) + + +def _log_config_changes( + address: str, + previous_config: GlobalConfig, + latest_config: GlobalConfig, +) -> None: + """Log config changes detected between cached and live device config.""" + previous = _serialize_device_config(previous_config) + latest = _serialize_device_config(latest_config) + if not isinstance(previous, dict) or not isinstance(latest, dict) or previous == latest: + return + + changed_keys = sorted( + key + for key in (set(previous.keys()) | set(latest.keys())) + if previous.get(key) != latest.get(key) + ) + _LOGGER.info( + "%s: Device config changed; syncing Home Assistant cache (changed keys: %s)", + address, + ", ".join(changed_keys) if changed_keys else "unknown", + ) def _cache_runtime_data( @@ -277,11 +301,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) entry, _get_platforms(entry.runtime_data) ) entry.async_on_unload(coordinator.async_start()) + was_available = coordinator.available + + async def _async_sync_runtime_config() -> None: + """Refresh firmware/config after the device comes back online.""" + ble_online = async_ble_device_from_address(hass, address, connectable=True) + if ble_online is None: + return + + try: + async with OpenDisplayDevice( + mac_address=address, + ble_device=ble_online, + encryption_key=encryption_key, + ) as device: + latest_fw = await device.read_firmware_version() + latest_config = device.config + if TYPE_CHECKING: + assert latest_config is not None + latest_is_flex = device.is_flex + except (AuthenticationFailedError, AuthenticationRequiredError) as err: + _LOGGER.debug("%s: Skipping runtime config sync due to auth error: %s", address, err) + return + except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err: + _LOGGER.debug("%s: Runtime config sync skipped: %s", address, err) + return + + _log_config_changes(address, entry.runtime_data.device_config, latest_config) + entry.runtime_data.firmware = latest_fw + entry.runtime_data.device_config = latest_config + entry.runtime_data.is_flex = latest_is_flex + _cache_runtime_data(hass, entry, latest_fw, latest_config, latest_is_flex) + coordinator.async_update_listeners() # 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.""" + nonlocal was_available + available_now = coordinator.available + if available_now and not was_available: + current = entry.runtime_data.config_sync_task + if current is None or current.done(): + entry.runtime_data.config_sync_task = hass.async_create_task( + _async_sync_runtime_config(), + name=f"opendisplay_sync_config_{address}", + ) + was_available = available_now + queued = entry.runtime_data.deep_sleep_upload if queued is None: return @@ -352,6 +419,10 @@ async def async_unload_entry( task.cancel() with contextlib.suppress(asyncio.CancelledError): await task + if (task := entry.runtime_data.config_sync_task) and not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task return await hass.config_entries.async_unload_platforms( entry, _get_platforms(entry.runtime_data) diff --git a/custom_components/opendisplay/deep_sleep.py b/custom_components/opendisplay/deep_sleep.py index 102a050..fa2a968 100644 --- a/custom_components/opendisplay/deep_sleep.py +++ b/custom_components/opendisplay/deep_sleep.py @@ -23,3 +23,24 @@ class DeepSleepQueuedUpload: def is_expired(self) -> bool: """Return True if the upload has passed its expiry window.""" return (datetime.now() - self.queued_at) > self.expiry + + +def supports_deep_sleep(device_config: object) -> bool: + """Return whether the device configuration exposes deep sleep support.""" + power = getattr(device_config, "power", None) + return hasattr(power, "deep_sleep_time_seconds") + + +def deep_sleep_seconds(device_config: object) -> int: + """Return configured deep sleep seconds, clamped to non-negative values.""" + power = getattr(device_config, "power", None) + raw_value = getattr(power, "deep_sleep_time_seconds", 0) + try: + return max(0, int(raw_value)) + except (TypeError, ValueError): + return 0 + + +def deep_sleep_enabled(device_config: object) -> bool: + """Return whether deep sleep is currently enabled in device config.""" + return supports_deep_sleep(device_config) and deep_sleep_seconds(device_config) > 0 diff --git a/custom_components/opendisplay/entity.py b/custom_components/opendisplay/entity.py index 7cd13bc..68c9e7e 100644 --- a/custom_components/opendisplay/entity.py +++ b/custom_components/opendisplay/entity.py @@ -9,6 +9,7 @@ from homeassistant.helpers.entity import EntityDescription from .coordinator import OpenDisplayCoordinator +from .deep_sleep import deep_sleep_enabled, supports_deep_sleep _DescriptionT = TypeVar("_DescriptionT", bound=EntityDescription) @@ -20,6 +21,7 @@ class OpenDisplayEntity( """Base class for all OpenDisplay entities.""" _attr_has_entity_name = True + _attr_assumed_state = False entity_description: _DescriptionT def __init__( @@ -35,3 +37,17 @@ def __init__( self._attr_device_info = DeviceInfo( connections={(CONNECTION_BLUETOOTH, coordinator.address)}, ) + + @property + def available(self) -> bool: + """Return True when device is online or assumed online due to deep sleep.""" + if self.coordinator.available: + self._attr_assumed_state = False + return True + + device_config = self.coordinator.config_entry.runtime_data.device_config + deep_sleep_active = supports_deep_sleep(device_config) and deep_sleep_enabled( + device_config + ) + self._attr_assumed_state = deep_sleep_active + return deep_sleep_active diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 143cca2..581bbea 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -51,6 +51,7 @@ from . import OpenDisplayConfigEntry from .const import CONF_ENCRYPTION_KEY, DOMAIN, SIGNAL_IMAGE_UPDATED +from .deep_sleep import deep_sleep_enabled, deep_sleep_seconds, supports_deep_sleep ATTR_IMAGE = "image" ATTR_ROTATION = "rotation" @@ -326,7 +327,10 @@ async def _async_send_image( address = entry.unique_id assert address is not None - deep_sleep_seconds = entry.runtime_data.device_config.power.deep_sleep_time_seconds + device_config = entry.runtime_data.device_config + deep_sleep_supported = supports_deep_sleep(device_config) + sleep_seconds = deep_sleep_seconds(device_config) + deep_sleep_active = deep_sleep_supported and deep_sleep_enabled(device_config) async def _upload(device: OpenDisplayDevice) -> None: await device.upload_image( @@ -340,7 +344,7 @@ async def _upload(device: OpenDisplayDevice) -> None: def _queue_for_deep_sleep(*, reason: str, error: Exception | None = None) -> None: """Queue upload until the sleeping device becomes connectable again.""" - expiry_seconds = int(deep_sleep_seconds * 1.1) + expiry_seconds = int(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 @@ -363,7 +367,7 @@ def _purge_if_expired() -> None: _LOGGER.info( "Dropped queued image upload for %s (sleep=%ss, ttl=%ss)", address, - deep_sleep_seconds, + sleep_seconds, expiry_seconds, ) entry.runtime_data.deep_sleep_expiry_handle = None @@ -375,7 +379,7 @@ def _purge_if_expired() -> None: "Queued image upload for %s (%s, sleep=%ss, ttl=%ss)", address, reason, - deep_sleep_seconds, + sleep_seconds, expiry_seconds, ) if error is not None: @@ -383,7 +387,7 @@ def _purge_if_expired() -> None: if ( async_ble_device_from_address(hass, address, connectable=True) is None - and deep_sleep_seconds > 0 + and deep_sleep_active ): # Device is sleeping right now – queue the upload for when it wakes. _queue_for_deep_sleep(reason="device not connectable") @@ -394,7 +398,7 @@ def _purge_if_expired() -> None: hass, entry, _upload, wrap_connection_errors=False ) except (BLEConnectionError, BLETimeoutError) as err: - if deep_sleep_seconds > 0: + if deep_sleep_active: _queue_for_deep_sleep(reason="connection failed", error=err) return raise HomeAssistantError( From 042b01747d986b043344edb485b02db94990089f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 09:36:58 +0000 Subject: [PATCH 08/16] Tidy config sync logging formatting --- custom_components/opendisplay/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index f5fdcab..823f577 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -143,7 +143,11 @@ def _log_config_changes( """Log config changes detected between cached and live device config.""" previous = _serialize_device_config(previous_config) latest = _serialize_device_config(latest_config) - if not isinstance(previous, dict) or not isinstance(latest, dict) or previous == latest: + if ( + not isinstance(previous, dict) + or not isinstance(latest, dict) + or previous == latest + ): return changed_keys = sorted( @@ -321,7 +325,11 @@ async def _async_sync_runtime_config() -> None: assert latest_config is not None latest_is_flex = device.is_flex except (AuthenticationFailedError, AuthenticationRequiredError) as err: - _LOGGER.debug("%s: Skipping runtime config sync due to auth error: %s", address, err) + _LOGGER.debug( + "%s: Skipping runtime config sync due to auth error: %s", + address, + err, + ) return except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err: _LOGGER.debug("%s: Runtime config sync skipped: %s", address, err) From f6fe2558e35da68641eed53fd7e94dd1f7609fce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 10:14:28 +0000 Subject: [PATCH 09/16] Add deep-sleep runtime sync tests --- custom_components/opendisplay/__init__.py | 6 +- tests/test_deep_sleep_runtime_sync.py | 190 ++++++++++++++++++++++ 2 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 tests/test_deep_sleep_runtime_sync.py diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 823f577..f71d270 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -342,10 +342,10 @@ async def _async_sync_runtime_config() -> None: _cache_runtime_data(hass, entry, latest_fw, latest_config, latest_is_flex) coordinator.async_update_listeners() - # Register coordinator listener to flush queued deep-sleep uploads when - # the device wakes up and becomes connectable again. + # Register coordinator listener to refresh runtime config and flush any + # queued deep-sleep upload when the device wakes up. def _on_coordinator_update() -> None: - """Try to flush any queued deep-sleep upload when device advertises.""" + """Handle wake-up transitions and queued uploads on coordinator updates.""" nonlocal was_available available_now = coordinator.available if available_now and not was_available: diff --git a/tests/test_deep_sleep_runtime_sync.py b/tests/test_deep_sleep_runtime_sync.py new file mode 100644 index 0000000..96e864f --- /dev/null +++ b/tests/test_deep_sleep_runtime_sync.py @@ -0,0 +1,190 @@ +"""Tests for deep-sleep restart/runtime config sync behavior.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.opendisplay import async_setup_entry + + +def _make_device_config(deep_sleep_seconds: int) -> SimpleNamespace: + """Create a minimal device config object used by async_setup_entry.""" + return SimpleNamespace( + power=SimpleNamespace(deep_sleep_time_seconds=deep_sleep_seconds), + manufacturer=SimpleNamespace( + manufacturer_name="OpenDisplay", + board_type_name="TestBoard", + board_type=1, + board_revision="1", + ), + displays=[ + SimpleNamespace( + color_scheme_enum=SimpleNamespace(name="BW"), + screen_diagonal_inches=2.9, + pixel_width=296, + pixel_height=128, + ) + ], + touch_controllers=[], + ) + + +class _FakeCoordinator: + """Minimal coordinator stub for setup/listener tests.""" + + def __init__(self) -> None: + self.available = False + self.listener = None + self.update_listeners_called = False + + def async_start(self): + return lambda: None + + def async_add_listener(self, listener): + self.listener = listener + return lambda: None + + def async_update_listeners(self) -> None: + self.update_listeners_called = True + + +def _make_hass() -> MagicMock: + """Create minimal hass mock that can schedule tasks and forward platforms.""" + hass = MagicMock() + hass.config_entries = MagicMock() + hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True) + hass.config_entries.async_update_entry = MagicMock() + hass_tasks: list[asyncio.Task] = [] + + def _create_task(coro, *, name=None): + task = asyncio.create_task(coro, name=name) + hass_tasks.append(task) + return task + + hass.async_create_task = MagicMock(side_effect=_create_task) + hass._test_tasks = hass_tasks + return hass + + +def _make_entry() -> MagicMock: + """Create minimal config entry mock.""" + entry = MagicMock() + entry.unique_id = "AA:BB:CC:DD:EE:FF" + entry.entry_id = "entry-1" + entry.data = {} + entry.async_on_unload = MagicMock() + return entry + + +@pytest.mark.asyncio +async def test_restart_during_deep_sleep_uses_cached_runtime_and_syncs_when_available() -> None: + """Restart while sleeping uses cache and later syncs on availability edge.""" + hass = _make_hass() + entry = _make_entry() + coordinator = _FakeCoordinator() + + cached_config = _make_device_config(300) + latest_config = _make_device_config(600) + latest_fw = {"major": 9, "minor": 9} + + class _FakeDevice: + is_flex = False + config = latest_config + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def read_firmware_version(self): + return latest_fw + + with ( + patch( + "custom_components.opendisplay._cached_runtime_data", + return_value=({"major": 1, "minor": 0}, cached_config, False), + ), + patch("custom_components.opendisplay.OpenDisplayCoordinator", return_value=coordinator), + patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), + patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), + patch( + "custom_components.opendisplay.async_ble_device_from_address", + side_effect=[None, MagicMock()], + ), + patch("custom_components.opendisplay._cache_runtime_data"), + ): + assert await async_setup_entry(hass, entry) is True + assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == 300 + + coordinator.available = True + coordinator.listener() + await asyncio.gather(*hass._test_tasks) + + assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == 600 + assert coordinator.update_listeners_called is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("cached_sleep", "latest_sleep"), + [ + (300, 300), # config unchanged + (300, 900), # deep-sleep time changed + (300, 0), # deep-sleep disabled + (0, 300), # deep-sleep enabled + ], +) +async def test_runtime_config_sync_updates_deep_sleep_value_without_restart( + cached_sleep: int, + latest_sleep: int, +) -> None: + """Live availability transition refreshes runtime deep-sleep config.""" + hass = _make_hass() + entry = _make_entry() + coordinator = _FakeCoordinator() + + cached_config = _make_device_config(cached_sleep) + latest_config = _make_device_config(latest_sleep) + latest_fw = {"major": 2, "minor": 3} + + class _FakeDevice: + is_flex = False + config = latest_config + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def read_firmware_version(self): + return latest_fw + + with ( + patch( + "custom_components.opendisplay._cached_runtime_data", + return_value=({"major": 1, "minor": 0}, cached_config, False), + ), + patch("custom_components.opendisplay.OpenDisplayCoordinator", return_value=coordinator), + patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), + patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), + patch( + "custom_components.opendisplay.async_ble_device_from_address", + side_effect=[None, MagicMock()], + ), + patch("custom_components.opendisplay._cache_runtime_data") as mock_cache_runtime_data, + ): + assert await async_setup_entry(hass, entry) is True + + coordinator.available = True + coordinator.listener() + await asyncio.gather(*hass._test_tasks) + + assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == latest_sleep + assert mock_cache_runtime_data.call_args.args[3] == latest_config + From 728195d603bf519d343ad30ac61ee5e07c3972ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 10:15:24 +0000 Subject: [PATCH 10/16] Cover deep-sleep restart sync scenarios --- tests/test_deep_sleep_runtime_sync.py | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/test_deep_sleep_runtime_sync.py b/tests/test_deep_sleep_runtime_sync.py index 96e864f..758dee1 100644 --- a/tests/test_deep_sleep_runtime_sync.py +++ b/tests/test_deep_sleep_runtime_sync.py @@ -92,8 +92,9 @@ async def test_restart_during_deep_sleep_uses_cached_runtime_and_syncs_when_avai latest_fw = {"major": 9, "minor": 9} class _FakeDevice: - is_flex = False - config = latest_config + def __init__(self, **kwargs) -> None: + self.is_flex = False + self.config = latest_config async def __aenter__(self): return self @@ -131,7 +132,7 @@ async def read_firmware_version(self): @pytest.mark.asyncio @pytest.mark.parametrize( - ("cached_sleep", "latest_sleep"), + ("initial_sleep", "latest_sleep"), [ (300, 300), # config unchanged (300, 900), # deep-sleep time changed @@ -140,7 +141,7 @@ async def read_firmware_version(self): ], ) async def test_runtime_config_sync_updates_deep_sleep_value_without_restart( - cached_sleep: int, + initial_sleep: int, latest_sleep: int, ) -> None: """Live availability transition refreshes runtime deep-sleep config.""" @@ -148,13 +149,15 @@ async def test_runtime_config_sync_updates_deep_sleep_value_without_restart( entry = _make_entry() coordinator = _FakeCoordinator() - cached_config = _make_device_config(cached_sleep) + initial_config = _make_device_config(initial_sleep) latest_config = _make_device_config(latest_sleep) - latest_fw = {"major": 2, "minor": 3} + fw_values = [{"major": 1, "minor": 0}, {"major": 2, "minor": 3}] + config_values = [initial_config, latest_config] class _FakeDevice: - is_flex = False - config = latest_config + def __init__(self, **kwargs) -> None: + self.is_flex = False + self.config = config_values.pop(0) async def __aenter__(self): return self @@ -163,23 +166,21 @@ async def __aexit__(self, exc_type, exc, tb): return False async def read_firmware_version(self): - return latest_fw + return fw_values.pop(0) with ( - patch( - "custom_components.opendisplay._cached_runtime_data", - return_value=({"major": 1, "minor": 0}, cached_config, False), - ), + patch("custom_components.opendisplay._cached_runtime_data", return_value=None), patch("custom_components.opendisplay.OpenDisplayCoordinator", return_value=coordinator), patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), patch( "custom_components.opendisplay.async_ble_device_from_address", - side_effect=[None, MagicMock()], + side_effect=[MagicMock(), MagicMock()], ), patch("custom_components.opendisplay._cache_runtime_data") as mock_cache_runtime_data, ): assert await async_setup_entry(hass, entry) is True + assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == initial_sleep coordinator.available = True coordinator.listener() @@ -187,4 +188,3 @@ async def read_firmware_version(self): assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == latest_sleep assert mock_cache_runtime_data.call_args.args[3] == latest_config - From bfc17529bef2d6a7d5187fb5ac44ec54cb14ebc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 10:54:45 +0000 Subject: [PATCH 11/16] Improve upload logging and queue replacement tests --- custom_components/opendisplay/services.py | 34 ++++++-- tests/test_deep_sleep_queue.py | 98 ++++++++++++++--------- 2 files changed, 90 insertions(+), 42 deletions(-) diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 581bbea..0d76b28 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -331,6 +331,9 @@ async def _async_send_image( deep_sleep_supported = supports_deep_sleep(device_config) sleep_seconds = deep_sleep_seconds(device_config) deep_sleep_active = deep_sleep_supported and deep_sleep_enabled(device_config) + is_connectable_now = ( + async_ble_device_from_address(hass, address, connectable=True) is not None + ) async def _upload(device: OpenDisplayDevice) -> None: await device.upload_image( @@ -345,6 +348,8 @@ async def _upload(device: OpenDisplayDevice) -> None: def _queue_for_deep_sleep(*, reason: str, error: Exception | None = None) -> None: """Queue upload until the sleeping device becomes connectable again.""" expiry_seconds = int(sleep_seconds * 1.1) + now = datetime.now() + previous_upload = entry.runtime_data.deep_sleep_upload if (handle := entry.runtime_data.deep_sleep_expiry_handle) is not None: handle.cancel() entry.runtime_data.deep_sleep_expiry_handle = None @@ -354,10 +359,19 @@ def _queue_for_deep_sleep(*, reason: str, error: Exception | None = None) -> Non queued_upload = DeepSleepQueuedUpload( action=_upload, jpeg_bytes=b"", - queued_at=datetime.now(), + queued_at=now, expiry=timedelta(seconds=expiry_seconds), ) entry.runtime_data.deep_sleep_upload = queued_upload + if previous_upload is not None: + age = now - previous_upload.queued_at + ttl_left = max(0, int((previous_upload.expiry - age).total_seconds())) + _LOGGER.info( + "Replacing queued image upload for %s; previous ttl_left=%ss, reset ttl=%ss", + address, + ttl_left, + expiry_seconds, + ) def _purge_if_expired() -> None: """Drop queued upload if it still exists when the expiry window closes.""" @@ -385,13 +399,22 @@ def _purge_if_expired() -> None: if error is not None: _LOGGER.debug("Queue trigger details for %s: %s", address, error) - if ( - async_ble_device_from_address(hass, address, connectable=True) is None - and deep_sleep_active - ): + if not is_connectable_now and deep_sleep_active: # Device is sleeping right now – queue the upload for when it wakes. _queue_for_deep_sleep(reason="device not connectable") return + if deep_sleep_active and is_connectable_now: + _LOGGER.info( + "Uploading image to %s immediately (device connectable, deep sleep=%ss)", + address, + sleep_seconds, + ) + elif not deep_sleep_active: + _LOGGER.info( + "Uploading image to %s immediately (deep sleep unsupported/disabled, connectable=%s)", + address, + is_connectable_now, + ) try: await _async_connect_and_run( @@ -409,6 +432,7 @@ def _purge_if_expired() -> None: jpeg = await hass.async_add_executor_job(_pil_to_jpeg, img) async_dispatcher_send(hass, f"{SIGNAL_IMAGE_UPDATED}_{entry.unique_id}", jpeg) + _LOGGER.info("%s: Upload completed and image cache updated", address) async def _async_upload_image(call: ServiceCall) -> None: diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index 3ff39bf..37ef163 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -165,7 +165,9 @@ async def test_send_image_queue_log_includes_sleep_and_ttl(caplog: pytest.LogCap @pytest.mark.asyncio -async def test_send_image_uploads_immediately_when_connectable() -> None: +async def test_send_image_uploads_immediately_when_connectable( + caplog: pytest.LogCaptureFixture, +) -> None: """Image upload proceeds immediately when the BLE device is connectable.""" hass = MagicMock() entry = _make_entry() @@ -178,21 +180,18 @@ async def test_send_image_uploads_immediately_when_connectable() -> None: 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"), - ): + 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" + ), caplog.at_level(logging.INFO): await _async_send_image( hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL ) @@ -201,6 +200,11 @@ async def test_send_image_uploads_immediately_when_connectable() -> None: assert entry.runtime_data.deep_sleep_upload is None # _async_connect_and_run should have been called mock_run.assert_awaited_once() + assert ( + "Uploading image to AA:BB:CC:DD:EE:FF immediately (device connectable, deep sleep=3600s)" + in caplog.text + ) + assert "AA:BB:CC:DD:EE:FF: Upload completed and image cache updated" in caplog.text @pytest.mark.asyncio @@ -249,15 +253,20 @@ class _FakeBLETimeoutError(Exception): @pytest.mark.asyncio -async def test_send_image_queued_upload_replaces_previous() -> None: +async def test_send_image_queued_upload_replaces_previous( + caplog: pytest.LogCaptureFixture, +) -> None: """A new image upload replaces any previously queued upload.""" hass = MagicMock() - entry = _make_entry() + entry = _make_entry(deep_sleep_time_seconds=300) + old_handle = MagicMock() + new_handle = MagicMock() + entry.runtime_data.deep_sleep_expiry_handle = old_handle first_upload = DeepSleepQueuedUpload( action=AsyncMock(), jpeg_bytes=b"", - queued_at=datetime.now(), - expiry=timedelta(seconds=3600), + queued_at=datetime.now() - timedelta(seconds=240), + expiry=timedelta(seconds=300), ) entry.runtime_data.deep_sleep_upload = first_upload @@ -265,10 +274,13 @@ async def test_send_image_queued_upload_replaces_previous() -> None: from opendisplay import DitherMode, RefreshMode from custom_components.opendisplay.services import _async_send_image + hass.loop = MagicMock() + hass.loop.call_later = MagicMock(return_value=new_handle) + with patch( "custom_components.opendisplay.services.async_ble_device_from_address", return_value=None, - ): + ), caplog.at_level(logging.INFO): await _async_send_image( hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL ) @@ -276,6 +288,14 @@ async def test_send_image_queued_upload_replaces_previous() -> None: new_upload = entry.runtime_data.deep_sleep_upload assert new_upload is not None assert new_upload is not first_upload + assert new_upload.expiry == timedelta(seconds=330) + assert entry.runtime_data.deep_sleep_expiry_handle is new_handle + old_handle.cancel.assert_called_once() + assert ( + "Replacing queued image upload for AA:BB:CC:DD:EE:FF; previous ttl_left=" + in caplog.text + ) + assert "reset ttl=330s" in caplog.text # --------------------------------------------------------------------------- @@ -308,7 +328,9 @@ async def test_expiry_derived_from_device_deep_sleep_time() -> None: @pytest.mark.asyncio -async def test_send_image_uploads_immediately_when_deep_sleep_is_unsupported() -> None: +async def test_send_image_uploads_immediately_when_deep_sleep_is_unsupported( + caplog: pytest.LogCaptureFixture, +) -> None: """Image upload is not queued when deep sleep is not configured.""" hass = MagicMock() entry = _make_entry(deep_sleep_time_seconds=0) @@ -319,27 +341,29 @@ async def test_send_image_uploads_immediately_when_deep_sleep_is_unsupported() - 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"), - ): + 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" + ), caplog.at_level(logging.INFO): 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() + assert ( + "Uploading image to AA:BB:CC:DD:EE:FF immediately " + "(deep sleep unsupported/disabled, connectable=False)" in caplog.text + ) + assert "AA:BB:CC:DD:EE:FF: Upload completed and image cache updated" in caplog.text @pytest.mark.asyncio From f1cf9d352de6cc4d8f7e7ad7bbdc51a84a4cf381 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 11:59:46 +0000 Subject: [PATCH 12/16] Fix deep-sleep queued upload wake retry handling --- custom_components/opendisplay/__init__.py | 79 +++++++++-- tests/test_deep_sleep_runtime_sync.py | 154 +++++++++++++++++++++- 2 files changed, 219 insertions(+), 14 deletions(-) diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index f71d270..ad028ba 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -5,6 +5,7 @@ import asyncio import contextlib from dataclasses import asdict, dataclass, is_dataclass +from datetime import datetime import logging from typing import TYPE_CHECKING, Any @@ -62,6 +63,7 @@ class OpenDisplayRuntimeData: is_flex: bool upload_task: asyncio.Task | None = None config_sync_task: asyncio.Task | None = None + deep_sleep_flush_task: asyncio.Task | None = None deep_sleep_upload: DeepSleepQueuedUpload | None = None deep_sleep_expiry_handle: asyncio.TimerHandle | None = None @@ -357,6 +359,10 @@ def _on_coordinator_update() -> None: ) was_available = available_now + flush_task = entry.runtime_data.deep_sleep_flush_task + if flush_task is not None and flush_task.done(): + entry.runtime_data.deep_sleep_flush_task = None + queued = entry.runtime_data.deep_sleep_upload if queued is None: return @@ -367,19 +373,24 @@ def _on_coordinator_update() -> None: entry.runtime_data.deep_sleep_expiry_handle = None return if async_ble_device_from_address(hass, address, connectable=True) is None: + _LOGGER.debug( + "%s: Queued image still waiting; device is not connectable", + address, + ) + return + if entry.runtime_data.deep_sleep_flush_task is not None: + _LOGGER.debug("%s: Queued image flush already in progress", address) 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 + + queued_age = datetime.now() - queued.queued_at + ttl_left = max(0, int((queued.expiry - queued_age).total_seconds())) _LOGGER.info( - "%s: Device is online again; sending queued image " - "(sleep=%ss, ttl=%ss)", + "%s: Device is online again; attempting queued image upload " + "(sleep=%ss, ttl_left=%ss)", address, _deep_sleep_seconds(entry.runtime_data.device_config), - int(queued.expiry.total_seconds()), + ttl_left, ) async def _flush_queued_upload() -> None: @@ -387,17 +398,58 @@ async def _flush_queued_upload() -> None: from .services import _async_connect_and_run # noqa: PLC0415 – avoid circular import at module level try: - await _async_connect_and_run(hass, entry, queued.action) + await _async_connect_and_run( + hass, entry, queued.action, wrap_connection_errors=False + ) + except (BLEConnectionError, BLETimeoutError) as err: + current_queued = entry.runtime_data.deep_sleep_upload + if current_queued is queued and not queued.is_expired: + queued_age = datetime.now() - queued.queued_at + ttl_left = max(0, int((queued.expiry - queued_age).total_seconds())) + _LOGGER.info( + "%s: Queued image upload deferred again; keeping queue " + "(ttl_left=%ss): %s", + address, + ttl_left, + err, + ) + else: + _LOGGER.debug( + "%s: Queued image upload failed after wake-up but queue is no " + "longer active: %s", + address, + err, + ) except HomeAssistantError as err: + if entry.runtime_data.deep_sleep_upload is queued: + 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 _LOGGER.warning( - "%s: Failed to send queued image after wake-up: %s", + "%s: Failed to send queued image after wake-up; " + "dropping queue: %s", address, err, ) else: + if entry.runtime_data.deep_sleep_upload is queued: + 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 _LOGGER.info("%s: Queued image sent to display", address) + finally: + if ( + task := asyncio.current_task() + ) is not None and entry.runtime_data.deep_sleep_flush_task is task: + entry.runtime_data.deep_sleep_flush_task = None - hass.async_create_task( + entry.runtime_data.deep_sleep_flush_task = hass.async_create_task( _flush_queued_upload(), name=f"opendisplay_deepsleep_flush_{address}", ) @@ -427,6 +479,11 @@ async def async_unload_entry( task.cancel() with contextlib.suppress(asyncio.CancelledError): await task + if (task := entry.runtime_data.deep_sleep_flush_task) and not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + entry.runtime_data.deep_sleep_flush_task = None if (task := entry.runtime_data.config_sync_task) and not task.done(): task.cancel() with contextlib.suppress(asyncio.CancelledError): diff --git a/tests/test_deep_sleep_runtime_sync.py b/tests/test_deep_sleep_runtime_sync.py index 758dee1..5f4359d 100644 --- a/tests/test_deep_sleep_runtime_sync.py +++ b/tests/test_deep_sleep_runtime_sync.py @@ -3,12 +3,16 @@ from __future__ import annotations import asyncio +from datetime import datetime, timedelta +import logging from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest +import custom_components.opendisplay as opendisplay_integration from custom_components.opendisplay import async_setup_entry +from custom_components.opendisplay.deep_sleep import DeepSleepQueuedUpload def _make_device_config(deep_sleep_seconds: int) -> SimpleNamespace: @@ -110,7 +114,10 @@ async def read_firmware_version(self): "custom_components.opendisplay._cached_runtime_data", return_value=({"major": 1, "minor": 0}, cached_config, False), ), - patch("custom_components.opendisplay.OpenDisplayCoordinator", return_value=coordinator), + patch( + "custom_components.opendisplay.OpenDisplayCoordinator", + return_value=coordinator, + ), patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), patch( @@ -170,14 +177,19 @@ async def read_firmware_version(self): with ( patch("custom_components.opendisplay._cached_runtime_data", return_value=None), - patch("custom_components.opendisplay.OpenDisplayCoordinator", return_value=coordinator), + patch( + "custom_components.opendisplay.OpenDisplayCoordinator", + return_value=coordinator, + ), patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), patch( "custom_components.opendisplay.async_ble_device_from_address", side_effect=[MagicMock(), MagicMock()], ), - patch("custom_components.opendisplay._cache_runtime_data") as mock_cache_runtime_data, + patch( + "custom_components.opendisplay._cache_runtime_data" + ) as mock_cache_runtime_data, ): assert await async_setup_entry(hass, entry) is True assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == initial_sleep @@ -188,3 +200,139 @@ async def read_firmware_version(self): assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == latest_sleep assert mock_cache_runtime_data.call_args.args[3] == latest_config + + +@pytest.mark.asyncio +async def test_queued_upload_is_kept_when_wake_flush_has_connection_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """Wake-up flush keeps queued image for later retry on transient BLE errors.""" + hass = _make_hass() + entry = _make_entry() + coordinator = _FakeCoordinator() + initial_config = _make_device_config(300) + + class _FakeDevice: + def __init__(self, **kwargs) -> None: + self.is_flex = False + self.config = initial_config + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def read_firmware_version(self): + return {"major": 1, "minor": 0} + + queued_action = AsyncMock() + queued_upload = DeepSleepQueuedUpload( + action=queued_action, + jpeg_bytes=b"", + queued_at=datetime.now() - timedelta(seconds=20), + expiry=timedelta(seconds=330), + ) + expiry_handle = MagicMock() + + with ( + patch("custom_components.opendisplay._cached_runtime_data", return_value=None), + patch( + "custom_components.opendisplay.OpenDisplayCoordinator", + return_value=coordinator, + ), + patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), + patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), + patch( + "custom_components.opendisplay.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch("custom_components.opendisplay._cache_runtime_data"), + patch( + "custom_components.opendisplay.services._async_connect_and_run", + new_callable=AsyncMock, + side_effect=opendisplay_integration.BLEConnectionError("no free slot"), + ), + caplog.at_level(logging.INFO), + ): + assert await async_setup_entry(hass, entry) is True + entry.runtime_data.deep_sleep_upload = queued_upload + entry.runtime_data.deep_sleep_expiry_handle = expiry_handle + + coordinator.available = True + coordinator.listener() + await asyncio.gather(*hass._test_tasks) + + assert entry.runtime_data.deep_sleep_upload is queued_upload + assert entry.runtime_data.deep_sleep_expiry_handle is expiry_handle + assert entry.runtime_data.deep_sleep_flush_task is None + expiry_handle.cancel.assert_not_called() + assert "Queued image upload deferred again; keeping queue" in caplog.text + + +@pytest.mark.asyncio +async def test_queued_upload_is_cleared_when_wake_flush_succeeds() -> None: + """Wake-up flush removes queued image after successful upload.""" + hass = _make_hass() + entry = _make_entry() + coordinator = _FakeCoordinator() + initial_config = _make_device_config(300) + + class _FakeDevice: + def __init__(self, **kwargs) -> None: + self.is_flex = False + self.config = initial_config + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def read_firmware_version(self): + return {"major": 1, "minor": 0} + + queued_upload = DeepSleepQueuedUpload( + action=AsyncMock(), + jpeg_bytes=b"", + queued_at=datetime.now() - timedelta(seconds=20), + expiry=timedelta(seconds=330), + ) + expiry_handle = MagicMock() + + with ( + patch("custom_components.opendisplay._cached_runtime_data", return_value=None), + patch( + "custom_components.opendisplay.OpenDisplayCoordinator", + return_value=coordinator, + ), + patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), + patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), + patch( + "custom_components.opendisplay.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch("custom_components.opendisplay._cache_runtime_data"), + patch( + "custom_components.opendisplay.services._async_connect_and_run", + new_callable=AsyncMock, + ) as mock_connect, + ): + assert await async_setup_entry(hass, entry) is True + entry.runtime_data.deep_sleep_upload = queued_upload + entry.runtime_data.deep_sleep_expiry_handle = expiry_handle + + coordinator.available = True + coordinator.listener() + await asyncio.gather(*hass._test_tasks) + + mock_connect.assert_awaited_once_with( + hass, + entry, + queued_upload.action, + wrap_connection_errors=False, + ) + assert entry.runtime_data.deep_sleep_upload is None + assert entry.runtime_data.deep_sleep_expiry_handle is None + assert entry.runtime_data.deep_sleep_flush_task is None + expiry_handle.cancel.assert_called_once() From 91e7bc5f8bf4d75193f1a9e1d21930516cacf89c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 12:29:23 +0000 Subject: [PATCH 13/16] Fix deep-sleep queue dropped on BLE cache expiry; add opendisplay mock stub and regression tests --- custom_components/opendisplay/services.py | 6 + tests/conftest.py | 180 +++++++++++++++++++++- tests/test_deep_sleep_queue.py | 53 +++++++ tests/test_deep_sleep_runtime_sync.py | 75 +++++++++ 4 files changed, 313 insertions(+), 1 deletion(-) diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 0d76b28..df87a3a 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -263,6 +263,12 @@ async def _async_connect_and_run( assert address is not None ble_device = async_ble_device_from_address(hass, address, connectable=True) if ble_device is None: + if not wrap_connection_errors: + # Treat a missing BLE cache entry as a retryable connection failure so + # callers like the deep-sleep flush keep the queued upload instead of + # dropping it when the connectable cache expires between the pre-check + # and the actual connect attempt. + raise BLEConnectionError(f"OpenDisplay device {address} not connectable") raise HomeAssistantError( translation_domain=DOMAIN, translation_key="device_not_found", diff --git a/tests/conftest.py b/tests/conftest.py index a5a98a8..5dafbc9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,185 @@ from __future__ import annotations import sys -import homeassistant.helpers.selector # ensure it's loaded first +import types +from enum import IntEnum +from unittest.mock import MagicMock + + +def _stub_opendisplay() -> None: + """Install a minimal opendisplay stub so unit tests run without the real library.""" + if "opendisplay" in sys.modules: + return + + # --- exception types --- + class OpenDisplayError(Exception): + pass + + class BLEConnectionError(OpenDisplayError): + pass + + class BLETimeoutError(OpenDisplayError): + pass + + class AuthenticationFailedError(OpenDisplayError): + pass + + class AuthenticationRequiredError(OpenDisplayError): + pass + + # --- enums --- + class DitherMode(IntEnum): + BURKES = 0 + FLOYD_STEINBERG = 1 + NONE = 2 + ORDERED = 3 + + class RefreshMode(IntEnum): + FULL = 0 + PARTIAL = 1 + + class FitMode(IntEnum): + CONTAIN = 0 + COVER = 1 + FILL = 2 + + class Rotation(IntEnum): + ROTATE_0 = 0 + ROTATE_90 = 90 + ROTATE_180 = 180 + ROTATE_270 = 270 + + # --- device / config types --- + class GlobalConfig: + pass + + class OpenDisplayDevice: + def __init__(self, **kwargs): + self.is_flex = False + self.config = None + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def read_firmware_version(self): + return {"major": 1, "minor": 0} + + async def upload_image(self, *args, **kwargs): + pass + + class LedFlashConfig: + pass + + class LedFlashStep: + pass + + class BuzzerActivateConfig: + pass + + class AdvertisementTracker: + def update(self, *args, **kwargs): + return [] + + class AdvertisementData: + pass + + class ButtonChangeEvent: + pass + + class TouchChangeEvent: + pass + + class TouchTracker: + def update(self, *args, **kwargs): + return [] + + MANUFACTURER_ID = 0x0B9B + + def parse_advertisement(data): + return AdvertisementData() + + def voltage_to_percent(v): + return 50 + + # Build the opendisplay package and sub-modules in sys.modules + pkg = types.ModuleType("opendisplay") + pkg.OpenDisplayError = OpenDisplayError + pkg.BLEConnectionError = BLEConnectionError + pkg.BLETimeoutError = BLETimeoutError + pkg.AuthenticationFailedError = AuthenticationFailedError + pkg.AuthenticationRequiredError = AuthenticationRequiredError + pkg.DitherMode = DitherMode + pkg.RefreshMode = RefreshMode + pkg.FitMode = FitMode + pkg.Rotation = Rotation + pkg.GlobalConfig = GlobalConfig + pkg.OpenDisplayDevice = OpenDisplayDevice + pkg.LedFlashConfig = LedFlashConfig + pkg.LedFlashStep = LedFlashStep + pkg.BuzzerActivateConfig = BuzzerActivateConfig + pkg.AdvertisementTracker = AdvertisementTracker + pkg.MANUFACTURER_ID = MANUFACTURER_ID + pkg.parse_advertisement = parse_advertisement + pkg.voltage_to_percent = voltage_to_percent + + # opendisplay.models + models_mod = types.ModuleType("opendisplay.models") + models_mod.FirmwareVersion = dict # TypedDict equivalent + pkg.models = models_mod + + # opendisplay.models.advertisement + adv_mod = types.ModuleType("opendisplay.models.advertisement") + adv_mod.AdvertisementData = AdvertisementData + adv_mod.ButtonChangeEvent = ButtonChangeEvent + adv_mod.TouchChangeEvent = TouchChangeEvent + adv_mod.TouchTracker = TouchTracker + + # opendisplay.models.enums + enums_mod = types.ModuleType("opendisplay.models.enums") + + class CapacityEstimator(IntEnum): + UNKNOWN = 0 + + class PowerMode(IntEnum): + NORMAL = 0 + DEEP_SLEEP = 1 + + enums_mod.CapacityEstimator = CapacityEstimator + enums_mod.PowerMode = PowerMode + + # opendisplay.models.firmware + firmware_mod = types.ModuleType("opendisplay.models.firmware") + firmware_mod.firmware_release_repo = MagicMock() + + # Register all sub-modules + sys.modules["opendisplay"] = pkg + sys.modules["opendisplay.models"] = models_mod + sys.modules["opendisplay.models.advertisement"] = adv_mod + sys.modules["opendisplay.models.enums"] = enums_mod + sys.modules["opendisplay.models.firmware"] = firmware_mod + + # Also stub epaper_dithering and odl_renderer used in services.py + if "epaper_dithering" not in sys.modules: + epaper_mod = types.ModuleType("epaper_dithering") + + class ColorScheme(IntEnum): + BW = 0 + + epaper_mod.ColorScheme = ColorScheme + sys.modules["epaper_dithering"] = epaper_mod + + if "odl_renderer" not in sys.modules: + odl_mod = types.ModuleType("odl_renderer") + odl_mod.generate_image = MagicMock() + sys.modules["odl_renderer"] = odl_mod + + +_stub_opendisplay() + +import homeassistant.helpers.selector # ensure it's loaded first # noqa: E402 def _stub_ha_selector() -> None: diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index 37ef163..9632b64 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -401,3 +401,56 @@ def _capture_call_later(_seconds: int, callback): assert entry.runtime_data.deep_sleep_upload is None assert entry.runtime_data.deep_sleep_expiry_handle is None + + +# --------------------------------------------------------------------------- +# _async_connect_and_run — device-not-found error wrapping +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_connect_and_run_raises_ble_error_when_no_device_wrap_false() -> None: + """_async_connect_and_run raises BLEConnectionError (retryable) when the BLE + connectable cache has no entry and wrap_connection_errors=False. + + This ensures deep-sleep flush callers keep the queued upload for retry + instead of permanently dropping it. + """ + from opendisplay import BLEConnectionError as _BLEConnectionError + from custom_components.opendisplay.services import _async_connect_and_run + + hass = MagicMock() + entry = MagicMock() + entry.unique_id = "AA:BB:CC:DD:EE:FF" + + with ( + patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ), + pytest.raises(_BLEConnectionError), + ): + await _async_connect_and_run(hass, entry, AsyncMock(), wrap_connection_errors=False) + + +@pytest.mark.asyncio +async def test_connect_and_run_raises_home_assistant_error_when_no_device_wrap_true() -> None: + """_async_connect_and_run raises HomeAssistantError (user-visible) when the BLE + connectable cache has no entry and wrap_connection_errors=True (the default). + """ + from homeassistant.exceptions import HomeAssistantError + from custom_components.opendisplay.services import _async_connect_and_run + + hass = MagicMock() + entry = MagicMock() + entry.unique_id = "AA:BB:CC:DD:EE:FF" + entry.data = {} + + with ( + patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ), + pytest.raises(HomeAssistantError), + ): + await _async_connect_and_run(hass, entry, AsyncMock(), wrap_connection_errors=True) diff --git a/tests/test_deep_sleep_runtime_sync.py b/tests/test_deep_sleep_runtime_sync.py index 5f4359d..037d336 100644 --- a/tests/test_deep_sleep_runtime_sync.py +++ b/tests/test_deep_sleep_runtime_sync.py @@ -336,3 +336,78 @@ async def read_firmware_version(self): assert entry.runtime_data.deep_sleep_expiry_handle is None assert entry.runtime_data.deep_sleep_flush_task is None expiry_handle.cancel.assert_called_once() + + +@pytest.mark.asyncio +async def test_queued_upload_kept_when_ble_cache_expires_between_precheck_and_flush( + caplog: pytest.LogCaptureFixture, +) -> None: + """Race condition: BLE connectable cache expires between the coordinator + pre-check (device appears connectable) and the actual connect attempt inside + _async_connect_and_run (device is gone from cache). + + The queue must be kept for the next wake-up cycle, not dropped. + """ + hass = _make_hass() + entry = _make_entry() + coordinator = _FakeCoordinator() + initial_config = _make_device_config(300) + + class _FakeDevice: + def __init__(self, **kwargs) -> None: + self.is_flex = False + self.config = initial_config + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def read_firmware_version(self): + return {"major": 1, "minor": 0} + + queued_action = AsyncMock() + queued_upload = DeepSleepQueuedUpload( + action=queued_action, + jpeg_bytes=b"", + queued_at=datetime.now() - timedelta(seconds=20), + expiry=timedelta(seconds=330), + ) + expiry_handle = MagicMock() + + with ( + patch("custom_components.opendisplay._cached_runtime_data", return_value=None), + patch( + "custom_components.opendisplay.OpenDisplayCoordinator", + return_value=coordinator, + ), + patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), + patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), + # __init__.py pre-check sees the device as connectable + patch( + "custom_components.opendisplay.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch("custom_components.opendisplay._cache_runtime_data"), + # services.py connect attempt finds the cache entry gone (race condition) + patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ), + caplog.at_level(logging.INFO), + ): + assert await async_setup_entry(hass, entry) is True + entry.runtime_data.deep_sleep_upload = queued_upload + entry.runtime_data.deep_sleep_expiry_handle = expiry_handle + + coordinator.available = True + coordinator.listener() + await asyncio.gather(*hass._test_tasks) + + # Queue must be preserved so the next wake-up can retry the upload + assert entry.runtime_data.deep_sleep_upload is queued_upload + assert entry.runtime_data.deep_sleep_expiry_handle is expiry_handle + assert entry.runtime_data.deep_sleep_flush_task is None + expiry_handle.cancel.assert_not_called() + assert "Queued image upload deferred again; keeping queue" in caplog.text From 1f50ecfd2149c32c825d89706d0ceb999c2198a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 13:26:12 +0000 Subject: [PATCH 14/16] fix: sanitize legacy OpenDisplay config entry data --- custom_components/opendisplay/__init__.py | 96 +++++++++++++++++++++-- custom_components/opendisplay/services.py | 2 + tests/conftest.py | 1 - tests/test_coordinator_connectable.py | 1 - tests/test_deep_sleep_runtime_sync.py | 49 ++++++++++++ 5 files changed, 139 insertions(+), 10 deletions(-) diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index ad028ba..e939d89 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -4,6 +4,7 @@ import asyncio import contextlib +from collections.abc import Mapping from dataclasses import asdict, dataclass, is_dataclass from datetime import datetime import logging @@ -18,6 +19,11 @@ OpenDisplayDevice, OpenDisplayError, ) +try: + from opendisplay.models.config_json import config_from_json, config_to_json +except ImportError: + config_from_json = None + config_to_json = None from homeassistant.components.bluetooth import async_ble_device_from_address from homeassistant.config_entries import ConfigEntry @@ -73,6 +79,14 @@ class OpenDisplayRuntimeData: def _serialize_device_config(device_config: GlobalConfig) -> dict[str, Any] | None: """Serialize GlobalConfig into plain dict for ConfigEntry storage.""" + if config_to_json is not None: + try: + dumped = config_to_json(device_config) + except Exception: + pass + else: + if isinstance(dumped, dict): + return dumped if hasattr(device_config, "model_dump"): dumped = device_config.model_dump() if isinstance(dumped, dict): @@ -96,6 +110,11 @@ def _deserialize_device_config(raw: object) -> GlobalConfig | None: """Deserialize plain dict into GlobalConfig.""" if not isinstance(raw, dict): return None + if config_from_json is not None: + try: + return config_from_json(raw) + except Exception: + pass if hasattr(GlobalConfig, "model_validate"): try: return GlobalConfig.model_validate(raw) @@ -117,13 +136,70 @@ def _deserialize_device_config(raw: object) -> GlobalConfig | None: return None +def _normalize_stored_encryption_key(raw: object) -> str | None: + """Normalize stored encryption key into a lowercase hex string.""" + if raw is None: + return None + if isinstance(raw, str): + return raw.strip().lower() + if isinstance(raw, (bytes, bytearray)): + raw_bytes = bytes(raw) + if len(raw_bytes) == 16: + return raw_bytes.hex() + try: + return raw_bytes.decode().strip().lower() + except UnicodeDecodeError: + return None + return None + + +def _contains_bytes(value: object) -> bool: + """Return True if the structure contains raw bytes.""" + if isinstance(value, (bytes, bytearray)): + return True + if isinstance(value, Mapping): + return any(_contains_bytes(item) for item in value.values()) + if isinstance(value, (list, tuple)): + return any(_contains_bytes(item) for item in value) + return False + + +def _normalize_entry_data(data: Mapping[str, Any]) -> dict[str, Any]: + """Normalize config entry data so Home Assistant can persist it.""" + normalized = dict(data) + + raw_key = normalized.get(CONF_ENCRYPTION_KEY) + normalized_key = _normalize_stored_encryption_key(raw_key) + if raw_key is None: + normalized.pop(CONF_ENCRYPTION_KEY, None) + elif normalized_key is None: + normalized.pop(CONF_ENCRYPTION_KEY, None) + else: + normalized[CONF_ENCRYPTION_KEY] = normalized_key + + raw_device_config = normalized.get(CONF_CACHED_DEVICE_CONFIG) + if isinstance(raw_device_config, dict): + if _deserialize_device_config(raw_device_config) is None or _contains_bytes( + raw_device_config + ): + normalized.pop(CONF_CACHED_DEVICE_CONFIG, None) + normalized.pop(CONF_CACHED_FIRMWARE, None) + normalized.pop(CONF_CACHED_IS_FLEX, None) + elif raw_device_config is not None: + normalized.pop(CONF_CACHED_DEVICE_CONFIG, None) + normalized.pop(CONF_CACHED_FIRMWARE, None) + normalized.pop(CONF_CACHED_IS_FLEX, None) + + return normalized + + def _cached_runtime_data( - entry: OpenDisplayConfigEntry, + entry_data: Mapping[str, Any], ) -> tuple[FirmwareVersion, GlobalConfig, bool] | None: """Return cached runtime metadata if valid.""" - raw_firmware = entry.data.get(CONF_CACHED_FIRMWARE) - raw_device_config = entry.data.get(CONF_CACHED_DEVICE_CONFIG) - raw_is_flex = entry.data.get(CONF_CACHED_IS_FLEX) + raw_firmware = entry_data.get(CONF_CACHED_FIRMWARE) + raw_device_config = entry_data.get(CONF_CACHED_DEVICE_CONFIG) + raw_is_flex = entry_data.get(CONF_CACHED_IS_FLEX) if not isinstance(raw_firmware, dict) or not isinstance(raw_is_flex, bool): return None device_config = _deserialize_device_config(raw_device_config) @@ -184,9 +260,9 @@ def _cache_runtime_data( hass.config_entries.async_update_entry(entry, data=data) -def _get_encryption_key(entry: OpenDisplayConfigEntry) -> bytes | None: +def _get_encryption_key(entry_data: Mapping[str, Any]) -> bytes | None: """Return the encryption key bytes from entry data, or None.""" - raw = entry.data.get(CONF_ENCRYPTION_KEY) + raw = _normalize_stored_encryption_key(entry_data.get(CONF_ENCRYPTION_KEY)) if raw is None: return None if len(raw) != 32: @@ -209,13 +285,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) -> bool: """Set up OpenDisplay from a config entry.""" + entry_data = _normalize_entry_data(entry.data) + if entry_data != entry.data: + hass.config_entries.async_update_entry(entry, data=entry_data) + address = entry.unique_id if TYPE_CHECKING: assert address is not None - cached_runtime = _cached_runtime_data(entry) + cached_runtime = _cached_runtime_data(entry_data) ble_device = async_ble_device_from_address(hass, address, connectable=True) - encryption_key = _get_encryption_key(entry) + encryption_key = _get_encryption_key(entry_data) fw: FirmwareVersion device_config: GlobalConfig is_flex: bool diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index df87a3a..1f2dcbc 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -276,6 +276,8 @@ async def _async_connect_and_run( ) raw_key = entry.data.get(CONF_ENCRYPTION_KEY) + if isinstance(raw_key, (bytes, bytearray)): + raw_key = bytes(raw_key).hex() if raw_key is not None and len(raw_key) != 32: entry.async_start_reauth(hass) raise HomeAssistantError( diff --git a/tests/conftest.py b/tests/conftest.py index 5dafbc9..bc8133d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -186,7 +186,6 @@ class ColorScheme(IntEnum): _stub_opendisplay() -import homeassistant.helpers.selector # ensure it's loaded first # noqa: E402 def _stub_ha_selector() -> None: diff --git a/tests/test_coordinator_connectable.py b/tests/test_coordinator_connectable.py index e7a1d9a..96544bc 100644 --- a/tests/test_coordinator_connectable.py +++ b/tests/test_coordinator_connectable.py @@ -7,7 +7,6 @@ from unittest.mock import MagicMock, patch -import pytest from custom_components.opendisplay.coordinator import ( BluetoothScanningMode, diff --git a/tests/test_deep_sleep_runtime_sync.py b/tests/test_deep_sleep_runtime_sync.py index 037d336..ccd9346 100644 --- a/tests/test_deep_sleep_runtime_sync.py +++ b/tests/test_deep_sleep_runtime_sync.py @@ -12,6 +12,12 @@ import custom_components.opendisplay as opendisplay_integration from custom_components.opendisplay import async_setup_entry +from custom_components.opendisplay.const import ( + CONF_CACHED_DEVICE_CONFIG, + CONF_CACHED_FIRMWARE, + CONF_CACHED_IS_FLEX, + CONF_ENCRYPTION_KEY, +) from custom_components.opendisplay.deep_sleep import DeepSleepQueuedUpload @@ -84,6 +90,49 @@ def _make_entry() -> MagicMock: return entry +def test_normalize_entry_data_converts_legacy_key_and_drops_byte_cache() -> None: + """Legacy byte values must not remain in config-entry storage.""" + normalized = opendisplay_integration._normalize_entry_data( + { + CONF_ENCRYPTION_KEY: b"\x01" * 16, + CONF_CACHED_FIRMWARE: {"major": 1, "minor": 0}, + CONF_CACHED_DEVICE_CONFIG: { + "system": {"reserved": b"\x00" * 15}, + "manufacturer": {"reserved": b"\x00" * 18}, + }, + CONF_CACHED_IS_FLEX: False, + } + ) + + assert normalized[CONF_ENCRYPTION_KEY] == "01" * 16 + assert CONF_CACHED_DEVICE_CONFIG not in normalized + assert CONF_CACHED_FIRMWARE not in normalized + assert CONF_CACHED_IS_FLEX not in normalized + + +def test_cache_runtime_data_uses_json_safe_device_config() -> None: + """Cached device config should use the library JSON serializer.""" + hass = _make_hass() + entry = _make_entry() + firmware = {"major": 1, "minor": 2} + serialized_config = {"version": 1, "packets": []} + + with patch( + "custom_components.opendisplay.config_to_json", + return_value=serialized_config, + ): + opendisplay_integration._cache_runtime_data( + hass, entry, firmware, MagicMock(), False + ) + + hass.config_entries.async_update_entry.assert_called_once() + assert hass.config_entries.async_update_entry.call_args.kwargs["data"] == { + CONF_CACHED_FIRMWARE: firmware, + CONF_CACHED_DEVICE_CONFIG: serialized_config, + CONF_CACHED_IS_FLEX: False, + } + + @pytest.mark.asyncio async def test_restart_during_deep_sleep_uses_cached_runtime_and_syncs_when_available() -> None: """Restart while sleeping uses cache and later syncs on availability edge.""" From 08190db3c5b7478e895d7dbe6fd31cb19ac684d2 Mon Sep 17 00:00:00 2001 From: Misiu Date: Fri, 29 May 2026 17:12:48 +0200 Subject: [PATCH 15/16] add RestoreSensor --- custom_components/opendisplay/__init__.py | 67 ++++++++---- custom_components/opendisplay/entity.py | 15 ++- custom_components/opendisplay/sensor.py | 25 ++++- custom_components/opendisplay/services.py | 19 +++- tests/test_deep_sleep_queue.py | 8 ++ tests/test_deep_sleep_runtime_sync.py | 124 +++++++++++++++++++++- tests/test_entity_sleep_restore.py | 89 ++++++++++++++++ 7 files changed, 313 insertions(+), 34 deletions(-) create mode 100644 tests/test_entity_sleep_restore.py diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index e939d89..571dc09 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -36,6 +36,7 @@ ) from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType if TYPE_CHECKING: @@ -47,6 +48,7 @@ CONF_CACHED_IS_FLEX, CONF_ENCRYPTION_KEY, DOMAIN, + SIGNAL_IMAGE_UPDATED, ) from .coordinator import OpenDisplayCoordinator from .deep_sleep import DeepSleepQueuedUpload, deep_sleep_seconds @@ -57,6 +59,7 @@ _BASE_PLATFORMS: list[Platform] = [Platform.IMAGE, Platform.SENSOR] _FLEX_PLATFORMS = [Platform.EVENT, Platform.IMAGE, Platform.SENSOR, Platform.UPDATE] +_CONNECT_SETUP_TIMEOUT_SECONDS = 20 @dataclass @@ -314,20 +317,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) ) else: try: - async with OpenDisplayDevice( - mac_address=address, - ble_device=ble_device, - encryption_key=encryption_key, - ) as device: - fw = await device.read_firmware_version() - is_flex = device.is_flex - device_config = device.config - if TYPE_CHECKING: - assert device_config is not None + async with asyncio.timeout(_CONNECT_SETUP_TIMEOUT_SECONDS): + async with OpenDisplayDevice( + mac_address=address, + ble_device=ble_device, + encryption_key=encryption_key, + ) as device: + fw = await device.read_firmware_version() + is_flex = device.is_flex + device_config = device.config + if TYPE_CHECKING: + assert device_config is not None except (AuthenticationFailedError, AuthenticationRequiredError) as err: raise ConfigEntryAuthFailed( f"Encryption key rejected by OpenDisplay device: {err}" ) from err + except TimeoutError as err: + if cached_runtime is None or _deep_sleep_seconds(cached_runtime[1]) <= 0: + raise ConfigEntryNotReady( + "Timed out while connecting to OpenDisplay device" + ) from err + fw, device_config, is_flex = cached_runtime + _LOGGER.info( + "%s: Startup connection timed out; using cached config " + "(deep sleep=%ss, assumed state)", + address, + _deep_sleep_seconds(device_config), + ) except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err: if cached_runtime is None or _deep_sleep_seconds(cached_runtime[1]) <= 0: raise ConfigEntryNotReady( @@ -396,16 +412,20 @@ async def _async_sync_runtime_config() -> None: return try: - async with OpenDisplayDevice( - mac_address=address, - ble_device=ble_online, - encryption_key=encryption_key, - ) as device: - latest_fw = await device.read_firmware_version() - latest_config = device.config - if TYPE_CHECKING: - assert latest_config is not None - latest_is_flex = device.is_flex + async with asyncio.timeout(_CONNECT_SETUP_TIMEOUT_SECONDS): + async with OpenDisplayDevice( + mac_address=address, + ble_device=ble_online, + encryption_key=encryption_key, + ) as device: + latest_fw = await device.read_firmware_version() + latest_config = device.config + if TYPE_CHECKING: + assert latest_config is not None + latest_is_flex = device.is_flex + except TimeoutError: + _LOGGER.debug("%s: Runtime config sync timed out", address) + return except (AuthenticationFailedError, AuthenticationRequiredError) as err: _LOGGER.debug( "%s: Skipping runtime config sync due to auth error: %s", @@ -459,7 +479,6 @@ def _on_coordinator_update() -> None: ) return if entry.runtime_data.deep_sleep_flush_task is not None: - _LOGGER.debug("%s: Queued image flush already in progress", address) return queued_age = datetime.now() - queued.queued_at @@ -522,6 +541,12 @@ async def _flush_queued_upload() -> None: ) is not None: handle.cancel() entry.runtime_data.deep_sleep_expiry_handle = None + if queued.jpeg_bytes: + async_dispatcher_send( + hass, + f"{SIGNAL_IMAGE_UPDATED}_{address}", + queued.jpeg_bytes, + ) _LOGGER.info("%s: Queued image sent to display", address) finally: if ( diff --git a/custom_components/opendisplay/entity.py b/custom_components/opendisplay/entity.py index 68c9e7e..85c19f0 100644 --- a/custom_components/opendisplay/entity.py +++ b/custom_components/opendisplay/entity.py @@ -42,12 +42,19 @@ def __init__( def available(self) -> bool: """Return True when device is online or assumed online due to deep sleep.""" if self.coordinator.available: - self._attr_assumed_state = False return True + return self._deep_sleep_active + + @property + def assumed_state(self) -> bool: + """Return True while state is inferred for sleeping devices.""" + return (not self.coordinator.available) and self._deep_sleep_active + + @property + def _deep_sleep_active(self) -> bool: + """Return whether deep sleep should keep entities available.""" device_config = self.coordinator.config_entry.runtime_data.device_config - deep_sleep_active = supports_deep_sleep(device_config) and deep_sleep_enabled( + return supports_deep_sleep(device_config) and deep_sleep_enabled( device_config ) - self._attr_assumed_state = deep_sleep_active - return deep_sleep_active diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index 335f0a0..54441d5 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -8,9 +8,10 @@ from opendisplay.models.enums import CapacityEstimator, PowerMode from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, - SensorEntity, SensorEntityDescription, + SensorExtraStoredData, SensorStateClass, ) from homeassistant.const import ( @@ -121,14 +122,30 @@ async def async_setup_entry( ) -class OpenDisplaySensorEntity(OpenDisplayEntity, SensorEntity): +class OpenDisplaySensorEntity(OpenDisplayEntity, RestoreSensor): """A sensor entity for an OpenDisplay device.""" entity_description: OpenDisplaySensorEntityDescription + def __init__( + self, + coordinator, + description: OpenDisplaySensorEntityDescription, + ) -> None: + """Initialize the sensor entity.""" + super().__init__(coordinator, description) + self._restored_data: SensorExtraStoredData | None = None + + async def async_added_to_hass(self) -> None: + """Restore the last native value for sleeping devices after restart.""" + await super().async_added_to_hass() + self._restored_data = await self.async_get_last_sensor_data() + @property def native_value(self) -> float | int | str | datetime | None: """Return the sensor value.""" - if self.coordinator.data is None: + if self.coordinator.data is not None: + return self.entity_description.value_fn(self.coordinator.data) + if self._restored_data is None: return None - return self.entity_description.value_fn(self.coordinator.data) + return self._restored_data.native_value diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 1f2dcbc..8814800 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -353,7 +353,12 @@ async def _upload(device: OpenDisplayDevice) -> None: rotate=rotate, ) - def _queue_for_deep_sleep(*, reason: str, error: Exception | None = None) -> None: + def _queue_for_deep_sleep( + *, + reason: str, + jpeg_bytes: bytes, + error: Exception | None = None, + ) -> None: """Queue upload until the sleeping device becomes connectable again.""" expiry_seconds = int(sleep_seconds * 1.1) now = datetime.now() @@ -366,7 +371,7 @@ def _queue_for_deep_sleep(*, reason: str, error: Exception | None = None) -> Non queued_upload = DeepSleepQueuedUpload( action=_upload, - jpeg_bytes=b"", + jpeg_bytes=jpeg_bytes, queued_at=now, expiry=timedelta(seconds=expiry_seconds), ) @@ -409,7 +414,8 @@ def _purge_if_expired() -> None: if not is_connectable_now and deep_sleep_active: # Device is sleeping right now – queue the upload for when it wakes. - _queue_for_deep_sleep(reason="device not connectable") + jpeg = await hass.async_add_executor_job(_pil_to_jpeg, img) + _queue_for_deep_sleep(reason="device not connectable", jpeg_bytes=jpeg) return if deep_sleep_active and is_connectable_now: _LOGGER.info( @@ -430,7 +436,12 @@ def _purge_if_expired() -> None: ) except (BLEConnectionError, BLETimeoutError) as err: if deep_sleep_active: - _queue_for_deep_sleep(reason="connection failed", error=err) + jpeg = await hass.async_add_executor_job(_pil_to_jpeg, img) + _queue_for_deep_sleep( + reason="connection failed", + jpeg_bytes=jpeg, + error=err, + ) return raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py index 9632b64..fe543ee 100644 --- a/tests/test_deep_sleep_queue.py +++ b/tests/test_deep_sleep_queue.py @@ -120,6 +120,7 @@ def _make_entry(address: str = "AA:BB:CC:DD:EE:FF", deep_sleep_time_seconds: int 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() + hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") entry = _make_entry() img = MagicMock() @@ -137,6 +138,7 @@ async def test_send_image_queues_when_device_not_connectable() -> None: # Upload should have been queued, not sent assert entry.runtime_data.deep_sleep_upload is not None + assert entry.runtime_data.deep_sleep_upload.jpeg_bytes == b"jpeg" assert not entry.runtime_data.deep_sleep_upload.is_expired @@ -144,6 +146,7 @@ async def test_send_image_queues_when_device_not_connectable() -> None: async def test_send_image_queue_log_includes_sleep_and_ttl(caplog: pytest.LogCaptureFixture) -> None: """Queue log includes deep sleep and TTL when device is not connectable.""" hass = MagicMock() + hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") entry = _make_entry(deep_sleep_time_seconds=300) img = MagicMock() @@ -213,6 +216,7 @@ async def test_send_image_queues_when_connection_times_out( ) -> None: """Image upload is queued when connection fails but deep sleep is enabled.""" hass = MagicMock() + hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") entry = _make_entry(deep_sleep_time_seconds=3600) img = MagicMock() @@ -243,6 +247,7 @@ class _FakeBLETimeoutError(Exception): ) assert entry.runtime_data.deep_sleep_upload is not None + assert entry.runtime_data.deep_sleep_upload.jpeg_bytes == b"jpeg" mock_run.assert_awaited_once() queue_logs = [ rec.getMessage() for rec in caplog.records if "Queued image upload for" in rec.getMessage() @@ -258,6 +263,7 @@ async def test_send_image_queued_upload_replaces_previous( ) -> None: """A new image upload replaces any previously queued upload.""" hass = MagicMock() + hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") entry = _make_entry(deep_sleep_time_seconds=300) old_handle = MagicMock() new_handle = MagicMock() @@ -307,6 +313,7 @@ async def test_send_image_queued_upload_replaces_previous( 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() + hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") entry = _make_entry(deep_sleep_time_seconds=3600) img = MagicMock() @@ -370,6 +377,7 @@ async def test_send_image_uploads_immediately_when_deep_sleep_is_unsupported( async def test_expiry_callback_purges_queued_upload_without_advertisement() -> None: """Queued upload is proactively removed when expiry timer callback runs.""" hass = MagicMock() + hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") entry = _make_entry(deep_sleep_time_seconds=10) img = MagicMock() diff --git a/tests/test_deep_sleep_runtime_sync.py b/tests/test_deep_sleep_runtime_sync.py index ccd9346..022e19f 100644 --- a/tests/test_deep_sleep_runtime_sync.py +++ b/tests/test_deep_sleep_runtime_sync.py @@ -343,7 +343,7 @@ async def read_firmware_version(self): queued_upload = DeepSleepQueuedUpload( action=AsyncMock(), - jpeg_bytes=b"", + jpeg_bytes=b"queued-jpeg", queued_at=datetime.now() - timedelta(seconds=20), expiry=timedelta(seconds=330), ) @@ -366,6 +366,7 @@ async def read_firmware_version(self): "custom_components.opendisplay.services._async_connect_and_run", new_callable=AsyncMock, ) as mock_connect, + patch("custom_components.opendisplay.async_dispatcher_send") as mock_dispatch, ): assert await async_setup_entry(hass, entry) is True entry.runtime_data.deep_sleep_upload = queued_upload @@ -381,12 +382,133 @@ async def read_firmware_version(self): queued_upload.action, wrap_connection_errors=False, ) + mock_dispatch.assert_called_once_with( + hass, + "opendisplay_image_updated_AA:BB:CC:DD:EE:FF", + b"queued-jpeg", + ) assert entry.runtime_data.deep_sleep_upload is None assert entry.runtime_data.deep_sleep_expiry_handle is None assert entry.runtime_data.deep_sleep_flush_task is None expiry_handle.cancel.assert_called_once() +@pytest.mark.asyncio +async def test_restart_connect_timeout_uses_cached_runtime_for_sleeping_device() -> None: + """Startup connect timeout falls back to cache when deep sleep is configured.""" + hass = _make_hass() + entry = _make_entry() + coordinator = _FakeCoordinator() + + cached_config = _make_device_config(300) + + with ( + patch( + "custom_components.opendisplay._cached_runtime_data", + return_value=({"major": 1, "minor": 0}, cached_config, False), + ), + patch( + "custom_components.opendisplay.OpenDisplayCoordinator", + return_value=coordinator, + ), + patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), + patch("custom_components.opendisplay.OpenDisplayDevice", AsyncMock()), + patch( + "custom_components.opendisplay.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch( + "custom_components.opendisplay.asyncio.timeout", + side_effect=TimeoutError, + ), + patch("custom_components.opendisplay._cache_runtime_data"), + ): + assert await async_setup_entry(hass, entry) is True + + assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == 300 + + +@pytest.mark.asyncio +async def test_queued_upload_does_not_spawn_duplicate_flush_when_listener_reenters( + caplog: pytest.LogCaptureFixture, +) -> None: + """A second coordinator update while flushing must not start another flush.""" + hass = _make_hass() + entry = _make_entry() + coordinator = _FakeCoordinator() + initial_config = _make_device_config(300) + + class _FakeDevice: + def __init__(self, **kwargs) -> None: + self.is_flex = False + self.config = initial_config + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def read_firmware_version(self): + return {"major": 1, "minor": 0} + + queued_upload = DeepSleepQueuedUpload( + action=AsyncMock(), + jpeg_bytes=b"", + queued_at=datetime.now() - timedelta(seconds=20), + expiry=timedelta(seconds=330), + ) + expiry_handle = MagicMock() + flush_started = asyncio.Event() + allow_flush_to_finish = asyncio.Event() + + async def _blocked_connect_and_run(*args, **kwargs): + flush_started.set() + await allow_flush_to_finish.wait() + + with ( + patch("custom_components.opendisplay._cached_runtime_data", return_value=None), + patch( + "custom_components.opendisplay.OpenDisplayCoordinator", + return_value=coordinator, + ), + patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), + patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), + patch( + "custom_components.opendisplay.async_ble_device_from_address", + return_value=MagicMock(), + ), + patch("custom_components.opendisplay._cache_runtime_data"), + patch( + "custom_components.opendisplay.services._async_connect_and_run", + new_callable=AsyncMock, + side_effect=_blocked_connect_and_run, + ) as mock_connect, + caplog.at_level(logging.DEBUG), + ): + assert await async_setup_entry(hass, entry) is True + entry.runtime_data.deep_sleep_upload = queued_upload + entry.runtime_data.deep_sleep_expiry_handle = expiry_handle + + coordinator.available = True + coordinator.listener() + await asyncio.wait_for(flush_started.wait(), timeout=1) + + coordinator.listener() + allow_flush_to_finish.set() + await asyncio.gather(*hass._test_tasks) + + mock_connect.assert_awaited_once_with( + hass, + entry, + queued_upload.action, + wrap_connection_errors=False, + ) + assert entry.runtime_data.deep_sleep_upload is None + assert entry.runtime_data.deep_sleep_flush_task is None + assert "Queued image flush already in progress" not in caplog.text + + @pytest.mark.asyncio async def test_queued_upload_kept_when_ble_cache_expires_between_precheck_and_flush( caplog: pytest.LogCaptureFixture, diff --git a/tests/test_entity_sleep_restore.py b/tests/test_entity_sleep_restore.py new file mode 100644 index 0000000..65e35ee --- /dev/null +++ b/tests/test_entity_sleep_restore.py @@ -0,0 +1,89 @@ +"""Tests for sleeping-device entity behavior (assumed state + restore).""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +from custom_components.opendisplay.sensor import ( + OpenDisplaySensorEntity, + OpenDisplaySensorEntityDescription, +) + + +def _make_coordinator(*, available: bool, deep_sleep_seconds: int, data=None): + """Create a minimal coordinator-like object for entity unit tests.""" + device_config = SimpleNamespace( + power=SimpleNamespace(deep_sleep_time_seconds=deep_sleep_seconds) + ) + runtime_data = SimpleNamespace(device_config=device_config) + config_entry = SimpleNamespace(runtime_data=runtime_data) + return SimpleNamespace( + available=available, + data=data, + address="AA:BB:CC:DD:EE:FF", + config_entry=config_entry, + ) + + +def _make_description() -> OpenDisplaySensorEntityDescription: + """Return a minimal sensor description for tests.""" + return OpenDisplaySensorEntityDescription( + key="temperature", + value_fn=lambda upd: upd.advertisement.temperature_c, + ) + + +def _build_entity(*, available: bool, deep_sleep_seconds: int, data=None) -> OpenDisplaySensorEntity: + """Build sensor entity with patched coordinator base initializer.""" + coordinator = _make_coordinator( + available=available, + deep_sleep_seconds=deep_sleep_seconds, + data=data, + ) + with patch( + "custom_components.opendisplay.entity.PassiveBluetoothCoordinatorEntity.__init__", + lambda self, coordinator: setattr(self, "coordinator", coordinator), + ): + return OpenDisplaySensorEntity(coordinator, _make_description()) + + +def test_sleeping_device_is_available_with_assumed_state() -> None: + """Sleeping device should stay available with assumed_state=True.""" + entity = _build_entity(available=False, deep_sleep_seconds=300) + + assert entity.available is True + assert entity.assumed_state is True + + +def test_non_sleeping_offline_device_is_unavailable() -> None: + """Offline device without deep sleep should be unavailable.""" + entity = _build_entity(available=False, deep_sleep_seconds=0) + + assert entity.available is False + assert entity.assumed_state is False + + +def test_online_device_not_assumed() -> None: + """Online devices should never report assumed state.""" + entity = _build_entity(available=True, deep_sleep_seconds=300) + + assert entity.available is True + assert entity.assumed_state is False + + +def test_sensor_native_value_restores_last_state_when_sleeping() -> None: + """When coordinator has no fresh data, sensor falls back to restored value.""" + entity = _build_entity(available=False, deep_sleep_seconds=300) + entity._restored_data = SimpleNamespace(native_value=22.5) + + assert entity.native_value == 22.5 + + +def test_sensor_native_value_prefers_live_data_over_restored() -> None: + """Fresh coordinator data must override restored value.""" + data = SimpleNamespace(advertisement=SimpleNamespace(temperature_c=19.8)) + entity = _build_entity(available=True, deep_sleep_seconds=300, data=data) + entity._restored_data = SimpleNamespace(native_value=22.5) + + assert entity.native_value == 19.8 From b9ed913d1e6d9563bbfb6a11aab68b42fe7c967a Mon Sep 17 00:00:00 2001 From: Misiu Date: Mon, 8 Jun 2026 22:18:42 +0200 Subject: [PATCH 16/16] wip added extra sensors, fixed timeout calculation, tests. --- custom_components/opendisplay/__init__.py | 275 +++--- .../opendisplay/binary_sensor.py | 60 ++ custom_components/opendisplay/config_flow.py | 84 +- custom_components/opendisplay/const.py | 10 +- custom_components/opendisplay/coordinator.py | 468 ++++++++- custom_components/opendisplay/deep_sleep.py | 66 +- custom_components/opendisplay/entity.py | 20 +- custom_components/opendisplay/event.py | 6 +- custom_components/opendisplay/image.py | 6 +- custom_components/opendisplay/sensor.py | 79 +- custom_components/opendisplay/services.py | 905 +++++++++++++++--- custom_components/opendisplay/strings.json | 27 + .../opendisplay/translations/en.json | 27 + custom_components/opendisplay/update.py | 18 +- hacs.json | 2 +- tests/conftest.py | 85 +- tests/test_binary_sensor_pending_upload.py | 39 + tests/test_coordinator_connectable.py | 670 +++++++++++++ tests/test_deep_sleep_queue.py | 464 --------- tests/test_deep_sleep_runtime_sync.py | 584 ----------- tests/test_entity_sleep_restore.py | 153 ++- tests/test_last_seen_cache.py | 46 + tests/test_options_flow.py | 82 ++ tests/test_pending_upload.py | 455 +++++++++ 24 files changed, 3234 insertions(+), 1397 deletions(-) create mode 100644 custom_components/opendisplay/binary_sensor.py create mode 100644 tests/test_binary_sensor_pending_upload.py delete mode 100644 tests/test_deep_sleep_queue.py delete mode 100644 tests/test_deep_sleep_runtime_sync.py create mode 100644 tests/test_last_seen_cache.py create mode 100644 tests/test_options_flow.py create mode 100644 tests/test_pending_upload.py diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 571dc09..0d943a1 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -4,9 +4,8 @@ import asyncio import contextlib -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import asdict, dataclass, is_dataclass -from datetime import datetime import logging from typing import TYPE_CHECKING, Any @@ -25,41 +24,60 @@ config_from_json = None config_to_json = None -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.bluetooth import ( + async_ble_device_from_address, + async_clear_advertisement_history, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, - HomeAssistantError, ) from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util if TYPE_CHECKING: from opendisplay.models import FirmwareVersion + from .services import PendingDisplayUpload from .const import ( CONF_CACHED_DEVICE_CONFIG, CONF_CACHED_FIRMWARE, CONF_CACHED_IS_FLEX, + CONF_CACHED_LAST_SEEN, CONF_ENCRYPTION_KEY, DOMAIN, - SIGNAL_IMAGE_UPDATED, ) from .coordinator import OpenDisplayCoordinator -from .deep_sleep import DeepSleepQueuedUpload, deep_sleep_seconds -from .services import async_setup_services +from .deep_sleep import ( + deep_sleep_enabled, + deep_sleep_seconds, + deep_sleep_timeout_margin_minutes, + supports_deep_sleep, +) +from .services import async_register_pending_upload_listener, async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _LOGGER = logging.getLogger(__name__) -_BASE_PLATFORMS: list[Platform] = [Platform.IMAGE, Platform.SENSOR] -_FLEX_PLATFORMS = [Platform.EVENT, Platform.IMAGE, Platform.SENSOR, Platform.UPDATE] +_BASE_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.IMAGE, + Platform.SENSOR, +] +_FLEX_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.EVENT, + Platform.IMAGE, + Platform.SENSOR, + Platform.UPDATE, +] _CONNECT_SETUP_TIMEOUT_SECONDS = 20 +_LAST_SEEN_CACHE_MIN_DELTA_SECONDS = 60 @dataclass @@ -72,9 +90,9 @@ class OpenDisplayRuntimeData: is_flex: bool upload_task: asyncio.Task | None = None config_sync_task: asyncio.Task | None = None - deep_sleep_flush_task: asyncio.Task | None = None - deep_sleep_upload: DeepSleepQueuedUpload | None = None - deep_sleep_expiry_handle: asyncio.TimerHandle | None = None + pending_upload: PendingDisplayUpload | None = None + pending_upload_task: asyncio.Task | None = None + pending_upload_expiry_unsub: Callable[[], None] | None = None type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData] @@ -188,14 +206,38 @@ def _normalize_entry_data(data: Mapping[str, Any]) -> dict[str, Any]: normalized.pop(CONF_CACHED_DEVICE_CONFIG, None) normalized.pop(CONF_CACHED_FIRMWARE, None) normalized.pop(CONF_CACHED_IS_FLEX, None) + normalized.pop(CONF_CACHED_LAST_SEEN, None) elif raw_device_config is not None: normalized.pop(CONF_CACHED_DEVICE_CONFIG, None) normalized.pop(CONF_CACHED_FIRMWARE, None) normalized.pop(CONF_CACHED_IS_FLEX, None) + normalized.pop(CONF_CACHED_LAST_SEEN, None) + + raw_last_seen = normalized.get(CONF_CACHED_LAST_SEEN) + if raw_last_seen is not None: + last_seen = _cached_last_seen(normalized) + if last_seen is None: + normalized.pop(CONF_CACHED_LAST_SEEN, None) + else: + normalized[CONF_CACHED_LAST_SEEN] = last_seen return normalized +def _cached_last_seen(entry_data: Mapping[str, Any]) -> float | None: + """Return cached last seen timestamp if present and valid.""" + raw_last_seen = entry_data.get(CONF_CACHED_LAST_SEEN) + if raw_last_seen is None: + return None + try: + last_seen = float(raw_last_seen) + except (TypeError, ValueError): + return None + if last_seen <= 0: + return None + return last_seen + + def _cached_runtime_data( entry_data: Mapping[str, Any], ) -> tuple[FirmwareVersion, GlobalConfig, bool] | None: @@ -249,6 +291,7 @@ def _cache_runtime_data( firmware: FirmwareVersion, device_config: GlobalConfig, is_flex: bool, + last_seen: float | None = None, ) -> None: """Persist runtime metadata so sleeping devices can restore quickly.""" if not isinstance(firmware, dict): @@ -260,9 +303,38 @@ def _cache_runtime_data( data[CONF_CACHED_FIRMWARE] = firmware data[CONF_CACHED_DEVICE_CONFIG] = serialized data[CONF_CACHED_IS_FLEX] = is_flex + if last_seen is not None: + data[CONF_CACHED_LAST_SEEN] = last_seen hass.config_entries.async_update_entry(entry, data=data) +def _cache_last_seen( + hass: HomeAssistant, + entry: OpenDisplayConfigEntry, + last_seen: float | None, +) -> None: + """Persist last seen without writing storage for every BLE advertisement.""" + if last_seen is None or last_seen <= 0: + return + previous_last_seen = _cached_last_seen(entry.data) + if ( + previous_last_seen is not None + and last_seen - previous_last_seen < _LAST_SEEN_CACHE_MIN_DELTA_SECONDS + ): + return + data = dict(entry.data) + data[CONF_CACHED_LAST_SEEN] = last_seen + hass.config_entries.async_update_entry(entry, data=data) + _LOGGER.debug( + "%s: Cached last_seen persisted (last_seen=%s, previous_last_seen=%s)", + getattr(entry, "unique_id", "unknown"), + dt_util.utc_from_timestamp(last_seen).isoformat(), + dt_util.utc_from_timestamp(previous_last_seen).isoformat() + if previous_last_seen is not None + else None, + ) + + def _get_encryption_key(entry_data: Mapping[str, Any]) -> bytes | None: """Return the encryption key bytes from entry data, or None.""" raw = _normalize_stored_encryption_key(entry_data.get(CONF_ENCRYPTION_KEY)) @@ -297,11 +369,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) assert address is not None cached_runtime = _cached_runtime_data(entry_data) + cached_last_seen = _cached_last_seen(entry_data) ble_device = async_ble_device_from_address(hass, address, connectable=True) encryption_key = _get_encryption_key(entry_data) fw: FirmwareVersion device_config: GlobalConfig is_flex: bool + startup_from_cache = False if ble_device is None: if cached_runtime is None or _deep_sleep_seconds(cached_runtime[1]) <= 0: @@ -309,9 +383,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) f"Could not find OpenDisplay device with address {address}" ) fw, device_config, is_flex = cached_runtime + startup_from_cache = True _LOGGER.info( "%s: Device not connectable at startup; using cached config " - "(deep sleep=%ss, assumed state)", + "(deep sleep=%ss, startup cache fallback)", address, _deep_sleep_seconds(device_config), ) @@ -338,9 +413,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) "Timed out while connecting to OpenDisplay device" ) from err fw, device_config, is_flex = cached_runtime + startup_from_cache = True _LOGGER.info( "%s: Startup connection timed out; using cached config " - "(deep sleep=%ss, assumed state)", + "(deep sleep=%ss, startup cache fallback)", address, _deep_sleep_seconds(device_config), ) @@ -350,17 +426,57 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) f"Failed to connect to OpenDisplay device: {err}" ) from err fw, device_config, is_flex = cached_runtime + startup_from_cache = True _LOGGER.info( "%s: Startup connection failed (%s); using cached config " - "(deep sleep=%ss, assumed state)", + "(deep sleep=%ss, startup cache fallback)", address, err, _deep_sleep_seconds(device_config), ) else: _cache_runtime_data(hass, entry, fw, device_config, is_flex) + finally: + async_clear_advertisement_history(hass, address) - coordinator = OpenDisplayCoordinator(hass, address) + coordinator = OpenDisplayCoordinator( + hass, + address, + deep_sleep_time_seconds=_deep_sleep_seconds(device_config), + deep_sleep_timeout_margin_minutes=deep_sleep_timeout_margin_minutes( + entry.options + ), + ) + if startup_from_cache: + coordinator.async_startup_from_cache() + coordinator.async_restore_last_seen(cached_last_seen) + + expected_wakeup = coordinator.expected_wakeup_timestamp + cached_last_seen_iso = ( + dt_util.utc_from_timestamp(cached_last_seen).isoformat() + if cached_last_seen is not None + else None + ) + _LOGGER.info( + "%s: Startup diagnostics " + "(deep_sleep_supported=%s, deep_sleep_enabled=%s, " + "deep_sleep_seconds=%ss, deep_sleep_timeout_margin=%smin, " + "availability_window=%ss, ble_connectable_at_startup=%s, " + "online_at_startup=%s, loaded_from_cache=%s, " + "coordinator_available=%s, cached_last_seen=%s, expected_wakeup=%s)", + address, + supports_deep_sleep(device_config), + deep_sleep_enabled(device_config), + _deep_sleep_seconds(device_config), + coordinator.deep_sleep_timeout_margin_minutes, + coordinator.deep_sleep_availability_window_seconds, + ble_device is not None, + ble_device is not None and not startup_from_cache, + startup_from_cache, + coordinator.available, + cached_last_seen_iso, + expected_wakeup.isoformat() if expected_wakeup else None, + ) manufacturer = device_config.manufacturer display = device_config.displays[0] @@ -436,19 +552,24 @@ async def _async_sync_runtime_config() -> None: except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err: _LOGGER.debug("%s: Runtime config sync skipped: %s", address, err) return + finally: + async_clear_advertisement_history(hass, address) _log_config_changes(address, entry.runtime_data.device_config, latest_config) entry.runtime_data.firmware = latest_fw entry.runtime_data.device_config = latest_config entry.runtime_data.is_flex = latest_is_flex + coordinator.async_set_deep_sleep_time_seconds( + _deep_sleep_seconds(latest_config) + ) _cache_runtime_data(hass, entry, latest_fw, latest_config, latest_is_flex) - coordinator.async_update_listeners() - # Register coordinator listener to refresh runtime config and flush any - # queued deep-sleep upload when the device wakes up. + # Register coordinator listener to refresh runtime config when the device wakes. def _on_coordinator_update() -> None: - """Handle wake-up transitions and queued uploads on coordinator updates.""" + """Handle wake-up transitions and runtime config synchronization.""" nonlocal was_available + if coordinator.deep_sleep_time_seconds > 0 and coordinator.data is not None: + _cache_last_seen(hass, entry, coordinator.data.last_seen) available_now = coordinator.available if available_now and not was_available: current = entry.runtime_data.config_sync_task @@ -459,107 +580,8 @@ def _on_coordinator_update() -> None: ) was_available = available_now - flush_task = entry.runtime_data.deep_sleep_flush_task - if flush_task is not None and flush_task.done(): - entry.runtime_data.deep_sleep_flush_task = None - - 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: - _LOGGER.debug( - "%s: Queued image still waiting; device is not connectable", - address, - ) - return - if entry.runtime_data.deep_sleep_flush_task is not None: - return - - queued_age = datetime.now() - queued.queued_at - ttl_left = max(0, int((queued.expiry - queued_age).total_seconds())) - - _LOGGER.info( - "%s: Device is online again; attempting queued image upload " - "(sleep=%ss, ttl_left=%ss)", - address, - _deep_sleep_seconds(entry.runtime_data.device_config), - ttl_left, - ) - - async def _flush_queued_upload() -> None: - """Send queued upload once the device wakes up.""" - from .services import _async_connect_and_run # noqa: PLC0415 – avoid circular import at module level - - try: - await _async_connect_and_run( - hass, entry, queued.action, wrap_connection_errors=False - ) - except (BLEConnectionError, BLETimeoutError) as err: - current_queued = entry.runtime_data.deep_sleep_upload - if current_queued is queued and not queued.is_expired: - queued_age = datetime.now() - queued.queued_at - ttl_left = max(0, int((queued.expiry - queued_age).total_seconds())) - _LOGGER.info( - "%s: Queued image upload deferred again; keeping queue " - "(ttl_left=%ss): %s", - address, - ttl_left, - err, - ) - else: - _LOGGER.debug( - "%s: Queued image upload failed after wake-up but queue is no " - "longer active: %s", - address, - err, - ) - except HomeAssistantError as err: - if entry.runtime_data.deep_sleep_upload is queued: - 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 - _LOGGER.warning( - "%s: Failed to send queued image after wake-up; " - "dropping queue: %s", - address, - err, - ) - else: - if entry.runtime_data.deep_sleep_upload is queued: - 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 - if queued.jpeg_bytes: - async_dispatcher_send( - hass, - f"{SIGNAL_IMAGE_UPDATED}_{address}", - queued.jpeg_bytes, - ) - _LOGGER.info("%s: Queued image sent to display", address) - finally: - if ( - task := asyncio.current_task() - ) is not None and entry.runtime_data.deep_sleep_flush_task is task: - entry.runtime_data.deep_sleep_flush_task = None - - entry.runtime_data.deep_sleep_flush_task = hass.async_create_task( - _flush_queued_upload(), - name=f"opendisplay_deepsleep_flush_{address}", - ) - entry.async_on_unload(coordinator.async_add_listener(_on_coordinator_update)) + entry.async_on_unload(async_register_pending_upload_listener(hass, entry)) return True @@ -576,19 +598,18 @@ 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): await task - if (task := entry.runtime_data.deep_sleep_flush_task) and not task.done(): + if (task := entry.runtime_data.pending_upload_task) and not task.done(): task.cancel() with contextlib.suppress(asyncio.CancelledError): await task - entry.runtime_data.deep_sleep_flush_task = None + entry.runtime_data.pending_upload_task = None + if (unsub := entry.runtime_data.pending_upload_expiry_unsub) is not None: + unsub() + entry.runtime_data.pending_upload_expiry_unsub = None if (task := entry.runtime_data.config_sync_task) and not task.done(): task.cancel() with contextlib.suppress(asyncio.CancelledError): diff --git a/custom_components/opendisplay/binary_sensor.py b/custom_components/opendisplay/binary_sensor.py new file mode 100644 index 0000000..811dbf8 --- /dev/null +++ b/custom_components/opendisplay/binary_sensor.py @@ -0,0 +1,60 @@ +"""Binary sensor platform for OpenDisplay diagnostic entities.""" + +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OpenDisplayConfigEntry +from .entity import OpenDisplayEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OpenDisplayBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes an OpenDisplay binary sensor entity.""" + + +_PENDING_UPLOAD_DESCRIPTION = OpenDisplayBinarySensorEntityDescription( + key="pending_upload", + translation_key="pending_upload", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenDisplayConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenDisplay binary sensor entities.""" + coordinator = entry.runtime_data.coordinator + async_add_entities( + [ + OpenDisplayPendingUploadBinarySensorEntity( + coordinator, + _PENDING_UPLOAD_DESCRIPTION, + ) + ] + ) + + +class OpenDisplayPendingUploadBinarySensorEntity( + OpenDisplayEntity[OpenDisplayBinarySensorEntityDescription], + BinarySensorEntity, +): + """Binary sensor representing pending upload queue state.""" + + entity_description: OpenDisplayBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return True when there is a pending upload to be sent.""" + return self.coordinator.pending_upload diff --git a/custom_components/opendisplay/config_flow.py b/custom_components/opendisplay/config_flow.py index 3bc0d1d..f4f8059 100644 --- a/custom_components/opendisplay/config_flow.py +++ b/custom_components/opendisplay/config_flow.py @@ -17,26 +17,62 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_ble_device_from_address, + async_clear_advertisement_history, async_discovered_service_info, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_ADDRESS from homeassistant.core import callback from .const import ( + CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, CONF_ENCRYPTION_KEY, DOMAIN, + MAX_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + MIN_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, ) +from .deep_sleep import deep_sleep_timeout_margin_minutes _LOGGER = logging.getLogger(__name__) _ENCRYPTION_KEY_VALIDATOR = vol.All(str.strip, str.lower, vol.Match(r"^[0-9a-f]{32}$")) +_DEEP_SLEEP_TIMEOUT_MARGIN_VALIDATOR = vol.All( + vol.Coerce(int), + vol.Range( + min=MIN_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + max=MAX_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + ), +) + + +def _options_schema(config_entry: ConfigEntry) -> vol.Schema: + """Return the options form schema.""" + current_margin = deep_sleep_timeout_margin_minutes(config_entry.options) + return vol.Schema( + { + vol.Required( + CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + default=current_margin, + ): _DEEP_SLEEP_TIMEOUT_MARGIN_VALIDATOR + } + ) class OpenDisplayConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenDisplay.""" + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithReload: + """Return the options flow.""" + return OpenDisplayOptionsFlow() + def __init__(self) -> None: """Initialize the config flow.""" self._discovery_info: BluetoothServiceInfoBleak | None = None @@ -50,10 +86,15 @@ async def _async_test_connection( if ble_device is None: raise BLEConnectionError(f"Could not find connectable device for {address}") - async with OpenDisplayDevice( - mac_address=address, ble_device=ble_device, encryption_key=encryption_key - ) as device: - await device.read_firmware_version() + try: + async with OpenDisplayDevice( + mac_address=address, + ble_device=ble_device, + encryption_key=encryption_key, + ) as device: + await device.read_firmware_version() + finally: + async_clear_advertisement_history(self.hass, address) async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak @@ -155,7 +196,7 @@ async def _async_try_connection( """Test connection, populate errors, and return True on success.""" try: await self._async_test_connection(address, encryption_key) - except AuthenticationFailedError, AuthenticationRequiredError: + except (AuthenticationFailedError, AuthenticationRequiredError): errors[CONF_ENCRYPTION_KEY] = "invalid_auth" except OpenDisplayError: errors["base"] = "cannot_connect" @@ -242,3 +283,34 @@ async def async_step_reauth_confirm( description_placeholders={"name": reauth_entry.title}, errors=errors, ) + + +class OpenDisplayOptionsFlow(OptionsFlowWithReload): + """Handle OpenDisplay options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage OpenDisplay options.""" + errors: dict[str, str] = {} + config_entry = self.config_entry + + if user_input is not None: + try: + timeout_margin = _DEEP_SLEEP_TIMEOUT_MARGIN_VALIDATOR( + user_input[CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES] + ) + except vol.Invalid: + errors[CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES] = ( + "invalid_timeout_margin" + ) + else: + options = dict(config_entry.options) + options[CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES] = timeout_margin + return self.async_create_entry(title="", data=options) + + return self.async_show_form( + step_id="init", + data_schema=_options_schema(config_entry), + errors=errors, + ) diff --git a/custom_components/opendisplay/const.py b/custom_components/opendisplay/const.py index 048d743..71f00ab 100644 --- a/custom_components/opendisplay/const.py +++ b/custom_components/opendisplay/const.py @@ -5,6 +5,12 @@ CONF_CACHED_DEVICE_CONFIG = "cached_device_config" CONF_CACHED_FIRMWARE = "cached_firmware" CONF_CACHED_IS_FLEX = "cached_is_flex" +CONF_CACHED_LAST_SEEN = "cached_last_seen" +CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES = "deep_sleep_timeout_margin_minutes" 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 +SIGNAL_DEVICE_SEEN = f"{DOMAIN}_device_seen" +SIGNAL_PENDING_UPLOAD = f"{DOMAIN}_pending_upload" + +DEFAULT_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES = 7 +MIN_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES = 0 +MAX_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES = 24 * 60 diff --git a/custom_components/opendisplay/coordinator.py b/custom_components/opendisplay/coordinator.py index 5512175..dfeb3cc 100644 --- a/custom_components/opendisplay/coordinator.py +++ b/custom_components/opendisplay/coordinator.py @@ -1,8 +1,9 @@ """Passive BLE coordinator for OpenDisplay devices.""" from dataclasses import dataclass, field +from datetime import datetime import logging -import time +import math from opendisplay import MANUFACTURER_ID, AdvertisementTracker, parse_advertisement from opendisplay.models.advertisement import ( @@ -16,15 +17,42 @@ BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak, + MONOTONIC_TIME, ) from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothDataUpdateCoordinator, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util + +from .const import ( + CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + DEFAULT_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + SIGNAL_DEVICE_SEEN, +) +from .deep_sleep import ( + availability_window_seconds, + deep_sleep_timeout_margin_minutes as normalize_timeout_margin_minutes, +) _LOGGER: logging.Logger = logging.getLogger(__package__) +def _utc_timestamp() -> float: + """Return the current UTC timestamp using Home Assistant datetime helpers.""" + return dt_util.utcnow().timestamp() + + +def _service_info_time(service_info: BluetoothServiceInfoBleak) -> float | None: + """Return monotonic BLE event time when Home Assistant provides it.""" + try: + return float(service_info.time) + except (AttributeError, TypeError, ValueError): + return None + + @dataclass class OpenDisplayUpdate: """Parsed advertisement data for one OpenDisplay device.""" @@ -33,6 +61,7 @@ class OpenDisplayUpdate: advertisement: AdvertisementData rssi: int | None = None last_seen: float | None = None + last_seen_ble_time: float | None = None button_events: list[ButtonChangeEvent] = field(default_factory=list) touch_events: list[TouchChangeEvent] = field(default_factory=list) @@ -40,7 +69,15 @@ class OpenDisplayUpdate: class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator): """Coordinator for passive BLE advertisement updates from an OpenDisplay device.""" - def __init__(self, hass: HomeAssistant, address: str) -> None: + def __init__( + self, + hass: HomeAssistant, + address: str, + deep_sleep_time_seconds: int = 0, + deep_sleep_timeout_margin_minutes: int = ( + DEFAULT_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES + ), + ) -> None: """Initialize the coordinator.""" super().__init__( hass, @@ -52,12 +89,360 @@ def __init__(self, hass: HomeAssistant, address: str) -> None: self.data: OpenDisplayUpdate | None = None self._tracker: AdvertisementTracker = AdvertisementTracker() self.touch_trackers: list[TouchTracker] = [] + self.deep_sleep_time_seconds = max(0, int(deep_sleep_time_seconds)) + self.deep_sleep_timeout_margin_minutes = normalize_timeout_margin_minutes( + { + CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES: ( + deep_sleep_timeout_margin_minutes + ) + } + ) + self._restored_last_seen: float | None = None + self._startup_cache_started_at: float | None = None + self._started_ble_time: float = MONOTONIC_TIME() + self._last_service_info_time: float | None = None + self._deep_sleep_deadline_unsub: CALLBACK_TYPE | None = None + self._pending_upload = False + _LOGGER.debug( + "%s: Coordinator initialized " + "(deep_sleep=%ss, timeout_margin=%smin, availability_window=%ss, " + "started_ble_time=%.3f)", + self.address, + self.deep_sleep_time_seconds, + self.deep_sleep_timeout_margin_minutes, + self.deep_sleep_availability_window_seconds, + self._started_ble_time, + ) + + @callback + def async_start(self) -> CALLBACK_TYPE: + """Start Bluetooth callbacks and deep-sleep deadline tracking.""" + parent_unsub = super().async_start() + self._async_schedule_deep_sleep_deadline() + + @callback + def _async_stop() -> None: + self._async_cancel_deep_sleep_deadline() + parent_unsub() + + return _async_stop + + @property + def deep_sleep_availability_window_seconds(self) -> int: + """Return how long a sleeping device should remain available.""" + return availability_window_seconds( + self.deep_sleep_time_seconds, + self.deep_sleep_timeout_margin_minutes, + ) + + @callback + def async_set_deep_sleep_time_seconds(self, value: int) -> None: + """Set deep-sleep duration and reschedule availability deadline.""" + try: + deep_sleep_time_seconds = max(0, int(value)) + except (TypeError, ValueError): + deep_sleep_time_seconds = 0 + if deep_sleep_time_seconds != self.deep_sleep_time_seconds: + _LOGGER.info( + "%s: Deep sleep time changed from %ss to %ss", + self.address, + self.deep_sleep_time_seconds, + deep_sleep_time_seconds, + ) + self.deep_sleep_time_seconds = deep_sleep_time_seconds + self._async_schedule_deep_sleep_deadline() + self.async_update_listeners() + + @callback + def async_startup_from_cache(self) -> None: + """Assume cached startup begins inside the current deep-sleep interval.""" + self._available = False + now = _utc_timestamp() + self._startup_cache_started_at = now + _LOGGER.debug( + "%s: Startup from cached runtime data " + "(startup_reference=%s, deep_sleep=%ss, availability_window=%ss)", + self.address, + dt_util.utc_from_timestamp(now).isoformat(), + self.deep_sleep_time_seconds, + self.deep_sleep_availability_window_seconds, + ) + self._async_schedule_deep_sleep_deadline() + + def _align_restored_reference_to_current_cycle( + self, + reference_ts: float, + now: float, + ) -> float: + """Align restored last_seen to the current deep-sleep cycle at startup.""" + if self.deep_sleep_time_seconds <= 0: + return reference_ts + availability_window = self.deep_sleep_availability_window_seconds + if reference_ts + availability_window > now: + return reference_ts + margin_seconds = availability_window - self.deep_sleep_time_seconds + cycle_number = max( + 1, + math.ceil( + (now - margin_seconds - reference_ts) + / self.deep_sleep_time_seconds + ), + ) + return reference_ts + ((cycle_number - 1) * self.deep_sleep_time_seconds) + + @callback + def async_restore_last_seen(self, value: datetime | float | int | None) -> None: + """Restore last seen timestamp from Home Assistant stored sensor data.""" + if value is None: + return + if isinstance(value, datetime): + timestamp = value.timestamp() + else: + try: + timestamp = float(value) + except (TypeError, ValueError): + return + if timestamp <= 0: + return + now = _utc_timestamp() + original_timestamp = timestamp + if ( + self._startup_cache_started_at is not None + and self.deep_sleep_time_seconds > 0 + ): + timestamp = self._align_restored_reference_to_current_cycle( + timestamp, + now, + ) + self._restored_last_seen = timestamp + if timestamp != original_timestamp: + _LOGGER.info( + "%s: Restored last_seen aligned to current deep-sleep cycle " + "(restored_last_seen=%s, cycle_reference=%s, " + "deep_sleep=%ss, timeout_margin=%smin, now=%s, " + "expected_wakeup=%s, availability_deadline=%s)", + self.address, + dt_util.utc_from_timestamp(original_timestamp).isoformat(), + dt_util.utc_from_timestamp(timestamp).isoformat(), + self.deep_sleep_time_seconds, + self.deep_sleep_timeout_margin_minutes, + dt_util.utc_from_timestamp(now).isoformat(), + self.expected_wakeup_timestamp.isoformat() + if self.expected_wakeup_timestamp + else None, + self.deep_sleep_availability_deadline_timestamp.isoformat() + if self.deep_sleep_availability_deadline_timestamp + else None, + ) + _LOGGER.debug( + "%s: Restored last_seen for deep-sleep availability " + "(last_seen=%s, expected_wakeup=%s, availability_deadline=%s)", + self.address, + dt_util.utc_from_timestamp(timestamp).isoformat(), + self.expected_wakeup_timestamp.isoformat() + if self.expected_wakeup_timestamp + else None, + self.deep_sleep_availability_deadline_timestamp.isoformat() + if self.deep_sleep_availability_deadline_timestamp + else None, + ) + self._async_schedule_deep_sleep_deadline() + self.async_update_listeners() + + @property + def available(self) -> bool: + """Return availability with deep-sleep grace semantics.""" + if self.deep_sleep_time_seconds <= 0: + return super().available + + now = _utc_timestamp() + if (reference_ts := self._sleep_reference_timestamp) is not None: + return ( + now - reference_ts + ) < self.deep_sleep_availability_window_seconds + + return super().available + + @property + def pending_upload(self) -> bool: + """Return whether this coordinator currently has a pending upload.""" + return self._pending_upload + + @callback + def async_set_pending_upload(self, value: bool) -> None: + """Set pending upload state used by diagnostic entities.""" + if self._pending_upload != value: + _LOGGER.debug( + "%s: Pending upload flag changed to %s", + self.address, + value, + ) + self._pending_upload = value + self.async_update_listeners() + + @property + def expected_wakeup_timestamp(self) -> datetime | None: + """Return expected wake-up timestamp based on last seen and deep sleep.""" + if self.deep_sleep_time_seconds <= 0: + return None + reference_ts = self._sleep_reference_timestamp + if reference_ts is None: + return None + return dt_util.utc_from_timestamp( + reference_ts + self.deep_sleep_time_seconds + ) + + @property + def deep_sleep_availability_deadline_timestamp(self) -> datetime | None: + """Return when the current deep-sleep availability window expires.""" + if self.deep_sleep_time_seconds <= 0: + return None + reference_ts = self._sleep_reference_timestamp + if reference_ts is None: + return None + return dt_util.utc_from_timestamp( + reference_ts + self.deep_sleep_availability_window_seconds + ) + + @property + def _sleep_reference_timestamp(self) -> float | None: + """Return the timestamp used as the beginning of the sleep interval.""" + if self.data is not None and self.data.last_seen is not None: + return self.data.last_seen + if self._restored_last_seen is not None: + return self._restored_last_seen + return self._startup_cache_started_at + + def _is_expected_sleep(self) -> bool: + """Return True when unavailable is expected inside deep-sleep window.""" + if self.deep_sleep_time_seconds <= 0: + return False + reference_ts = self._sleep_reference_timestamp + if reference_ts is None: + return False + deadline = reference_ts + self.deep_sleep_availability_window_seconds + now = _utc_timestamp() + _LOGGER.debug( + "%s: Expected sleep window check " + "(sleep_reference=%.3f, sleep=%ss, availability_window=%ss, " + "deadline=%.3f, now=%.3f)", + self.address, + reference_ts, + self.deep_sleep_time_seconds, + self.deep_sleep_availability_window_seconds, + deadline, + now, + ) + return now < deadline + + @callback + def _async_cancel_deep_sleep_deadline(self) -> None: + """Cancel any scheduled deep-sleep availability deadline callback.""" + if self._deep_sleep_deadline_unsub is None: + return + self._deep_sleep_deadline_unsub() + self._deep_sleep_deadline_unsub = None + + @callback + def _async_schedule_deep_sleep_deadline(self) -> None: + """Schedule a state update when the deep-sleep availability window expires.""" + self._async_cancel_deep_sleep_deadline() + if self.deep_sleep_time_seconds <= 0: + return + deadline = self.deep_sleep_availability_deadline_timestamp + if deadline is None: + return + now = _utc_timestamp() + if deadline.timestamp() <= now: + _LOGGER.debug( + "%s: Deep-sleep availability deadline already expired " + "(deadline=%s, now=%s)", + self.address, + deadline.isoformat(), + dt_util.utc_from_timestamp(now).isoformat(), + ) + return + self._deep_sleep_deadline_unsub = async_track_point_in_utc_time( + self.hass, + self._async_deep_sleep_deadline_reached, + deadline, + ) + _LOGGER.debug( + "%s: Deep-sleep availability deadline scheduled " + "(deadline=%s, expected_wakeup=%s, availability_window=%ss)", + self.address, + deadline.isoformat(), + self.expected_wakeup_timestamp.isoformat() + if self.expected_wakeup_timestamp + else None, + self.deep_sleep_availability_window_seconds, + ) + + @callback + def _async_deep_sleep_deadline_reached(self, _now: datetime) -> None: + """Refresh listeners when the deep-sleep availability deadline is reached.""" + self._deep_sleep_deadline_unsub = None + if self._is_expected_sleep(): + self._async_schedule_deep_sleep_deadline() + return + _LOGGER.info( + "%s: Deep-sleep availability window expired; marking device unavailable", + self.address, + ) + self._available = False + self.async_update_listeners() + + def _is_stale_bluetooth_event( + self, + service_info: BluetoothServiceInfoBleak, + service_time: float | None, + ) -> bool: + """Return True when a BLE callback is older than the current session.""" + if service_time is None: + return False + + if service_time < self._started_ble_time: + _LOGGER.debug( + "%s: Ignoring restored Bluetooth advertisement " + "(ble_time=%.3f, coordinator_started_ble_time=%.3f, " + "has_opendisplay_manufacturer_data=%s, available=%s)", + service_info.address, + service_time, + self._started_ble_time, + MANUFACTURER_ID in service_info.manufacturer_data, + self.available, + ) + return True + + if ( + self._last_service_info_time is not None + and service_time <= self._last_service_info_time + ): + _LOGGER.debug( + "%s: Ignoring duplicate or older Bluetooth advertisement " + "(ble_time=%.3f, last_ble_time=%.3f, " + "has_opendisplay_manufacturer_data=%s, available=%s)", + service_info.address, + service_time, + self._last_service_info_time, + MANUFACTURER_ID in service_info.manufacturer_data, + self.available, + ) + return True + + return False @callback def _async_handle_unavailable( self, service_info: BluetoothServiceInfoBleak ) -> None: """Handle the device going unavailable.""" + if self._is_expected_sleep(): + _LOGGER.debug( + "%s: Device is in expected deep sleep window; availability unchanged", + service_info.address, + ) + return if self._available: _LOGGER.info("%s: Device is unavailable", service_info.address) super()._async_handle_unavailable(service_info) @@ -69,11 +454,40 @@ def _async_handle_bluetooth_event( change: BluetoothChange, ) -> None: """Handle a Bluetooth advertisement event.""" - if not self._available: - _LOGGER.info("%s: Device is available again", service_info.address) + parsed_update: OpenDisplayUpdate | None = None + service_time = _service_info_time(service_info) + _LOGGER.debug( + "%s: Bluetooth event received " + "(change=%s, ble_time=%s, coordinator_started_ble_time=%.3f, " + "last_ble_time=%s, rssi=%s, connectable=%s, " + "has_opendisplay_manufacturer_data=%s, available_before=%s, " + "deep_sleep=%ss, expected_wakeup=%s, availability_deadline=%s)", + service_info.address, + change, + service_time, + self._started_ble_time, + self._last_service_info_time, + getattr(service_info, "rssi", None), + getattr(service_info, "connectable", None), + MANUFACTURER_ID in service_info.manufacturer_data, + self.available, + self.deep_sleep_time_seconds, + self.expected_wakeup_timestamp.isoformat() + if self.expected_wakeup_timestamp + else None, + self.deep_sleep_availability_deadline_timestamp.isoformat() + if self.deep_sleep_availability_deadline_timestamp + else None, + ) + if self._is_stale_bluetooth_event(service_info, service_time): + return if MANUFACTURER_ID not in service_info.manufacturer_data: - super()._async_handle_bluetooth_event(service_info, change) + _LOGGER.debug( + "%s: Ignoring Bluetooth advertisement without OpenDisplay " + "manufacturer data", + service_info.address, + ) return try: @@ -87,6 +501,7 @@ def _async_handle_bluetooth_event( err, exc_info=True, ) + return else: button_events = self._tracker.update(service_info.address, advertisement) touch_events: list[TouchChangeEvent] = [] @@ -94,13 +509,50 @@ def _async_handle_bluetooth_event( touch_events.extend( touch_tracker.update(service_info.address, advertisement) ) - self.data = OpenDisplayUpdate( + parsed_update = OpenDisplayUpdate( address=service_info.address, advertisement=advertisement, rssi=service_info.rssi, - last_seen=time.time(), + last_seen=_utc_timestamp(), + last_seen_ble_time=service_time, button_events=button_events, touch_events=touch_events, ) + self.data = parsed_update + self._restored_last_seen = None + self._startup_cache_started_at = None + self._last_service_info_time = service_time + if not self._available: + _LOGGER.info("%s: Device is available again", service_info.address) + _LOGGER.debug( + "%s: Advertisement parsed (rssi=%s, button_events=%s, " + "touch_events=%s, ble_time=%s, last_seen=%s, expected_wakeup=%s, " + "availability_deadline=%s); signaling device seen", + service_info.address, + service_info.rssi, + len(button_events), + len(touch_events), + service_time, + dt_util.utc_from_timestamp(parsed_update.last_seen).isoformat() + if parsed_update.last_seen is not None + else None, + self.expected_wakeup_timestamp.isoformat() + if self.expected_wakeup_timestamp + else None, + self.deep_sleep_availability_deadline_timestamp.isoformat() + if self.deep_sleep_availability_deadline_timestamp + else None, + ) + async_dispatcher_send( + self.hass, + f"{SIGNAL_DEVICE_SEEN}_{service_info.address}", + ) super()._async_handle_bluetooth_event(service_info, change) + + # Parent coordinator can store raw Bluetooth service info in self.data. + # Restore parsed OpenDisplay payload so sensors always see typed fields. + if parsed_update is not None: + self.data = parsed_update + self._async_schedule_deep_sleep_deadline() + self.async_update_listeners() diff --git a/custom_components/opendisplay/deep_sleep.py b/custom_components/opendisplay/deep_sleep.py index fa2a968..dea5218 100644 --- a/custom_components/opendisplay/deep_sleep.py +++ b/custom_components/opendisplay/deep_sleep.py @@ -1,28 +1,16 @@ -"""Deep-sleep upload queue data structures.""" +"""Deep-sleep capability helpers.""" from __future__ import annotations -from dataclasses import dataclass -from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Awaitable, Callable +from collections.abc import Mapping +from typing import Any -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 +from .const import ( + CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + DEFAULT_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + MAX_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + MIN_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, +) def supports_deep_sleep(device_config: object) -> bool: @@ -44,3 +32,39 @@ def deep_sleep_seconds(device_config: object) -> int: def deep_sleep_enabled(device_config: object) -> bool: """Return whether deep sleep is currently enabled in device config.""" return supports_deep_sleep(device_config) and deep_sleep_seconds(device_config) > 0 + + +def deep_sleep_timeout_margin_minutes(options: Mapping[str, Any] | None) -> int: + """Return the configured deep-sleep timeout margin in minutes.""" + raw_value = ( + options.get(CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES) + if options is not None + else None + ) + if raw_value is None: + return DEFAULT_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES + try: + margin = int(raw_value) + except (TypeError, ValueError): + return DEFAULT_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES + return min( + MAX_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + max(MIN_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, margin), + ) + + +def availability_window_seconds( + deep_sleep_time_seconds: int, + timeout_margin_minutes: int = DEFAULT_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, +) -> int: + """Return how long a sleeping device should remain available.""" + try: + sleep_seconds = max(0, int(deep_sleep_time_seconds)) + except (TypeError, ValueError): + return 0 + if sleep_seconds <= 0: + return 0 + margin_minutes = deep_sleep_timeout_margin_minutes( + {CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES: timeout_margin_minutes} + ) + return sleep_seconds + (margin_minutes * 60) diff --git a/custom_components/opendisplay/entity.py b/custom_components/opendisplay/entity.py index 85c19f0..10044ef 100644 --- a/custom_components/opendisplay/entity.py +++ b/custom_components/opendisplay/entity.py @@ -9,7 +9,6 @@ from homeassistant.helpers.entity import EntityDescription from .coordinator import OpenDisplayCoordinator -from .deep_sleep import deep_sleep_enabled, supports_deep_sleep _DescriptionT = TypeVar("_DescriptionT", bound=EntityDescription) @@ -40,21 +39,10 @@ def __init__( @property def available(self) -> bool: - """Return True when device is online or assumed online due to deep sleep.""" - if self.coordinator.available: - return True - - return self._deep_sleep_active + """Return True when coordinator reports device available.""" + return self.coordinator.available @property def assumed_state(self) -> bool: - """Return True while state is inferred for sleeping devices.""" - return (not self.coordinator.available) and self._deep_sleep_active - - @property - def _deep_sleep_active(self) -> bool: - """Return whether deep sleep should keep entities available.""" - device_config = self.coordinator.config_entry.runtime_data.device_config - return supports_deep_sleep(device_config) and deep_sleep_enabled( - device_config - ) + """OpenDisplay entities do not expose assumed state.""" + return False diff --git a/custom_components/opendisplay/event.py b/custom_components/opendisplay/event.py index e5891af..00456bd 100644 --- a/custom_components/opendisplay/event.py +++ b/custom_components/opendisplay/event.py @@ -39,7 +39,7 @@ async def async_setup_entry( entry: OpenDisplayConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up OpenDisplay event entities from binary_inputs and touch_controllers config.""" + """Set up OpenDisplay event entities from binary and touch controller config.""" coordinator = entry.runtime_data.coordinator entity_registry = er.async_get(hass) @@ -92,7 +92,9 @@ def _remove_stale(prefix: str, active_ids: set[str]) -> None: icon="mdi:gesture-tap", ) ) - touch_trackers.append(TouchTracker(tc.instance_number, tc.touch_data_start_byte)) + touch_trackers.append( + TouchTracker(tc.instance_number, tc.touch_data_start_byte) + ) coordinator.touch_trackers = touch_trackers diff --git a/custom_components/opendisplay/image.py b/custom_components/opendisplay/image.py index 3d32703..e704bb5 100644 --- a/custom_components/opendisplay/image.py +++ b/custom_components/opendisplay/image.py @@ -30,7 +30,11 @@ class OpenDisplayImageEntity(ImageEntity): _attr_translation_key = "content" _attr_content_type = "image/jpeg" - def __init__(self, hass: HomeAssistant, coordinator: OpenDisplayCoordinator) -> None: + def __init__( + self, + hass: HomeAssistant, + coordinator: OpenDisplayCoordinator, + ) -> None: """Initialize the image entity.""" super().__init__(hass) self._coordinator = coordinator diff --git a/custom_components/opendisplay/sensor.py b/custom_components/opendisplay/sensor.py index 54441d5..13ebe37 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -2,7 +2,8 @@ from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime +import logging from opendisplay import voltage_to_percent from opendisplay.models.enums import CapacityEstimator, PowerMode @@ -11,7 +12,6 @@ RestoreSensor, SensorDeviceClass, SensorEntityDescription, - SensorExtraStoredData, SensorStateClass, ) from homeassistant.const import ( @@ -20,15 +20,18 @@ EntityCategory, UnitOfElectricPotential, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from . import OpenDisplayConfigEntry from .coordinator import OpenDisplayUpdate from .entity import OpenDisplayEntity PARALLEL_UPDATES = 0 +_LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) @@ -79,12 +82,31 @@ class OpenDisplaySensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda upd: ( - datetime.fromtimestamp(upd.last_seen, tz=timezone.utc) + dt_util.utc_from_timestamp(upd.last_seen) if upd.last_seen is not None else None ), ) +_DEEP_SLEEP_TIME_DESCRIPTION = OpenDisplaySensorEntityDescription( + key="deep_sleep_time", + translation_key="deep_sleep_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda upd: None, +) + +_EXPECTED_WAKEUP_DESCRIPTION = OpenDisplaySensorEntityDescription( + key="expected_wakeup", + translation_key="expected_wakeup", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda upd: None, +) + async def async_setup_entry( hass: HomeAssistant, @@ -98,6 +120,8 @@ async def async_setup_entry( _TEMPERATURE_DESCRIPTION, _RSSI_DESCRIPTION, _LAST_SEEN_DESCRIPTION, + _DEEP_SLEEP_TIME_DESCRIPTION, + _EXPECTED_WAKEUP_DESCRIPTION, ] if power_config.power_mode_enum in _BATTERY_POWER_MODES: @@ -134,18 +158,57 @@ def __init__( ) -> None: """Initialize the sensor entity.""" super().__init__(coordinator, description) - self._restored_data: SensorExtraStoredData | None = None + self._attr_native_value: float | int | str | datetime | None = None + + @property + def _restore_when_sleeping(self) -> bool: + """Return whether this sensor may use restored data while sleeping.""" + return self.coordinator.deep_sleep_time_seconds > 0 async def async_added_to_hass(self) -> None: """Restore the last native value for sleeping devices after restart.""" await super().async_added_to_hass() - self._restored_data = await self.async_get_last_sensor_data() + if not self._restore_when_sleeping: + return + if self._attr_native_value is not None: + return + last_sensor_data = await self.async_get_last_sensor_data() + if last_sensor_data is not None: + self._attr_native_value = last_sensor_data.native_value + _LOGGER.debug( + "%s: Restored sensor state " + "(sensor=%s, native_value=%s)", + self.coordinator.address, + self.entity_description.key, + last_sensor_data.native_value, + ) + if self.entity_description.key == "last_seen": + self.coordinator.async_restore_last_seen( + last_sensor_data.native_value + ) + else: + _LOGGER.debug( + "%s: No restored sensor state available (sensor=%s)", + self.coordinator.address, + self.entity_description.key, + ) @property def native_value(self) -> float | int | str | datetime | None: """Return the sensor value.""" + if self.entity_description.key == "deep_sleep_time": + self._attr_native_value = self.coordinator.deep_sleep_time_seconds + return self._attr_native_value + + if self.entity_description.key == "expected_wakeup": + if self.coordinator.expected_wakeup_timestamp is not None: + self._attr_native_value = self.coordinator.expected_wakeup_timestamp + return self._attr_native_value + if self.coordinator.data is not None: - return self.entity_description.value_fn(self.coordinator.data) - if self._restored_data is None: + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) + elif not self._restore_when_sleeping: return None - return self._restored_data.native_value + return self._attr_native_value diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index 8814800..3e7416a 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Awaitable, Callable import contextlib +from dataclasses import dataclass, field from datetime import datetime, timedelta from enum import IntEnum import io @@ -33,7 +34,10 @@ from PIL import Image as PILImage, ImageOps import voluptuous as vol -from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.components.bluetooth import ( + async_ble_device_from_address, + async_clear_advertisement_history, +) from homeassistant.components.http.auth import async_sign_path from homeassistant.components.media_source import async_resolve_media from homeassistant.config_entries import ConfigEntryState @@ -43,15 +47,32 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url from homeassistant.helpers.selector import MediaSelector, MediaSelectorConfig +from homeassistant.util import dt as dt_util if TYPE_CHECKING: from . import OpenDisplayConfigEntry -from .const import CONF_ENCRYPTION_KEY, DOMAIN, SIGNAL_IMAGE_UPDATED -from .deep_sleep import deep_sleep_enabled, deep_sleep_seconds, supports_deep_sleep +from .const import ( + CONF_ENCRYPTION_KEY, + DOMAIN, + SIGNAL_DEVICE_SEEN, + SIGNAL_IMAGE_UPDATED, + SIGNAL_PENDING_UPLOAD, +) +from .deep_sleep import ( + availability_window_seconds, + deep_sleep_enabled, + deep_sleep_seconds, + deep_sleep_timeout_margin_minutes, + supports_deep_sleep, +) ATTR_IMAGE = "image" ATTR_ROTATION = "rotation" @@ -59,6 +80,24 @@ ATTR_REFRESH_MODE = "refresh_mode" ATTR_FIT_MODE = "fit_mode" ATTR_TONE_COMPRESSION = "tone_compression" +_PENDING_UPLOAD_WAKE_SETTLE_DELAY_SECONDS = 3 +_UPLOAD_RETRY_DELAY_SECONDS = 5 +_UPLOAD_MAX_ATTEMPTS = 3 + + +@dataclass(slots=True) +class PendingDisplayUpload: + """Stored image payload waiting for the next wake-up window.""" + + image: PILImage.Image + dither_mode: DitherMode + refresh_mode: RefreshMode + fit: FitMode = FitMode.CONTAIN + tone: float | str = "auto" + rotate: Rotation = Rotation.ROTATE_0 + source: str = "unknown" + created_at: datetime = field(default_factory=dt_util.utcnow) + expires_at: datetime | None = None def _str_to_int_enum(enum_class: type[IntEnum]) -> Callable[[str], Any]: @@ -91,7 +130,9 @@ def validate(value: Any) -> Any: MediaSelectorConfig(accept=["image/*"]) ), vol.Optional(ATTR_ROTATION, default=Rotation.ROTATE_0): vol.All( - _coerce_none_to_default(Rotation.ROTATE_0), vol.Coerce(int), vol.Coerce(Rotation) + _coerce_none_to_default(Rotation.ROTATE_0), + vol.Coerce(int), + vol.Coerce(Rotation), ), vol.Optional(ATTR_DITHER_MODE, default="burkes"): _str_to_int_enum(DitherMode), vol.Optional(ATTR_REFRESH_MODE, default="full"): _str_to_int_enum(RefreshMode), @@ -125,7 +166,11 @@ def validate(value: Any) -> Any: def _rgb_to_led_color(value: list[int]) -> int: """Convert [R, G, B] (0-255 each) to packed 8-bit LED color byte (3R 3G 2B).""" r, g, b = value - return ((round(r * 7 / 255)) << 5) | ((round(g * 7 / 255)) << 2) | (round(b * 3 / 255)) + return ( + ((round(r * 7 / 255)) << 5) + | ((round(g * 7 / 255)) << 2) + | (round(b * 3 / 255)) + ) def _ms_to_loop_delay(value: int) -> int: @@ -138,24 +183,39 @@ def _ms_to_inter_delay(value: int) -> int: return max(0, min(255, round(value / 100))) -def _led_step_fields(n: int, *, color_default: list[int], flash_count_default: int) -> dict: +def _led_step_fields( + n: int, + *, + color_default: list[int], + flash_count_default: int, +) -> dict: """Return the voluptuous field definitions for one LED step.""" return { vol.Optional(f"color{n}", default=color_default): _rgb_to_led_color, vol.Optional(f"flash_count{n}", default=flash_count_default): vol.All( vol.Coerce(int), vol.Range(min=0, max=15) ), - vol.Optional(f"loop_delay{n}", default=0): vol.All(vol.Coerce(int), _ms_to_loop_delay), - vol.Optional(f"inter_delay{n}", default=0): vol.All(vol.Coerce(int), _ms_to_inter_delay), + vol.Optional(f"loop_delay{n}", default=0): vol.All( + vol.Coerce(int), _ms_to_loop_delay + ), + vol.Optional(f"inter_delay{n}", default=0): vol.All( + vol.Coerce(int), _ms_to_inter_delay + ), } SCHEMA_ACTIVATE_LED = vol.Schema( { vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Optional("instance", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), - vol.Optional("brightness", default=8): vol.All(vol.Coerce(int), vol.Range(min=1, max=16)), - vol.Optional("repeats", default=1): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), + vol.Optional("instance", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional("brightness", default=8): vol.All( + vol.Coerce(int), vol.Range(min=1, max=16) + ), + vol.Optional("repeats", default=1): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), **_led_step_fields(1, color_default=[255, 0, 0], flash_count_default=1), **_led_step_fields(2, color_default=[0, 255, 0], flash_count_default=0), **_led_step_fields(3, color_default=[0, 0, 255], flash_count_default=0), @@ -165,10 +225,18 @@ def _led_step_fields(n: int, *, color_default: list[int], flash_count_default: i SCHEMA_ACTIVATE_BUZZER = vol.Schema( { vol.Required(ATTR_DEVICE_ID): cv.string, - vol.Optional("instance", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=3)), - vol.Optional("frequency_hz", default=1000): vol.All(vol.Coerce(int), vol.Range(min=0, max=12000)), - vol.Optional("duration_ms", default=100): vol.All(vol.Coerce(int), vol.Range(min=5, max=1275)), - vol.Optional("repeats", default=1): vol.All(vol.Coerce(int), vol.Range(min=1, max=255)), + vol.Optional("instance", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=3) + ), + vol.Optional("frequency_hz", default=1000): vol.All( + vol.Coerce(int), vol.Range(min=0, max=12000) + ), + vol.Optional("duration_ms", default=100): vol.All( + vol.Coerce(int), vol.Range(min=5, max=1275) + ), + vol.Optional("repeats", default=1): vol.All( + vol.Coerce(int), vol.Range(min=1, max=255) + ), } ) @@ -216,6 +284,186 @@ def _pil_to_jpeg(img: PILImage.Image) -> bytes: return buf.getvalue() +def _pending_age_seconds(pending: PendingDisplayUpload) -> int: + """Return pending upload age in seconds.""" + return max(0, int((dt_util.utcnow() - pending.created_at).total_seconds())) + + +def _pending_upload_timeout_seconds(entry: "OpenDisplayConfigEntry") -> int: + """Return how long a pending upload may wait for a sleeping device.""" + coordinator = entry.runtime_data.coordinator + availability_window = int( + getattr(coordinator, "deep_sleep_availability_window_seconds", 0) or 0 + ) + if availability_window > 0: + return availability_window + + sleep_seconds = deep_sleep_seconds(entry.runtime_data.device_config) + return availability_window_seconds( + sleep_seconds, + deep_sleep_timeout_margin_minutes(entry.options), + ) + + +def _cancel_pending_upload_expiry(entry: "OpenDisplayConfigEntry") -> None: + """Cancel a pending upload expiry timer if one exists.""" + if ( + unsub := getattr(entry.runtime_data, "pending_upload_expiry_unsub", None) + ) is None: + return + unsub() + entry.runtime_data.pending_upload_expiry_unsub = None + + +def _cancel_pending_upload_task(entry: "OpenDisplayConfigEntry") -> None: + """Cancel an in-flight pending upload task if one exists.""" + task = getattr(entry.runtime_data, "pending_upload_task", None) + if task is None or task.done(): + return + task.cancel() + entry.runtime_data.pending_upload_task = None + + +def _clear_pending_upload( + hass: HomeAssistant, + entry: "OpenDisplayConfigEntry", + *, + reason: str, + cancel_task: bool = False, +) -> bool: + """Clear the current queued image upload.""" + address = entry.unique_id + assert address is not None + pending = entry.runtime_data.pending_upload + if pending is None: + _cancel_pending_upload_expiry(entry) + if cancel_task: + _cancel_pending_upload_task(entry) + return False + if cancel_task: + _cancel_pending_upload_task(entry) + _cancel_pending_upload_expiry(entry) + entry.runtime_data.pending_upload = None + entry.runtime_data.coordinator.async_set_pending_upload(False) + async_dispatcher_send(hass, f"{SIGNAL_PENDING_UPLOAD}_{address}") + _LOGGER.info( + "%s: Pending upload cleared " + "(reason=%s, source=%s, age=%ss, expires_at=%s)", + address, + reason, + pending.source, + _pending_age_seconds(pending), + pending.expires_at.isoformat() if pending.expires_at else None, + ) + return True + + +def _replace_pending_upload( + hass: HomeAssistant, + entry: "OpenDisplayConfigEntry", + pending: PendingDisplayUpload, +) -> int: + """Store one pending image upload for this device, replacing any older one.""" + address = entry.unique_id + assert address is not None + previous = entry.runtime_data.pending_upload + if previous is not None: + _LOGGER.info( + "%s: Replacing pending upload " + "(old_source=%s, old_age=%ss, old_expires_at=%s, " + "new_source=%s)", + address, + previous.source, + _pending_age_seconds(previous), + previous.expires_at.isoformat() if previous.expires_at else None, + pending.source, + ) + _cancel_pending_upload_task(entry) + entry.runtime_data.pending_upload = pending + timeout_seconds = _schedule_pending_upload_expiry(hass, entry, pending) + entry.runtime_data.coordinator.async_set_pending_upload(True) + async_dispatcher_send(hass, f"{SIGNAL_PENDING_UPLOAD}_{address}") + return timeout_seconds + + +def _drop_pending_upload( + hass: HomeAssistant, + entry: "OpenDisplayConfigEntry", + pending: PendingDisplayUpload, + *, + reason: str, +) -> None: + """Drop a queued image and update diagnostics.""" + address = entry.unique_id + assert address is not None + if entry.runtime_data.pending_upload is not pending: + return + _cancel_pending_upload_expiry(entry) + entry.runtime_data.pending_upload = None + entry.runtime_data.coordinator.async_set_pending_upload(False) + async_dispatcher_send(hass, f"{SIGNAL_PENDING_UPLOAD}_{address}") + coordinator = entry.runtime_data.coordinator + expected_wakeup = getattr(coordinator, "expected_wakeup_timestamp", None) + availability_deadline = getattr( + coordinator, + "deep_sleep_availability_deadline_timestamp", + None, + ) + _LOGGER.error( + "%s: Pending upload dropped " + "(reason=%s, source=%s, age=%ss, created_at=%s, expires_at=%s, " + "deep_sleep=%ss, coordinator_available=%s, expected_wakeup=%s, " + "availability_deadline=%s)", + address, + reason, + pending.source, + _pending_age_seconds(pending), + pending.created_at.isoformat(), + pending.expires_at.isoformat() if pending.expires_at else None, + deep_sleep_seconds(entry.runtime_data.device_config), + coordinator.available, + expected_wakeup.isoformat() if expected_wakeup else None, + availability_deadline.isoformat() if availability_deadline else None, + ) + + +def _schedule_pending_upload_expiry( + hass: HomeAssistant, + entry: "OpenDisplayConfigEntry", + pending: PendingDisplayUpload, +) -> int: + """Schedule expiration for a queued image upload.""" + address = entry.unique_id + assert address is not None + _cancel_pending_upload_expiry(entry) + timeout_seconds = _pending_upload_timeout_seconds(entry) + pending.expires_at = dt_util.utcnow() + timedelta(seconds=timeout_seconds) + + @callback + def _expire_pending_upload(_now: datetime) -> None: + _drop_pending_upload( + hass, + entry, + pending, + reason="device did not return before pending upload timeout", + ) + + entry.runtime_data.pending_upload_expiry_unsub = async_call_later( + hass, + timeout_seconds, + _expire_pending_upload, + ) + _LOGGER.debug( + "%s: Pending upload expiry scheduled " + "(timeout=%ss, expires_at=%s, source=%s)", + address, + timeout_seconds, + pending.expires_at.isoformat(), + pending.source, + ) + return timeout_seconds + + def _load_image(path: str) -> PILImage.Image: """Load an image from disk and apply EXIF orientation.""" image = PILImage.open(path) @@ -254,7 +502,7 @@ async def _async_download_image(hass: HomeAssistant, url: str) -> PILImage.Image async def _async_connect_and_run( hass: HomeAssistant, entry: "OpenDisplayConfigEntry", - action: Callable[[OpenDisplayDevice], Awaitable[None]], + action: Callable[[Any], Awaitable[None]], *, wrap_connection_errors: bool = True, ) -> None: @@ -263,6 +511,12 @@ async def _async_connect_and_run( assert address is not None ble_device = async_ble_device_from_address(hass, address, connectable=True) if ble_device is None: + _LOGGER.debug( + "%s: BLE device not connectable for OpenDisplay action " + "(wrap_connection_errors=%s)", + address, + wrap_connection_errors, + ) if not wrap_connection_errors: # Treat a missing BLE cache entry as a retryable connection failure so # callers like the deep-sleep flush keep the queued upload instead of @@ -292,6 +546,14 @@ async def _async_connect_and_run( ) from err try: + _LOGGER.debug( + "%s: Opening OpenDisplay BLE connection " + "(wrap_connection_errors=%s, cached_config=%s, encrypted=%s)", + address, + wrap_connection_errors, + entry.runtime_data.device_config is not None, + encryption_key is not None, + ) async with OpenDisplayDevice( mac_address=address, ble_device=ble_device, @@ -299,6 +561,7 @@ async def _async_connect_and_run( encryption_key=encryption_key, ) as device: await action(device) + _LOGGER.debug("%s: OpenDisplay BLE action finished", address) except (AuthenticationFailedError, AuthenticationRequiredError) as err: entry.async_start_reauth(hass) raise HomeAssistantError( @@ -318,140 +581,485 @@ async def _async_connect_and_run( translation_key="upload_error", translation_placeholders={"error": str(err)}, ) from err + finally: + async_clear_advertisement_history(hass, address) -async def _async_send_image( +async def _async_send_image_now( hass: HomeAssistant, entry: "OpenDisplayConfigEntry", - img: PILImage.Image, - *, - dither_mode: DitherMode, - refresh_mode: RefreshMode, - fit: FitMode = FitMode.CONTAIN, - tone: float | str = "auto", - rotate: Rotation = Rotation.ROTATE_0, + pending: PendingDisplayUpload, ) -> None: - """Upload a PIL image to the device, queuing if the device is sleeping.""" + """Upload image immediately and update image entity cache.""" address = entry.unique_id assert address is not None - - device_config = entry.runtime_data.device_config - deep_sleep_supported = supports_deep_sleep(device_config) - sleep_seconds = deep_sleep_seconds(device_config) - deep_sleep_active = deep_sleep_supported and deep_sleep_enabled(device_config) - is_connectable_now = ( - async_ble_device_from_address(hass, address, connectable=True) is not None + _LOGGER.debug( + "%s: Sending image now " + "(source=%s, image_size=%s, refresh_mode=%s, dither_mode=%s, " + "fit=%s, rotate=%s, tone=%s, created_at=%s)", + address, + pending.source, + getattr(pending.image, "size", None), + pending.refresh_mode, + pending.dither_mode, + pending.fit, + pending.rotate, + pending.tone, + pending.created_at, ) async def _upload(device: OpenDisplayDevice) -> None: await device.upload_image( - img, - refresh_mode=refresh_mode, - dither_mode=dither_mode, - tone=tone, - fit=fit, - rotate=rotate, - ) - - def _queue_for_deep_sleep( - *, - reason: str, - jpeg_bytes: bytes, - error: Exception | None = None, - ) -> None: - """Queue upload until the sleeping device becomes connectable again.""" - expiry_seconds = int(sleep_seconds * 1.1) - now = datetime.now() - previous_upload = entry.runtime_data.deep_sleep_upload - 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=jpeg_bytes, - queued_at=now, - expiry=timedelta(seconds=expiry_seconds), - ) - entry.runtime_data.deep_sleep_upload = queued_upload - if previous_upload is not None: - age = now - previous_upload.queued_at - ttl_left = max(0, int((previous_upload.expiry - age).total_seconds())) + pending.image, + refresh_mode=pending.refresh_mode, + dither_mode=pending.dither_mode, + tone=pending.tone, + fit=pending.fit, + rotate=pending.rotate, + ) + + await _async_connect_and_run(hass, entry, _upload) + + jpeg = await hass.async_add_executor_job(_pil_to_jpeg, pending.image) + async_dispatcher_send(hass, f"{SIGNAL_IMAGE_UPDATED}_{entry.unique_id}", jpeg) + _LOGGER.info( + "%s: Image uploaded successfully (source=%s, image_size=%s)", + address, + pending.source, + getattr(pending.image, "size", None), + ) + + +async def _async_send_image_with_retries( + hass: HomeAssistant, + entry: "OpenDisplayConfigEntry", + pending: PendingDisplayUpload, + *, + context: str, + settle_delay_seconds: int = 0, + should_continue: Callable[[], bool] | None = None, +) -> bool: + """Upload an image with consistent retry behavior.""" + address = entry.unique_id + assert address is not None + if settle_delay_seconds > 0: + _LOGGER.info( + "%s: Waiting before upload attempts " + "(context=%s, source=%s, settle_delay=%ss)", + address, + context, + pending.source, + settle_delay_seconds, + ) + await asyncio.sleep(settle_delay_seconds) + + for attempt in range(1, _UPLOAD_MAX_ATTEMPTS + 1): + if should_continue is not None and not should_continue(): _LOGGER.info( - "Replacing queued image upload for %s; previous ttl_left=%ss, reset ttl=%ss", + "%s: Upload attempts stopped before attempt %s/%s " + "(context=%s, source=%s)", address, - ttl_left, - expiry_seconds, + attempt, + _UPLOAD_MAX_ATTEMPTS, + context, + pending.source, ) + return False - 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( + "%s: Upload attempt %s/%s " + "(context=%s, source=%s, age=%ss, expires_at=%s)", + address, + attempt, + _UPLOAD_MAX_ATTEMPTS, + context, + pending.source, + _pending_age_seconds(pending), + pending.expires_at.isoformat() if pending.expires_at else None, + ) + try: + await _async_send_image_now(hass, entry, pending) + except HomeAssistantError as err: + if attempt >= _UPLOAD_MAX_ATTEMPTS: _LOGGER.info( - "Dropped queued image upload for %s (sleep=%ss, ttl=%ss)", + "%s: Upload failed after %s attempts " + "(context=%s, source=%s, age=%ss, expires_at=%s, error=%s)", address, - sleep_seconds, - expiry_seconds, + attempt, + context, + pending.source, + _pending_age_seconds(pending), + pending.expires_at.isoformat() if pending.expires_at else None, + err, ) - entry.runtime_data.deep_sleep_expiry_handle = None - - entry.runtime_data.deep_sleep_expiry_handle = hass.loop.call_later( - expiry_seconds, _purge_if_expired - ) + raise + _LOGGER.info( + "%s: Upload attempt %s/%s failed; retrying in %ss " + "(context=%s, source=%s, error=%s)", + address, + attempt, + _UPLOAD_MAX_ATTEMPTS, + _UPLOAD_RETRY_DELAY_SECONDS, + context, + pending.source, + err, + ) + await asyncio.sleep(_UPLOAD_RETRY_DELAY_SECONDS) + continue _LOGGER.info( - "Queued image upload for %s (%s, sleep=%ss, ttl=%ss)", + "%s: Upload attempts succeeded " + "(context=%s, source=%s, attempt=%s/%s)", address, - reason, - sleep_seconds, - expiry_seconds, + context, + pending.source, + attempt, + _UPLOAD_MAX_ATTEMPTS, ) - if error is not None: - _LOGGER.debug("Queue trigger details for %s: %s", address, error) + return True - if not is_connectable_now and deep_sleep_active: - # Device is sleeping right now – queue the upload for when it wakes. - jpeg = await hass.async_add_executor_job(_pil_to_jpeg, img) - _queue_for_deep_sleep(reason="device not connectable", jpeg_bytes=jpeg) + return False + + +async def _async_queue_or_send_image( + hass: HomeAssistant, + entry: "OpenDisplayConfigEntry", + pending: PendingDisplayUpload, +) -> None: + """Send immediately when awake, otherwise keep as pending upload.""" + address = entry.unique_id + assert address is not None + + device_config = entry.runtime_data.device_config + deep_sleep_supported = supports_deep_sleep(device_config) + deep_sleep_active = deep_sleep_supported and deep_sleep_enabled(device_config) + coordinator = entry.runtime_data.coordinator + coordinator_available = getattr(coordinator, "available", True) + expected_wakeup = getattr(coordinator, "expected_wakeup_timestamp", None) + availability_deadline = getattr( + coordinator, + "deep_sleep_availability_deadline_timestamp", + None, + ) + timeout_margin = getattr( + coordinator, + "deep_sleep_timeout_margin_minutes", + None, + ) + availability_window = getattr( + coordinator, + "deep_sleep_availability_window_seconds", + None, + ) + ble_device = async_ble_device_from_address(hass, address, connectable=True) + + _LOGGER.info( + "%s: Upload decision " + "(source=%s, image_size=%s, deep_sleep_supported=%s, " + "deep_sleep_enabled=%s, deep_sleep=%ss, timeout_margin=%smin, " + "availability_window=%ss, connectable=%s, coordinator_available=%s, " + "expected_wakeup=%s, availability_deadline=%s, pending_already_queued=%s)", + address, + pending.source, + getattr(pending.image, "size", None), + deep_sleep_supported, + deep_sleep_active, + deep_sleep_seconds(device_config), + timeout_margin, + availability_window, + ble_device is not None, + coordinator_available, + expected_wakeup.isoformat() if expected_wakeup else None, + availability_deadline.isoformat() if availability_deadline else None, + entry.runtime_data.pending_upload is not None, + ) + + if not deep_sleep_active: + _clear_pending_upload( + hass, + entry, + reason="new upload request for non-deep-sleep device", + cancel_task=True, + ) + try: + await _async_send_image_with_retries( + hass, + entry, + pending, + context="initial non-deep-sleep upload", + ) + except HomeAssistantError as err: + _LOGGER.warning( + "%s: Upload failed after retry attempts for non-deep-sleep " + "device; not queueing (error=%s)", + address, + err, + ) + raise return - if deep_sleep_active and is_connectable_now: + + if ble_device is not None and coordinator_available: _LOGGER.info( - "Uploading image to %s immediately (device connectable, deep sleep=%ss)", + "%s: Deep-sleep device appears awake; attempting upload before " + "queueing (source=%s)", address, - sleep_seconds, + pending.source, + ) + _clear_pending_upload( + hass, + entry, + reason="new upload request for awake deep-sleep device", + cancel_task=True, ) - elif not deep_sleep_active: + try: + await _async_send_image_with_retries( + hass, + entry, + pending, + context="initial awake deep-sleep upload", + ) + return + except HomeAssistantError as err: + _LOGGER.info( + "%s: Upload failed after retry attempts for awake deep-sleep " + "device; queueing for next wake-up (error=%s)", + address, + err, + exc_info=True, + ) + else: _LOGGER.info( - "Uploading image to %s immediately (deep sleep unsupported/disabled, connectable=%s)", + "%s: Deep-sleep device appears asleep; queueing without immediate " + "upload attempts (source=%s, connectable=%s, coordinator_available=%s)", address, - is_connectable_now, + pending.source, + ble_device is not None, + coordinator_available, ) - try: - await _async_connect_and_run( - hass, entry, _upload, wrap_connection_errors=False + _LOGGER.info( + "%s: Deep-sleep upload queued " + "(source=%s, connectable=%s, coordinator_available=%s, " + "expected_wakeup=%s, availability_deadline=%s)", + address, + pending.source, + ble_device is not None, + coordinator_available, + expected_wakeup.isoformat() if expected_wakeup else None, + availability_deadline.isoformat() if availability_deadline else None, + ) + timeout_seconds = _replace_pending_upload(hass, entry, pending) + _LOGGER.info( + "%s: Queued image upload " + "(source=%s, deep_sleep=%ss, timeout=%ss, expires_at=%s)", + address, + pending.source, + deep_sleep_seconds(device_config), + timeout_seconds, + pending.expires_at.isoformat() if pending.expires_at else None, + ) + + +async def _async_try_pending_upload( + hass: HomeAssistant, + entry: "OpenDisplayConfigEntry", +) -> None: + """Attempt to flush queued image upload on advertisement/wake-up.""" + address = entry.unique_id + assert address is not None + + pending = entry.runtime_data.pending_upload + if pending is None: + _LOGGER.debug("%s: No pending upload to flush", address) + _cancel_pending_upload_expiry(entry) + entry.runtime_data.coordinator.async_set_pending_upload(False) + return + + if pending.expires_at is not None and dt_util.utcnow() >= pending.expires_at: + _drop_pending_upload( + hass, + entry, + pending, + reason="pending upload expired before flush attempt", ) - except (BLEConnectionError, BLETimeoutError) as err: - if deep_sleep_active: - jpeg = await hass.async_add_executor_job(_pil_to_jpeg, img) - _queue_for_deep_sleep( - reason="connection failed", - jpeg_bytes=jpeg, - error=err, + return + + ble_device = async_ble_device_from_address(hass, address, connectable=True) + coordinator = entry.runtime_data.coordinator + expected_wakeup = getattr(coordinator, "expected_wakeup_timestamp", None) + availability_deadline = getattr( + coordinator, + "deep_sleep_availability_deadline_timestamp", + None, + ) + _LOGGER.info( + "%s: Pending upload flush check " + "(source=%s, age=%ss, expires_at=%s, connectable=%s, " + "coordinator_available=%s, expected_wakeup=%s, availability_deadline=%s)", + address, + pending.source, + _pending_age_seconds(pending), + pending.expires_at.isoformat() if pending.expires_at else None, + ble_device is not None, + coordinator.available, + expected_wakeup.isoformat() if expected_wakeup else None, + availability_deadline.isoformat() if availability_deadline else None, + ) + if ble_device is None: + _LOGGER.debug( + "%s: Pending upload deferred; device not connectable yet " + "(source=%s, expires_at=%s)", + address, + pending.source, + pending.expires_at.isoformat() if pending.expires_at else None, + ) + return + + task = entry.runtime_data.pending_upload_task + if task is not None and not task.done(): + _LOGGER.debug( + "%s: Pending upload task already running, skipping new attempt " + "(source=%s)", + address, + pending.source, + ) + return + + async def _runner() -> None: + current_task = asyncio.current_task() + try: + pending_now = entry.runtime_data.pending_upload + if pending_now is None: + _LOGGER.debug( + "%s: Pending upload vanished before runner start", + entry.unique_id, + ) + _cancel_pending_upload_expiry(entry) + entry.runtime_data.coordinator.async_set_pending_upload(False) + return + _LOGGER.info( + "%s: Pending upload flush started " + "(source=%s, age=%ss, expires_at=%s, settle_delay=%ss, " + "attempts=%s, retry_delay=%ss)", + address, + pending_now.source, + _pending_age_seconds(pending_now), + pending_now.expires_at.isoformat() if pending_now.expires_at else None, + _PENDING_UPLOAD_WAKE_SETTLE_DELAY_SECONDS, + _UPLOAD_MAX_ATTEMPTS, + _UPLOAD_RETRY_DELAY_SECONDS, ) + + def _pending_upload_still_current() -> bool: + if entry.runtime_data.pending_upload is not pending_now: + _LOGGER.info( + "%s: Pending upload flush stopped because a newer upload " + "replaced it (source=%s)", + address, + pending_now.source, + ) + return False + if ( + pending_now.expires_at is not None + and dt_util.utcnow() >= pending_now.expires_at + ): + _drop_pending_upload( + hass, + entry, + pending_now, + reason="pending upload expired during retry attempts", + ) + return False + return True + + try: + delivered = await _async_send_image_with_retries( + hass, + entry, + pending_now, + context="pending upload flush", + settle_delay_seconds=_PENDING_UPLOAD_WAKE_SETTLE_DELAY_SECONDS, + should_continue=_pending_upload_still_current, + ) + except HomeAssistantError: + _drop_pending_upload( + hass, + entry, + pending_now, + reason="pending upload failed after retry attempts", + ) + return + + if not delivered: + return + + if entry.runtime_data.pending_upload is pending_now: + _cancel_pending_upload_expiry(entry) + entry.runtime_data.pending_upload = None + entry.runtime_data.coordinator.async_set_pending_upload(False) + async_dispatcher_send(hass, f"{SIGNAL_PENDING_UPLOAD}_{address}") + _LOGGER.info( + "%s: Pending upload delivered successfully " + "(source=%s)", + address, + pending_now.source, + ) + except asyncio.CancelledError: + _LOGGER.info( + "%s: Pending upload task cancelled because a newer upload " + "superseded it", + address, + ) + raise + finally: + if entry.runtime_data.pending_upload_task is current_task: + entry.runtime_data.pending_upload_task = None + + entry.runtime_data.pending_upload_task = hass.async_create_task( + _runner(), + name=f"opendisplay_pending_upload_{address}", + ) + + +def async_register_pending_upload_listener( + hass: HomeAssistant, + entry: "OpenDisplayConfigEntry", +) -> Callable[[], None]: + """Listen for BLE advertisements and attempt pending upload flushes.""" + address = entry.unique_id + assert address is not None + + @callback + def _schedule_try_pending_upload() -> None: + pending = entry.runtime_data.pending_upload + if pending is None: return - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="upload_error", - translation_placeholders={"error": str(err)}, - ) from err + task = entry.runtime_data.pending_upload_task + if task is not None and not task.done(): + _LOGGER.debug( + "%s: Device-seen signal received but pending upload task is " + "already running (source=%s)", + address, + pending.source, + ) + return + _LOGGER.info( + "%s: Device-seen signal received; scheduling pending upload attempt " + "(source=%s, age=%ss, expires_at=%s)", + address, + pending.source, + _pending_age_seconds(pending), + pending.expires_at.isoformat() if pending.expires_at else None, + ) + hass.async_create_task( + _async_try_pending_upload(hass, entry), + name=f"opendisplay_try_pending_{address}", + ) - jpeg = await hass.async_add_executor_job(_pil_to_jpeg, img) - async_dispatcher_send(hass, f"{SIGNAL_IMAGE_UPDATED}_{entry.unique_id}", jpeg) - _LOGGER.info("%s: Upload completed and image cache updated", address) + return async_dispatcher_connect( + hass, + f"{SIGNAL_DEVICE_SEEN}_{address}", + _schedule_try_pending_upload, + ) async def _async_upload_image(call: ServiceCall) -> None: @@ -488,16 +1096,28 @@ async def _async_upload_image(call: ServiceCall) -> None: else: pil_image = await _async_download_image(call.hass, media.url) - await _async_send_image( - call.hass, - entry, - pil_image, + pending = PendingDisplayUpload( + image=pil_image, dither_mode=dither_mode, refresh_mode=refresh_mode, fit=fit_mode, tone=tone_compression, rotate=rotation, + source="upload_image", ) + _LOGGER.info( + "%s: upload_image service resolved media " + "(image_size=%s, refresh_mode=%s, dither_mode=%s, fit=%s, " + "rotate=%s, tone=%s)", + entry.unique_id, + getattr(pil_image, "size", None), + refresh_mode, + dither_mode, + fit_mode, + rotation, + tone_compression, + ) + await _async_queue_or_send_image(call.hass, entry, pending) except asyncio.CancelledError: return finally: @@ -673,15 +1293,27 @@ async def _drawcustom_for_device( return dither_mode: DitherMode = call.data["dither"] - refresh_mode = RefreshMode.FAST if call.data["refresh_type"] == 1 else RefreshMode.FULL + refresh_mode = ( + RefreshMode.FAST + if call.data["refresh_type"] == 1 + else RefreshMode.FULL + ) - await _async_send_image( - hass, - entry, - img, + pending = PendingDisplayUpload( + image=img, dither_mode=dither_mode, refresh_mode=refresh_mode, + source="drawcustom", ) + _LOGGER.info( + "%s: drawcustom generated image " + "(image_size=%s, refresh_mode=%s, dither_mode=%s)", + entry.unique_id, + getattr(img, "size", None), + refresh_mode, + dither_mode, + ) + await _async_queue_or_send_image(hass, entry, pending) async def _async_activate_led(call: ServiceCall) -> None: @@ -756,6 +1388,21 @@ def async_setup_services(hass: HomeAssistant) -> None: _async_upload_image, schema=SCHEMA_UPLOAD_IMAGE, ) - hass.services.async_register(DOMAIN, "drawcustom", _async_drawcustom, schema=SCHEMA_DRAWCUSTOM) - hass.services.async_register(DOMAIN, "activate_led", _async_activate_led, schema=SCHEMA_ACTIVATE_LED) - hass.services.async_register(DOMAIN, "activate_buzzer", _async_activate_buzzer, schema=SCHEMA_ACTIVATE_BUZZER) \ No newline at end of file + hass.services.async_register( + DOMAIN, + "drawcustom", + _async_drawcustom, + schema=SCHEMA_DRAWCUSTOM, + ) + hass.services.async_register( + DOMAIN, + "activate_led", + _async_activate_led, + schema=SCHEMA_ACTIVATE_LED, + ) + hass.services.async_register( + DOMAIN, + "activate_buzzer", + _async_activate_buzzer, + schema=SCHEMA_ACTIVATE_BUZZER, + ) diff --git a/custom_components/opendisplay/strings.json b/custom_components/opendisplay/strings.json index 0fc5e1e..f892297 100644 --- a/custom_components/opendisplay/strings.json +++ b/custom_components/opendisplay/strings.json @@ -50,7 +50,28 @@ } } }, + "options": { + "error": { + "invalid_timeout_margin": "Enter a value between 0 and 1440 minutes." + }, + "step": { + "init": { + "data": { + "deep_sleep_timeout_margin_minutes": "Deep sleep timeout margin" + }, + "data_description": { + "deep_sleep_timeout_margin_minutes": "Additional time, in minutes, to keep a deep-sleep device available after its expected wake-up time." + }, + "title": "OpenDisplay options" + } + } + }, "entity": { + "binary_sensor": { + "pending_upload": { + "name": "Pending upload" + } + }, "image": { "content": { "name": "Display content" @@ -90,6 +111,12 @@ }, "last_seen": { "name": "Last seen" + }, + "deep_sleep_time": { + "name": "Deep sleep time" + }, + "expected_wakeup": { + "name": "Expected wake-up" } }, "update": { diff --git a/custom_components/opendisplay/translations/en.json b/custom_components/opendisplay/translations/en.json index 9176504..08b08e8 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -50,7 +50,28 @@ } } }, + "options": { + "error": { + "invalid_timeout_margin": "Enter a value between 0 and 1440 minutes." + }, + "step": { + "init": { + "data": { + "deep_sleep_timeout_margin_minutes": "Deep sleep timeout margin" + }, + "data_description": { + "deep_sleep_timeout_margin_minutes": "Additional time, in minutes, to keep a deep-sleep device available after its expected wake-up time." + }, + "title": "OpenDisplay options" + } + } + }, "entity": { + "binary_sensor": { + "pending_upload": { + "name": "Pending upload" + } + }, "image": { "content": { "name": "Display content" @@ -90,6 +111,12 @@ }, "last_seen": { "name": "Last seen" + }, + "deep_sleep_time": { + "name": "Deep sleep time" + }, + "expected_wakeup": { + "name": "Expected wake-up" } }, "update": { diff --git a/custom_components/opendisplay/update.py b/custom_components/opendisplay/update.py index bab0568..3636bb2 100644 --- a/custom_components/opendisplay/update.py +++ b/custom_components/opendisplay/update.py @@ -7,7 +7,11 @@ import aiohttp from opendisplay.models.firmware import firmware_release_repo -from homeassistant.components.update import UpdateDeviceClass, UpdateEntity, UpdateEntityDescription +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -39,11 +43,14 @@ async def async_setup_entry( ) -class OpenDisplayFirmwareUpdateEntity(OpenDisplayEntity[UpdateEntityDescription], UpdateEntity): +class OpenDisplayFirmwareUpdateEntity( + OpenDisplayEntity[UpdateEntityDescription], + UpdateEntity, +): """Firmware update entity for an OpenDisplay device.""" _attr_latest_version: str | None = None - should_poll = True # override coordinator's should_poll=False; GitHub needs regular polling + should_poll = True def __init__(self, coordinator, entry: OpenDisplayConfigEntry) -> None: """Initialize the entity.""" @@ -68,7 +75,10 @@ def latest_version(self) -> str | None: def release_url(self) -> str | None: """Return URL to the GitHub release page.""" if self._firmware_repo and self._attr_latest_version: - return f"https://github.com/{self._firmware_repo}/releases/tag/{self._attr_latest_version}" + return ( + f"https://github.com/{self._firmware_repo}/releases/tag/" + f"{self._attr_latest_version}" + ) return None async def async_added_to_hass(self) -> None: diff --git a/hacs.json b/hacs.json index 73fde3d..3ae7662 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "OpenDisplay", "render_readme": true, - "homeassistant": "2026.4.0" + "homeassistant": "2026.6.0" } diff --git a/tests/conftest.py b/tests/conftest.py index bc8133d..e87a9e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,69 @@ from unittest.mock import MagicMock +def _stub_aiousbwatcher() -> None: + """Stub optional Home Assistant USB watcher dependency for unit tests.""" + if "aiousbwatcher" in sys.modules: + return + + usb_mod = types.ModuleType("aiousbwatcher") + + class AIOUSBWatcher: + pass + + class InotifyNotAvailableError(Exception): + pass + + usb_mod.AIOUSBWatcher = AIOUSBWatcher + usb_mod.InotifyNotAvailableError = InotifyNotAvailableError + sys.modules["aiousbwatcher"] = usb_mod + + +_stub_aiousbwatcher() + + +def _stub_serialx() -> None: + """Stub optional serialx dependency imported by Home Assistant USB.""" + if "serialx" in sys.modules: + return + + serialx_mod = types.ModuleType("serialx") + serialx_mod.__path__ = [] + serialx_mod.register_uri_handler = MagicMock(return_value=lambda: None) + + class SerialPortInfo: + def __init__(self, **kwargs): + self.device = kwargs.get("device", "") + self.vid = kwargs.get("vid") + self.pid = kwargs.get("pid") + self.serial_number = kwargs.get("serial_number") + self.manufacturer = kwargs.get("manufacturer") + self.description = kwargs.get("description") + self.bcd_device = kwargs.get("bcd_device") + self.interface_description = kwargs.get("interface_description") + self.interface_num = kwargs.get("interface_num") + + serialx_mod.SerialPortInfo = SerialPortInfo + serialx_mod.list_serial_ports = MagicMock(return_value=[]) + platforms_mod = types.ModuleType("serialx.platforms") + serial_esphome_mod = types.ModuleType("serialx.platforms.serial_esphome") + + class ESPHomeSerial: + pass + + class ESPHomeSerialTransport: + pass + + serial_esphome_mod.ESPHomeSerial = ESPHomeSerial + serial_esphome_mod.ESPHomeSerialTransport = ESPHomeSerialTransport + sys.modules["serialx"] = serialx_mod + sys.modules["serialx.platforms"] = platforms_mod + sys.modules["serialx.platforms.serial_esphome"] = serial_esphome_mod + + +_stub_serialx() + + def _stub_opendisplay() -> None: """Install a minimal opendisplay stub so unit tests run without the real library.""" if "opendisplay" in sys.modules: @@ -43,12 +106,14 @@ class DitherMode(IntEnum): class RefreshMode(IntEnum): FULL = 0 - PARTIAL = 1 + FAST = 1 + PARTIAL = 2 class FitMode(IntEnum): - CONTAIN = 0 - COVER = 1 - FILL = 2 + STRETCH = 0 + CONTAIN = 1 + COVER = 2 + CROP = 3 class Rotation(IntEnum): ROTATE_0 = 0 @@ -108,7 +173,7 @@ def update(self, *args, **kwargs): def parse_advertisement(data): return AdvertisementData() - def voltage_to_percent(v): + def voltage_to_percent(v, capacity_estimator=None): return 50 # Build the opendisplay package and sub-modules in sys.modules @@ -148,11 +213,15 @@ def voltage_to_percent(v): enums_mod = types.ModuleType("opendisplay.models.enums") class CapacityEstimator(IntEnum): - UNKNOWN = 0 + LI_ION = 1 + LIFEPO4 = 2 + SUPERCAP = 3 + LITHIUM_PRIMARY = 4 class PowerMode(IntEnum): - NORMAL = 0 - DEEP_SLEEP = 1 + BATTERY = 1 + USB = 2 + SOLAR = 3 enums_mod.CapacityEstimator = CapacityEstimator enums_mod.PowerMode = PowerMode diff --git a/tests/test_binary_sensor_pending_upload.py b/tests/test_binary_sensor_pending_upload.py new file mode 100644 index 0000000..287084a --- /dev/null +++ b/tests/test_binary_sensor_pending_upload.py @@ -0,0 +1,39 @@ +"""Tests for pending upload diagnostic binary sensor.""" + +from types import SimpleNamespace +from unittest.mock import patch + +from custom_components.opendisplay.binary_sensor import ( + OpenDisplayPendingUploadBinarySensorEntity, +) + + +def _make_coordinator(pending_upload: bool): + runtime_data = SimpleNamespace( + device_config=SimpleNamespace( + power=SimpleNamespace(deep_sleep_time_seconds=300) + ) + ) + config_entry = SimpleNamespace(runtime_data=runtime_data) + return SimpleNamespace( + available=True, + pending_upload=pending_upload, + address="AA:BB:CC:DD:EE:FF", + config_entry=config_entry, + ) + + +def test_pending_upload_binary_sensor_reflects_coordinator_state() -> None: + coordinator = _make_coordinator(pending_upload=True) + description = SimpleNamespace(key="pending_upload") + + with patch( + "custom_components.opendisplay.entity.PassiveBluetoothCoordinatorEntity.__init__", + lambda self, coordinator: setattr(self, "coordinator", coordinator), + ): + entity = OpenDisplayPendingUploadBinarySensorEntity(coordinator, description) + + assert entity.is_on is True + + coordinator.pending_upload = False + assert entity.is_on is False diff --git a/tests/test_coordinator_connectable.py b/tests/test_coordinator_connectable.py index 96544bc..a51e4a5 100644 --- a/tests/test_coordinator_connectable.py +++ b/tests/test_coordinator_connectable.py @@ -5,12 +5,17 @@ the entity is correctly reported as available. """ +from datetime import datetime, timezone +from types import SimpleNamespace from unittest.mock import MagicMock, patch +import time from custom_components.opendisplay.coordinator import ( + BluetoothChange, BluetoothScanningMode, OpenDisplayCoordinator, + OpenDisplayUpdate, ) @@ -93,3 +98,668 @@ def _capture_init(self, *args, **kwargs): "connectable=True would exclude non-connectable advertisements from deep-sleep " "devices; the coordinator must use connectable=False" ) + + +def test_startup_from_cache_ignores_non_opendisplay_advertisement() -> None: + """Cached startup should only trust fresh OpenDisplay advertisements.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator(hass, "AA:BB:CC:DD:EE:FF") + + coordinator._started_ble_time = 1000.0 + coordinator.async_startup_from_cache() + assert coordinator.available is False + + service_info = SimpleNamespace( + address="AA:BB:CC:DD:EE:FF", + time=1001.0, + manufacturer_data={}, + ) + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator." + "PassiveBluetoothDataUpdateCoordinator._async_handle_bluetooth_event" + ) as mock_super: + coordinator._async_handle_bluetooth_event( + service_info, + BluetoothChange.ADVERTISEMENT, + ) + + mock_super.assert_not_called() + assert coordinator.available is False + assert coordinator._last_service_info_time is None + + +def test_startup_from_cache_ignores_restored_bluetooth_history() -> None: + """Bluetooth history from before coordinator start must not mark device online.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator(hass, "AA:BB:CC:DD:EE:FF") + + coordinator._started_ble_time = 1000.0 + coordinator.async_startup_from_cache() + + service_info = SimpleNamespace( + address="AA:BB:CC:DD:EE:FF", + time=999.0, + manufacturer_data={}, + ) + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator." + "PassiveBluetoothDataUpdateCoordinator._async_handle_bluetooth_event" + ) as mock_super: + coordinator._async_handle_bluetooth_event( + service_info, + BluetoothChange.ADVERTISEMENT, + ) + + mock_super.assert_not_called() + assert coordinator.available is False + + +def test_expected_sleep_window_uses_default_timeout_margin() -> None: + """Unavailable is suppressed for the deep-sleep availability window.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = True + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=120, + ) + + now = time.time() + coordinator.data = SimpleNamespace(last_seen=now - 539) + assert coordinator._is_expected_sleep() is True + + coordinator.data = SimpleNamespace(last_seen=now - 541) + assert coordinator._is_expected_sleep() is False + + +def test_deep_sleep_availability_window_adds_timeout_margin() -> None: + """Long sleep intervals should add the configured timeout margin.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + self._listeners = {} + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=24 * 60 * 60, + ) + + assert coordinator.deep_sleep_availability_window_seconds == ( + 24 * 60 * 60 + 7 * 60 + ) + + +def test_deep_sleep_availability_window_uses_configured_timeout_margin() -> None: + """Options can tune the deep-sleep timeout margin.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + self._listeners = {} + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=120, + deep_sleep_timeout_margin_minutes=10, + ) + + assert coordinator.deep_sleep_timeout_margin_minutes == 10 + assert coordinator.deep_sleep_availability_window_seconds == 720 + + +def test_expected_wakeup_timestamp_reflects_last_seen_plus_deep_sleep() -> None: + """Expected wakeup timestamp should follow last_seen + deep_sleep.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = True + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=300, + ) + + coordinator.data = SimpleNamespace(last_seen=1000.0) + expected = coordinator.expected_wakeup_timestamp + assert expected is not None + assert expected.timestamp() == 1300.0 + + +def test_available_remains_true_within_deep_sleep_grace_after_last_seen() -> None: + """Deep-sleep devices should stay available for grace window.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + self._listeners = {} + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=300, + ) + + now = time.time() + coordinator.data = SimpleNamespace(last_seen=now - 60) + assert coordinator.available is True + + +def test_available_false_after_deep_sleep_window_even_if_base_available() -> None: + """Stale deep-sleep devices should become unavailable after wake window.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = True + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=120, + ) + + now = time.time() + coordinator.data = SimpleNamespace(last_seen=now - 360) + assert coordinator.available is True + + coordinator.data = SimpleNamespace(last_seen=now - 541) + assert coordinator.available is False + + +def test_available_true_during_startup_cache_fallback_window() -> None: + """Startup cache fallback should keep deep-sleep device available.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=300, + ) + + coordinator.async_startup_from_cache() + assert coordinator.available is True + + +def test_available_false_after_startup_cache_sleep_window_expires() -> None: + """Cached startup should expire when the device misses its wake window.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=300, + ) + + with patch( + "custom_components.opendisplay.coordinator._utc_timestamp", + return_value=1000, + ): + coordinator.async_startup_from_cache() + assert coordinator.available is True + + with patch( + "custom_components.opendisplay.coordinator._utc_timestamp", + return_value=1721, + ): + assert coordinator.available is False + + +def test_deep_sleep_deadline_schedules_state_update() -> None: + """Restored last_seen should schedule a HA state update at the sleep deadline.""" + hass = MagicMock() + cancel_deadline = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + self._listeners = {} + + with patch(original_init, _capture_init), patch( + "custom_components.opendisplay.coordinator.async_track_point_in_utc_time", + return_value=cancel_deadline, + ) as mock_track, patch( + "custom_components.opendisplay.coordinator._utc_timestamp", + return_value=1000.0, + ): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=120, + ) + coordinator.async_restore_last_seen( + datetime.fromtimestamp(900, tz=timezone.utc) + ) + + mock_track.assert_called_once() + assert mock_track.call_args.args[0] is hass + assert mock_track.call_args.args[2].timestamp() == 1440.0 + assert coordinator._deep_sleep_deadline_unsub is cancel_deadline + + +def test_deep_sleep_deadline_updates_listeners_when_window_expires() -> None: + """Entities should be refreshed when the deep-sleep window expires.""" + hass = MagicMock() + listener = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = True + self._listeners = {object(): (listener, None)} + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=120, + ) + + coordinator.data = SimpleNamespace(last_seen=1000.0) + with patch( + "custom_components.opendisplay.coordinator._utc_timestamp", + return_value=1540.0, + ): + coordinator._async_deep_sleep_deadline_reached( + datetime.fromtimestamp(1540, tz=timezone.utc) + ) + + assert coordinator.available is False + assert coordinator._available is False + listener.assert_called_once() + + +def test_non_opendisplay_advertisement_preserves_restored_last_seen() -> None: + """Non-OpenDisplay advertisements should not erase restored sleep state.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + self._listeners = {} + + with patch(original_init, _capture_init), patch( + "custom_components.opendisplay.coordinator.async_track_point_in_utc_time", + return_value=MagicMock(), + ), patch( + "custom_components.opendisplay.coordinator._utc_timestamp", + return_value=1000.0, + ): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=120, + ) + coordinator.async_restore_last_seen( + datetime.fromtimestamp(900, tz=timezone.utc) + ) + + service_info = SimpleNamespace( + address="AA:BB:CC:DD:EE:FF", + time=1001.0, + manufacturer_data={}, + ) + + with patch( + "homeassistant.components.bluetooth.passive_update_coordinator." + "PassiveBluetoothDataUpdateCoordinator._async_handle_bluetooth_event" + ) as mock_super: + coordinator._async_handle_bluetooth_event( + service_info, + BluetoothChange.ADVERTISEMENT, + ) + + mock_super.assert_not_called() + assert coordinator._restored_last_seen == 900.0 + assert coordinator._last_service_info_time is None + + +def test_expected_wakeup_timestamp_uses_startup_cache_reference() -> None: + """Expected wake-up should be known even before the first advertisement.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=300, + ) + + with patch( + "custom_components.opendisplay.coordinator._utc_timestamp", + return_value=1000, + ): + coordinator.async_startup_from_cache() + + expected = coordinator.expected_wakeup_timestamp + assert expected is not None + assert expected.timestamp() == 1300.0 + + +def test_fresh_restored_last_seen_tightens_startup_cache_window() -> None: + """Fresh restored last_seen should override the conservative startup reference.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + self._listeners = {} + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=300, + ) + + with patch( + "custom_components.opendisplay.coordinator._utc_timestamp", + return_value=1000, + ): + coordinator.async_startup_from_cache() + coordinator.async_restore_last_seen( + datetime.fromtimestamp(400, tz=timezone.utc) + ) + assert coordinator.available is True + + deadline = coordinator.deep_sleep_availability_deadline_timestamp + assert deadline is not None + assert deadline.timestamp() == 1120.0 + + +def test_stale_restored_last_seen_aligns_to_current_startup_cycle() -> None: + """Stale restored last_seen should align to the current sleep cycle.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + self._listeners = {} + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=300, + ) + + with patch( + "custom_components.opendisplay.coordinator._utc_timestamp", + return_value=1000, + ): + coordinator.async_startup_from_cache() + coordinator.async_restore_last_seen( + datetime.fromtimestamp(200, tz=timezone.utc) + ) + assert coordinator.available is True + + assert coordinator._restored_last_seen == 500.0 + expected = coordinator.expected_wakeup_timestamp + assert expected is not None + assert expected.timestamp() == 800.0 + deadline = coordinator.deep_sleep_availability_deadline_timestamp + assert deadline is not None + assert deadline.timestamp() == 1220.0 + + +def test_restored_last_seen_aligns_to_next_deep_sleep_wake_cycle() -> None: + """Restart during sleep should keep device available until next wake margin.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + self._listeners = {} + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=600, + deep_sleep_timeout_margin_minutes=6, + ) + + with patch( + "custom_components.opendisplay.coordinator._utc_timestamp", + return_value=1000 + (19 * 60), + ): + coordinator.async_startup_from_cache() + coordinator.async_restore_last_seen( + datetime.fromtimestamp(1000, tz=timezone.utc) + ) + assert coordinator.available is True + + assert coordinator._restored_last_seen == 1000 + (10 * 60) + expected = coordinator.expected_wakeup_timestamp + assert expected is not None + assert expected.timestamp() == 1000 + (20 * 60) + deadline = coordinator.deep_sleep_availability_deadline_timestamp + assert deadline is not None + assert deadline.timestamp() == 1000 + (26 * 60) + + +def test_expected_wakeup_timestamp_uses_restored_last_seen() -> None: + """Expected wake-up should use restored last_seen before fresh data arrives.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + self._listeners = {} + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator( + hass, + "AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=300, + ) + + coordinator.async_restore_last_seen(datetime.fromtimestamp(600, tz=timezone.utc)) + expected = coordinator.expected_wakeup_timestamp + assert expected is not None + assert expected.timestamp() == 900.0 + + +def test_coordinator_keeps_parsed_update_in_data_after_super_call() -> None: + """Coordinator data should remain OpenDisplayUpdate, not raw service info.""" + hass = MagicMock() + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + self._available = False + self._listeners = {} + + with patch(original_init, _capture_init): + coordinator = OpenDisplayCoordinator(hass, "AA:BB:CC:DD:EE:FF") + + coordinator._started_ble_time = 1000.0 + service_info = SimpleNamespace( + address="AA:BB:CC:DD:EE:FF", + time=1001.0, + rssi=-60, + manufacturer_data={0x2A8A: b"raw"}, + ) + + with patch( + "custom_components.opendisplay.coordinator.MANUFACTURER_ID", + 0x2A8A, + ), patch( + "custom_components.opendisplay.coordinator.parse_advertisement", + return_value=SimpleNamespace(), + ), patch( + "homeassistant.components.bluetooth.passive_update_coordinator." + "PassiveBluetoothDataUpdateCoordinator._async_handle_bluetooth_event" + ) as mock_super: + mock_super.side_effect = ( + lambda svc, _chg: setattr(coordinator, "data", svc) + ) + with patch( + "custom_components.opendisplay.coordinator._utc_timestamp", + return_value=1700000000.0, + ): + coordinator._async_handle_bluetooth_event( + service_info, + BluetoothChange.ADVERTISEMENT, + ) + + assert isinstance(coordinator.data, OpenDisplayUpdate) + assert coordinator.data.last_seen == 1700000000.0 + assert coordinator.data.last_seen_ble_time == 1001.0 diff --git a/tests/test_deep_sleep_queue.py b/tests/test_deep_sleep_queue.py deleted file mode 100644 index fe543ee..0000000 --- a/tests/test_deep_sleep_queue.py +++ /dev/null @@ -1,464 +0,0 @@ -"""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 -import logging -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 - - -def test_upload_image_schema_accepts_none_rotation() -> None: - """Upload image schema should map the frontend 'none' sentinel to default rotation.""" - from opendisplay import Rotation - from custom_components.opendisplay.services import SCHEMA_UPLOAD_IMAGE - - validated = SCHEMA_UPLOAD_IMAGE( - { - "device_id": "device_1", - "image": {"media_content_id": "media-source://image"}, - "rotation": "none", - } - ) - - assert validated["rotation"] == Rotation.ROTATE_0 - - -def test_drawcustom_schema_accepts_none_numeric_fields() -> None: - """Drawcustom schema should map 'none' to default numeric values.""" - from custom_components.opendisplay.services import SCHEMA_DRAWCUSTOM - - validated = SCHEMA_DRAWCUSTOM( - { - "payload": [], - "rotate": "none", - "refresh_type": "none", - } - ) - - assert validated["rotate"] == 0 - assert validated["refresh_type"] == 0 - - -# --------------------------------------------------------------------------- -# _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() - hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") - 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 entry.runtime_data.deep_sleep_upload.jpeg_bytes == b"jpeg" - assert not entry.runtime_data.deep_sleep_upload.is_expired - - -@pytest.mark.asyncio -async def test_send_image_queue_log_includes_sleep_and_ttl(caplog: pytest.LogCaptureFixture) -> None: - """Queue log includes deep sleep and TTL when device is not connectable.""" - hass = MagicMock() - hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") - entry = _make_entry(deep_sleep_time_seconds=300) - 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, - ), - caplog.at_level(logging.INFO), - ): - await _async_send_image( - hass, entry, img, dither_mode=DitherMode.BURKES, refresh_mode=RefreshMode.FULL - ) - - assert "Queued image upload for AA:BB:CC:DD:EE:FF (device not connectable, sleep=300s, ttl=330s)" in caplog.text - - -@pytest.mark.asyncio -async def test_send_image_uploads_immediately_when_connectable( - caplog: pytest.LogCaptureFixture, -) -> 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" - ), caplog.at_level(logging.INFO): - 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() - assert ( - "Uploading image to AA:BB:CC:DD:EE:FF immediately (device connectable, deep sleep=3600s)" - in caplog.text - ) - assert "AA:BB:CC:DD:EE:FF: Upload completed and image cache updated" in caplog.text - - -@pytest.mark.asyncio -async def test_send_image_queues_when_connection_times_out( - caplog: pytest.LogCaptureFixture, -) -> None: - """Image upload is queued when connection fails but deep sleep is enabled.""" - hass = MagicMock() - hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") - entry = _make_entry(deep_sleep_time_seconds=3600) - img = MagicMock() - - from opendisplay import DitherMode, RefreshMode - from custom_components.opendisplay.services import _async_send_image - - class _FakeBLETimeoutError(Exception): - """Synthetic timeout exception for fallback queue testing.""" - - with caplog.at_level(logging.INFO): - with ( - patch( - "custom_components.opendisplay.services.BLETimeoutError", - _FakeBLETimeoutError, - ), - patch( - "custom_components.opendisplay.services.async_ble_device_from_address", - return_value=MagicMock(), # appears connectable - ), - patch( - "custom_components.opendisplay.services._async_connect_and_run", - new_callable=AsyncMock, - side_effect=_FakeBLETimeoutError("timeout"), - ) as mock_run, - ): - 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_upload.jpeg_bytes == b"jpeg" - mock_run.assert_awaited_once() - queue_logs = [ - rec.getMessage() for rec in caplog.records if "Queued image upload for" in rec.getMessage() - ] - assert queue_logs == [ - "Queued image upload for AA:BB:CC:DD:EE:FF (connection failed, sleep=3600s, ttl=3960s)" - ] - - -@pytest.mark.asyncio -async def test_send_image_queued_upload_replaces_previous( - caplog: pytest.LogCaptureFixture, -) -> None: - """A new image upload replaces any previously queued upload.""" - hass = MagicMock() - hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") - entry = _make_entry(deep_sleep_time_seconds=300) - old_handle = MagicMock() - new_handle = MagicMock() - entry.runtime_data.deep_sleep_expiry_handle = old_handle - first_upload = DeepSleepQueuedUpload( - action=AsyncMock(), - jpeg_bytes=b"", - queued_at=datetime.now() - timedelta(seconds=240), - expiry=timedelta(seconds=300), - ) - entry.runtime_data.deep_sleep_upload = first_upload - - img = MagicMock() - from opendisplay import DitherMode, RefreshMode - from custom_components.opendisplay.services import _async_send_image - - hass.loop = MagicMock() - hass.loop.call_later = MagicMock(return_value=new_handle) - - with patch( - "custom_components.opendisplay.services.async_ble_device_from_address", - return_value=None, - ), caplog.at_level(logging.INFO): - 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 - assert new_upload.expiry == timedelta(seconds=330) - assert entry.runtime_data.deep_sleep_expiry_handle is new_handle - old_handle.cancel.assert_called_once() - assert ( - "Replacing queued image upload for AA:BB:CC:DD:EE:FF; previous ttl_left=" - in caplog.text - ) - assert "reset ttl=330s" in caplog.text - - -# --------------------------------------------------------------------------- -# 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() - hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") - 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( - caplog: pytest.LogCaptureFixture, -) -> 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" - ), caplog.at_level(logging.INFO): - 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() - assert ( - "Uploading image to AA:BB:CC:DD:EE:FF immediately " - "(deep sleep unsupported/disabled, connectable=False)" in caplog.text - ) - assert "AA:BB:CC:DD:EE:FF: Upload completed and image cache updated" in caplog.text - - -@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() - hass.async_add_executor_job = AsyncMock(return_value=b"jpeg") - 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 - - -# --------------------------------------------------------------------------- -# _async_connect_and_run — device-not-found error wrapping -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_connect_and_run_raises_ble_error_when_no_device_wrap_false() -> None: - """_async_connect_and_run raises BLEConnectionError (retryable) when the BLE - connectable cache has no entry and wrap_connection_errors=False. - - This ensures deep-sleep flush callers keep the queued upload for retry - instead of permanently dropping it. - """ - from opendisplay import BLEConnectionError as _BLEConnectionError - from custom_components.opendisplay.services import _async_connect_and_run - - hass = MagicMock() - entry = MagicMock() - entry.unique_id = "AA:BB:CC:DD:EE:FF" - - with ( - patch( - "custom_components.opendisplay.services.async_ble_device_from_address", - return_value=None, - ), - pytest.raises(_BLEConnectionError), - ): - await _async_connect_and_run(hass, entry, AsyncMock(), wrap_connection_errors=False) - - -@pytest.mark.asyncio -async def test_connect_and_run_raises_home_assistant_error_when_no_device_wrap_true() -> None: - """_async_connect_and_run raises HomeAssistantError (user-visible) when the BLE - connectable cache has no entry and wrap_connection_errors=True (the default). - """ - from homeassistant.exceptions import HomeAssistantError - from custom_components.opendisplay.services import _async_connect_and_run - - hass = MagicMock() - entry = MagicMock() - entry.unique_id = "AA:BB:CC:DD:EE:FF" - entry.data = {} - - with ( - patch( - "custom_components.opendisplay.services.async_ble_device_from_address", - return_value=None, - ), - pytest.raises(HomeAssistantError), - ): - await _async_connect_and_run(hass, entry, AsyncMock(), wrap_connection_errors=True) diff --git a/tests/test_deep_sleep_runtime_sync.py b/tests/test_deep_sleep_runtime_sync.py deleted file mode 100644 index 022e19f..0000000 --- a/tests/test_deep_sleep_runtime_sync.py +++ /dev/null @@ -1,584 +0,0 @@ -"""Tests for deep-sleep restart/runtime config sync behavior.""" - -from __future__ import annotations - -import asyncio -from datetime import datetime, timedelta -import logging -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -import custom_components.opendisplay as opendisplay_integration -from custom_components.opendisplay import async_setup_entry -from custom_components.opendisplay.const import ( - CONF_CACHED_DEVICE_CONFIG, - CONF_CACHED_FIRMWARE, - CONF_CACHED_IS_FLEX, - CONF_ENCRYPTION_KEY, -) -from custom_components.opendisplay.deep_sleep import DeepSleepQueuedUpload - - -def _make_device_config(deep_sleep_seconds: int) -> SimpleNamespace: - """Create a minimal device config object used by async_setup_entry.""" - return SimpleNamespace( - power=SimpleNamespace(deep_sleep_time_seconds=deep_sleep_seconds), - manufacturer=SimpleNamespace( - manufacturer_name="OpenDisplay", - board_type_name="TestBoard", - board_type=1, - board_revision="1", - ), - displays=[ - SimpleNamespace( - color_scheme_enum=SimpleNamespace(name="BW"), - screen_diagonal_inches=2.9, - pixel_width=296, - pixel_height=128, - ) - ], - touch_controllers=[], - ) - - -class _FakeCoordinator: - """Minimal coordinator stub for setup/listener tests.""" - - def __init__(self) -> None: - self.available = False - self.listener = None - self.update_listeners_called = False - - def async_start(self): - return lambda: None - - def async_add_listener(self, listener): - self.listener = listener - return lambda: None - - def async_update_listeners(self) -> None: - self.update_listeners_called = True - - -def _make_hass() -> MagicMock: - """Create minimal hass mock that can schedule tasks and forward platforms.""" - hass = MagicMock() - hass.config_entries = MagicMock() - hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True) - hass.config_entries.async_update_entry = MagicMock() - hass_tasks: list[asyncio.Task] = [] - - def _create_task(coro, *, name=None): - task = asyncio.create_task(coro, name=name) - hass_tasks.append(task) - return task - - hass.async_create_task = MagicMock(side_effect=_create_task) - hass._test_tasks = hass_tasks - return hass - - -def _make_entry() -> MagicMock: - """Create minimal config entry mock.""" - entry = MagicMock() - entry.unique_id = "AA:BB:CC:DD:EE:FF" - entry.entry_id = "entry-1" - entry.data = {} - entry.async_on_unload = MagicMock() - return entry - - -def test_normalize_entry_data_converts_legacy_key_and_drops_byte_cache() -> None: - """Legacy byte values must not remain in config-entry storage.""" - normalized = opendisplay_integration._normalize_entry_data( - { - CONF_ENCRYPTION_KEY: b"\x01" * 16, - CONF_CACHED_FIRMWARE: {"major": 1, "minor": 0}, - CONF_CACHED_DEVICE_CONFIG: { - "system": {"reserved": b"\x00" * 15}, - "manufacturer": {"reserved": b"\x00" * 18}, - }, - CONF_CACHED_IS_FLEX: False, - } - ) - - assert normalized[CONF_ENCRYPTION_KEY] == "01" * 16 - assert CONF_CACHED_DEVICE_CONFIG not in normalized - assert CONF_CACHED_FIRMWARE not in normalized - assert CONF_CACHED_IS_FLEX not in normalized - - -def test_cache_runtime_data_uses_json_safe_device_config() -> None: - """Cached device config should use the library JSON serializer.""" - hass = _make_hass() - entry = _make_entry() - firmware = {"major": 1, "minor": 2} - serialized_config = {"version": 1, "packets": []} - - with patch( - "custom_components.opendisplay.config_to_json", - return_value=serialized_config, - ): - opendisplay_integration._cache_runtime_data( - hass, entry, firmware, MagicMock(), False - ) - - hass.config_entries.async_update_entry.assert_called_once() - assert hass.config_entries.async_update_entry.call_args.kwargs["data"] == { - CONF_CACHED_FIRMWARE: firmware, - CONF_CACHED_DEVICE_CONFIG: serialized_config, - CONF_CACHED_IS_FLEX: False, - } - - -@pytest.mark.asyncio -async def test_restart_during_deep_sleep_uses_cached_runtime_and_syncs_when_available() -> None: - """Restart while sleeping uses cache and later syncs on availability edge.""" - hass = _make_hass() - entry = _make_entry() - coordinator = _FakeCoordinator() - - cached_config = _make_device_config(300) - latest_config = _make_device_config(600) - latest_fw = {"major": 9, "minor": 9} - - class _FakeDevice: - def __init__(self, **kwargs) -> None: - self.is_flex = False - self.config = latest_config - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def read_firmware_version(self): - return latest_fw - - with ( - patch( - "custom_components.opendisplay._cached_runtime_data", - return_value=({"major": 1, "minor": 0}, cached_config, False), - ), - patch( - "custom_components.opendisplay.OpenDisplayCoordinator", - return_value=coordinator, - ), - patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), - patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), - patch( - "custom_components.opendisplay.async_ble_device_from_address", - side_effect=[None, MagicMock()], - ), - patch("custom_components.opendisplay._cache_runtime_data"), - ): - assert await async_setup_entry(hass, entry) is True - assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == 300 - - coordinator.available = True - coordinator.listener() - await asyncio.gather(*hass._test_tasks) - - assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == 600 - assert coordinator.update_listeners_called is True - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("initial_sleep", "latest_sleep"), - [ - (300, 300), # config unchanged - (300, 900), # deep-sleep time changed - (300, 0), # deep-sleep disabled - (0, 300), # deep-sleep enabled - ], -) -async def test_runtime_config_sync_updates_deep_sleep_value_without_restart( - initial_sleep: int, - latest_sleep: int, -) -> None: - """Live availability transition refreshes runtime deep-sleep config.""" - hass = _make_hass() - entry = _make_entry() - coordinator = _FakeCoordinator() - - initial_config = _make_device_config(initial_sleep) - latest_config = _make_device_config(latest_sleep) - fw_values = [{"major": 1, "minor": 0}, {"major": 2, "minor": 3}] - config_values = [initial_config, latest_config] - - class _FakeDevice: - def __init__(self, **kwargs) -> None: - self.is_flex = False - self.config = config_values.pop(0) - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def read_firmware_version(self): - return fw_values.pop(0) - - with ( - patch("custom_components.opendisplay._cached_runtime_data", return_value=None), - patch( - "custom_components.opendisplay.OpenDisplayCoordinator", - return_value=coordinator, - ), - patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), - patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), - patch( - "custom_components.opendisplay.async_ble_device_from_address", - side_effect=[MagicMock(), MagicMock()], - ), - patch( - "custom_components.opendisplay._cache_runtime_data" - ) as mock_cache_runtime_data, - ): - assert await async_setup_entry(hass, entry) is True - assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == initial_sleep - - coordinator.available = True - coordinator.listener() - await asyncio.gather(*hass._test_tasks) - - assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == latest_sleep - assert mock_cache_runtime_data.call_args.args[3] == latest_config - - -@pytest.mark.asyncio -async def test_queued_upload_is_kept_when_wake_flush_has_connection_error( - caplog: pytest.LogCaptureFixture, -) -> None: - """Wake-up flush keeps queued image for later retry on transient BLE errors.""" - hass = _make_hass() - entry = _make_entry() - coordinator = _FakeCoordinator() - initial_config = _make_device_config(300) - - class _FakeDevice: - def __init__(self, **kwargs) -> None: - self.is_flex = False - self.config = initial_config - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def read_firmware_version(self): - return {"major": 1, "minor": 0} - - queued_action = AsyncMock() - queued_upload = DeepSleepQueuedUpload( - action=queued_action, - jpeg_bytes=b"", - queued_at=datetime.now() - timedelta(seconds=20), - expiry=timedelta(seconds=330), - ) - expiry_handle = MagicMock() - - with ( - patch("custom_components.opendisplay._cached_runtime_data", return_value=None), - patch( - "custom_components.opendisplay.OpenDisplayCoordinator", - return_value=coordinator, - ), - patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), - patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), - patch( - "custom_components.opendisplay.async_ble_device_from_address", - return_value=MagicMock(), - ), - patch("custom_components.opendisplay._cache_runtime_data"), - patch( - "custom_components.opendisplay.services._async_connect_and_run", - new_callable=AsyncMock, - side_effect=opendisplay_integration.BLEConnectionError("no free slot"), - ), - caplog.at_level(logging.INFO), - ): - assert await async_setup_entry(hass, entry) is True - entry.runtime_data.deep_sleep_upload = queued_upload - entry.runtime_data.deep_sleep_expiry_handle = expiry_handle - - coordinator.available = True - coordinator.listener() - await asyncio.gather(*hass._test_tasks) - - assert entry.runtime_data.deep_sleep_upload is queued_upload - assert entry.runtime_data.deep_sleep_expiry_handle is expiry_handle - assert entry.runtime_data.deep_sleep_flush_task is None - expiry_handle.cancel.assert_not_called() - assert "Queued image upload deferred again; keeping queue" in caplog.text - - -@pytest.mark.asyncio -async def test_queued_upload_is_cleared_when_wake_flush_succeeds() -> None: - """Wake-up flush removes queued image after successful upload.""" - hass = _make_hass() - entry = _make_entry() - coordinator = _FakeCoordinator() - initial_config = _make_device_config(300) - - class _FakeDevice: - def __init__(self, **kwargs) -> None: - self.is_flex = False - self.config = initial_config - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def read_firmware_version(self): - return {"major": 1, "minor": 0} - - queued_upload = DeepSleepQueuedUpload( - action=AsyncMock(), - jpeg_bytes=b"queued-jpeg", - queued_at=datetime.now() - timedelta(seconds=20), - expiry=timedelta(seconds=330), - ) - expiry_handle = MagicMock() - - with ( - patch("custom_components.opendisplay._cached_runtime_data", return_value=None), - patch( - "custom_components.opendisplay.OpenDisplayCoordinator", - return_value=coordinator, - ), - patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), - patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), - patch( - "custom_components.opendisplay.async_ble_device_from_address", - return_value=MagicMock(), - ), - patch("custom_components.opendisplay._cache_runtime_data"), - patch( - "custom_components.opendisplay.services._async_connect_and_run", - new_callable=AsyncMock, - ) as mock_connect, - patch("custom_components.opendisplay.async_dispatcher_send") as mock_dispatch, - ): - assert await async_setup_entry(hass, entry) is True - entry.runtime_data.deep_sleep_upload = queued_upload - entry.runtime_data.deep_sleep_expiry_handle = expiry_handle - - coordinator.available = True - coordinator.listener() - await asyncio.gather(*hass._test_tasks) - - mock_connect.assert_awaited_once_with( - hass, - entry, - queued_upload.action, - wrap_connection_errors=False, - ) - mock_dispatch.assert_called_once_with( - hass, - "opendisplay_image_updated_AA:BB:CC:DD:EE:FF", - b"queued-jpeg", - ) - assert entry.runtime_data.deep_sleep_upload is None - assert entry.runtime_data.deep_sleep_expiry_handle is None - assert entry.runtime_data.deep_sleep_flush_task is None - expiry_handle.cancel.assert_called_once() - - -@pytest.mark.asyncio -async def test_restart_connect_timeout_uses_cached_runtime_for_sleeping_device() -> None: - """Startup connect timeout falls back to cache when deep sleep is configured.""" - hass = _make_hass() - entry = _make_entry() - coordinator = _FakeCoordinator() - - cached_config = _make_device_config(300) - - with ( - patch( - "custom_components.opendisplay._cached_runtime_data", - return_value=({"major": 1, "minor": 0}, cached_config, False), - ), - patch( - "custom_components.opendisplay.OpenDisplayCoordinator", - return_value=coordinator, - ), - patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), - patch("custom_components.opendisplay.OpenDisplayDevice", AsyncMock()), - patch( - "custom_components.opendisplay.async_ble_device_from_address", - return_value=MagicMock(), - ), - patch( - "custom_components.opendisplay.asyncio.timeout", - side_effect=TimeoutError, - ), - patch("custom_components.opendisplay._cache_runtime_data"), - ): - assert await async_setup_entry(hass, entry) is True - - assert entry.runtime_data.device_config.power.deep_sleep_time_seconds == 300 - - -@pytest.mark.asyncio -async def test_queued_upload_does_not_spawn_duplicate_flush_when_listener_reenters( - caplog: pytest.LogCaptureFixture, -) -> None: - """A second coordinator update while flushing must not start another flush.""" - hass = _make_hass() - entry = _make_entry() - coordinator = _FakeCoordinator() - initial_config = _make_device_config(300) - - class _FakeDevice: - def __init__(self, **kwargs) -> None: - self.is_flex = False - self.config = initial_config - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def read_firmware_version(self): - return {"major": 1, "minor": 0} - - queued_upload = DeepSleepQueuedUpload( - action=AsyncMock(), - jpeg_bytes=b"", - queued_at=datetime.now() - timedelta(seconds=20), - expiry=timedelta(seconds=330), - ) - expiry_handle = MagicMock() - flush_started = asyncio.Event() - allow_flush_to_finish = asyncio.Event() - - async def _blocked_connect_and_run(*args, **kwargs): - flush_started.set() - await allow_flush_to_finish.wait() - - with ( - patch("custom_components.opendisplay._cached_runtime_data", return_value=None), - patch( - "custom_components.opendisplay.OpenDisplayCoordinator", - return_value=coordinator, - ), - patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), - patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), - patch( - "custom_components.opendisplay.async_ble_device_from_address", - return_value=MagicMock(), - ), - patch("custom_components.opendisplay._cache_runtime_data"), - patch( - "custom_components.opendisplay.services._async_connect_and_run", - new_callable=AsyncMock, - side_effect=_blocked_connect_and_run, - ) as mock_connect, - caplog.at_level(logging.DEBUG), - ): - assert await async_setup_entry(hass, entry) is True - entry.runtime_data.deep_sleep_upload = queued_upload - entry.runtime_data.deep_sleep_expiry_handle = expiry_handle - - coordinator.available = True - coordinator.listener() - await asyncio.wait_for(flush_started.wait(), timeout=1) - - coordinator.listener() - allow_flush_to_finish.set() - await asyncio.gather(*hass._test_tasks) - - mock_connect.assert_awaited_once_with( - hass, - entry, - queued_upload.action, - wrap_connection_errors=False, - ) - assert entry.runtime_data.deep_sleep_upload is None - assert entry.runtime_data.deep_sleep_flush_task is None - assert "Queued image flush already in progress" not in caplog.text - - -@pytest.mark.asyncio -async def test_queued_upload_kept_when_ble_cache_expires_between_precheck_and_flush( - caplog: pytest.LogCaptureFixture, -) -> None: - """Race condition: BLE connectable cache expires between the coordinator - pre-check (device appears connectable) and the actual connect attempt inside - _async_connect_and_run (device is gone from cache). - - The queue must be kept for the next wake-up cycle, not dropped. - """ - hass = _make_hass() - entry = _make_entry() - coordinator = _FakeCoordinator() - initial_config = _make_device_config(300) - - class _FakeDevice: - def __init__(self, **kwargs) -> None: - self.is_flex = False - self.config = initial_config - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - return False - - async def read_firmware_version(self): - return {"major": 1, "minor": 0} - - queued_action = AsyncMock() - queued_upload = DeepSleepQueuedUpload( - action=queued_action, - jpeg_bytes=b"", - queued_at=datetime.now() - timedelta(seconds=20), - expiry=timedelta(seconds=330), - ) - expiry_handle = MagicMock() - - with ( - patch("custom_components.opendisplay._cached_runtime_data", return_value=None), - patch( - "custom_components.opendisplay.OpenDisplayCoordinator", - return_value=coordinator, - ), - patch("custom_components.opendisplay.dr.async_get", return_value=MagicMock()), - patch("custom_components.opendisplay.OpenDisplayDevice", _FakeDevice), - # __init__.py pre-check sees the device as connectable - patch( - "custom_components.opendisplay.async_ble_device_from_address", - return_value=MagicMock(), - ), - patch("custom_components.opendisplay._cache_runtime_data"), - # services.py connect attempt finds the cache entry gone (race condition) - patch( - "custom_components.opendisplay.services.async_ble_device_from_address", - return_value=None, - ), - caplog.at_level(logging.INFO), - ): - assert await async_setup_entry(hass, entry) is True - entry.runtime_data.deep_sleep_upload = queued_upload - entry.runtime_data.deep_sleep_expiry_handle = expiry_handle - - coordinator.available = True - coordinator.listener() - await asyncio.gather(*hass._test_tasks) - - # Queue must be preserved so the next wake-up can retry the upload - assert entry.runtime_data.deep_sleep_upload is queued_upload - assert entry.runtime_data.deep_sleep_expiry_handle is expiry_handle - assert entry.runtime_data.deep_sleep_flush_task is None - expiry_handle.cancel.assert_not_called() - assert "Queued image upload deferred again; keeping queue" in caplog.text diff --git a/tests/test_entity_sleep_restore.py b/tests/test_entity_sleep_restore.py index 65e35ee..f850b06 100644 --- a/tests/test_entity_sleep_restore.py +++ b/tests/test_entity_sleep_restore.py @@ -1,28 +1,32 @@ -"""Tests for sleeping-device entity behavior (assumed state + restore).""" +"""Tests for sensor restore behavior and coordinator-driven availability.""" from __future__ import annotations +from datetime import datetime, timezone from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch +from custom_components.opendisplay.entity import OpenDisplayEntity from custom_components.opendisplay.sensor import ( OpenDisplaySensorEntity, OpenDisplaySensorEntityDescription, ) +_COORDINATOR_ENTITY_INIT = ( + "custom_components.opendisplay.entity." + "PassiveBluetoothCoordinatorEntity.__init__" +) + def _make_coordinator(*, available: bool, deep_sleep_seconds: int, data=None): """Create a minimal coordinator-like object for entity unit tests.""" - device_config = SimpleNamespace( - power=SimpleNamespace(deep_sleep_time_seconds=deep_sleep_seconds) - ) - runtime_data = SimpleNamespace(device_config=device_config) - config_entry = SimpleNamespace(runtime_data=runtime_data) return SimpleNamespace( available=available, data=data, address="AA:BB:CC:DD:EE:FF", - config_entry=config_entry, + deep_sleep_time_seconds=deep_sleep_seconds, + expected_wakeup_timestamp=None, + async_restore_last_seen=MagicMock(), ) @@ -34,7 +38,12 @@ def _make_description() -> OpenDisplaySensorEntityDescription: ) -def _build_entity(*, available: bool, deep_sleep_seconds: int, data=None) -> OpenDisplaySensorEntity: +def _build_entity( + *, + available: bool, + deep_sleep_seconds: int, + data=None, +) -> OpenDisplaySensorEntity: """Build sensor entity with patched coordinator base initializer.""" coordinator = _make_coordinator( available=available, @@ -42,18 +51,18 @@ def _build_entity(*, available: bool, deep_sleep_seconds: int, data=None) -> Ope data=data, ) with patch( - "custom_components.opendisplay.entity.PassiveBluetoothCoordinatorEntity.__init__", + _COORDINATOR_ENTITY_INIT, lambda self, coordinator: setattr(self, "coordinator", coordinator), ): return OpenDisplaySensorEntity(coordinator, _make_description()) -def test_sleeping_device_is_available_with_assumed_state() -> None: - """Sleeping device should stay available with assumed_state=True.""" +def test_sleeping_device_is_unavailable_without_advertisements() -> None: + """Without coordinator availability the entity remains unavailable.""" entity = _build_entity(available=False, deep_sleep_seconds=300) - assert entity.available is True - assert entity.assumed_state is True + assert entity.available is False + assert entity.assumed_state is False def test_non_sleeping_offline_device_is_unavailable() -> None: @@ -75,15 +84,127 @@ def test_online_device_not_assumed() -> None: def test_sensor_native_value_restores_last_state_when_sleeping() -> None: """When coordinator has no fresh data, sensor falls back to restored value.""" entity = _build_entity(available=False, deep_sleep_seconds=300) - entity._restored_data = SimpleNamespace(native_value=22.5) + entity._attr_native_value = 22.5 assert entity.native_value == 22.5 +def test_sensor_native_value_without_deep_sleep_does_not_restore() -> None: + """Restore fallback is disabled when device does not support deep sleep.""" + entity = _build_entity(available=False, deep_sleep_seconds=0) + entity._attr_native_value = 22.5 + + assert entity.native_value is None + + def test_sensor_native_value_prefers_live_data_over_restored() -> None: """Fresh coordinator data must override restored value.""" data = SimpleNamespace(advertisement=SimpleNamespace(temperature_c=19.8)) entity = _build_entity(available=True, deep_sleep_seconds=300, data=data) - entity._restored_data = SimpleNamespace(native_value=22.5) + entity._attr_native_value = 22.5 assert entity.native_value == 19.8 + + +async def test_sensor_async_added_to_hass_restores_when_deep_sleep_enabled() -> None: + """RestoreSensor lifecycle should load native value for deep-sleep devices.""" + entity = _build_entity(available=False, deep_sleep_seconds=300) + last_sensor_data = SimpleNamespace(native_value=22.5) + + with patch.object( + OpenDisplayEntity, + "async_added_to_hass", + new_callable=AsyncMock, + create=True, + ), patch.object( + entity, + "async_get_last_sensor_data", + new_callable=AsyncMock, + return_value=last_sensor_data, + ) as mock_get_last_sensor_data: + await entity.async_added_to_hass() + + mock_get_last_sensor_data.assert_awaited_once() + assert entity.native_value == 22.5 + + +async def test_async_added_to_hass_skips_restore_when_deep_sleep_disabled() -> None: + """Always-on devices should not reuse stale restored sensor values.""" + entity = _build_entity(available=False, deep_sleep_seconds=0) + + with patch.object( + OpenDisplayEntity, + "async_added_to_hass", + new_callable=AsyncMock, + create=True, + ), patch.object( + entity, + "async_get_last_sensor_data", + new_callable=AsyncMock, + return_value=SimpleNamespace(native_value=22.5), + ) as mock_get_last_sensor_data: + await entity.async_added_to_hass() + + mock_get_last_sensor_data.assert_not_awaited() + assert entity.native_value is None + + +async def test_last_seen_restore_updates_coordinator_sleep_reference() -> None: + """Restored last_seen should tighten coordinator wake-up calculations.""" + coordinator = _make_coordinator( + available=False, + deep_sleep_seconds=300, + data=None, + ) + description = OpenDisplaySensorEntityDescription( + key="last_seen", + value_fn=lambda upd: None, + ) + with patch( + _COORDINATOR_ENTITY_INIT, + lambda self, coordinator: setattr(self, "coordinator", coordinator), + ): + entity = OpenDisplaySensorEntity(coordinator, description) + + restored = datetime(2026, 6, 1, 8, 0, tzinfo=timezone.utc) + with patch.object( + OpenDisplayEntity, + "async_added_to_hass", + new_callable=AsyncMock, + create=True, + ), patch.object( + entity, + "async_get_last_sensor_data", + new_callable=AsyncMock, + return_value=SimpleNamespace(native_value=restored), + ): + await entity.async_added_to_hass() + + coordinator.async_restore_last_seen.assert_called_once_with(restored) + + +def test_expected_wakeup_keeps_restored_value_without_fresh_advertisement() -> None: + """Expected wakeup should not be erased while the device is still asleep.""" + coordinator = _make_coordinator( + available=False, + deep_sleep_seconds=300, + data=None, + ) + description = OpenDisplaySensorEntityDescription( + key="expected_wakeup", + value_fn=lambda upd: None, + ) + with patch( + _COORDINATOR_ENTITY_INIT, + lambda self, coordinator: setattr(self, "coordinator", coordinator), + ): + entity = OpenDisplaySensorEntity(coordinator, description) + + restored = datetime(2026, 6, 1, 8, 0, tzinfo=timezone.utc) + entity._attr_native_value = restored + + assert entity.native_value == restored + + live = datetime(2026, 6, 1, 8, 5, tzinfo=timezone.utc) + coordinator.expected_wakeup_timestamp = live + assert entity.native_value == live diff --git a/tests/test_last_seen_cache.py b/tests/test_last_seen_cache.py new file mode 100644 index 0000000..f5f4109 --- /dev/null +++ b/tests/test_last_seen_cache.py @@ -0,0 +1,46 @@ +"""Tests for cached last_seen persistence.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from custom_components.opendisplay import ( + _cache_last_seen, + _cached_last_seen, + _normalize_entry_data, +) +from custom_components.opendisplay.const import CONF_CACHED_LAST_SEEN + + +def test_cached_last_seen_accepts_numeric_values() -> None: + """Cached last_seen should be restored as a positive timestamp.""" + assert _cached_last_seen({CONF_CACHED_LAST_SEEN: "123.5"}) == 123.5 + + +def test_normalize_entry_data_drops_invalid_cached_last_seen() -> None: + """Invalid cached last_seen values should not stay in entry data.""" + normalized = _normalize_entry_data({CONF_CACHED_LAST_SEEN: "not-a-timestamp"}) + + assert CONF_CACHED_LAST_SEEN not in normalized + + +def test_cache_last_seen_throttles_small_updates() -> None: + """last_seen writes should be throttled for chatty BLE advertisements.""" + hass = SimpleNamespace(config_entries=MagicMock()) + entry = SimpleNamespace(data={CONF_CACHED_LAST_SEEN: 1000.0}) + + _cache_last_seen(hass, entry, 1030.0) + + hass.config_entries.async_update_entry.assert_not_called() + + +def test_cache_last_seen_persists_after_throttle_window() -> None: + """last_seen should be persisted when enough time has passed.""" + hass = SimpleNamespace(config_entries=MagicMock()) + entry = SimpleNamespace(data={CONF_CACHED_LAST_SEEN: 1000.0}) + + _cache_last_seen(hass, entry, 1061.0) + + hass.config_entries.async_update_entry.assert_called_once_with( + entry, + data={CONF_CACHED_LAST_SEEN: 1061.0}, + ) diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py new file mode 100644 index 0000000..23d02aa --- /dev/null +++ b/tests/test_options_flow.py @@ -0,0 +1,82 @@ +"""Tests for OpenDisplay options flow.""" + +from types import SimpleNamespace +from unittest.mock import PropertyMock, patch + +import pytest + +from custom_components.opendisplay.config_flow import OpenDisplayOptionsFlow +from custom_components.opendisplay.const import ( + CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, + MAX_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES, +) +from custom_components.opendisplay.deep_sleep import ( + availability_window_seconds, + deep_sleep_timeout_margin_minutes, +) + + +def test_availability_window_uses_timeout_margin_minutes() -> None: + """Availability window should be deep sleep plus configured margin.""" + assert availability_window_seconds(120, 7) == 540 + + +def test_timeout_margin_options_are_clamped() -> None: + """Stored options should be normalized before runtime use.""" + assert ( + deep_sleep_timeout_margin_minutes( + {CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES: -1} + ) + == 0 + ) + assert ( + deep_sleep_timeout_margin_minutes( + {CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES: 9999} + ) + == MAX_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES + ) + + +@pytest.mark.asyncio +async def test_options_flow_accepts_timeout_margin() -> None: + """Options flow should store a valid timeout margin.""" + flow = OpenDisplayOptionsFlow() + + with patch.object( + OpenDisplayOptionsFlow, + "config_entry", + new_callable=PropertyMock, + return_value=SimpleNamespace(options={}), + ): + result = await flow.async_step_init( + {CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES: 12} + ) + + assert result["type"] == "create_entry" + assert result["data"][CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES] == 12 + + +@pytest.mark.asyncio +async def test_options_flow_rejects_out_of_range_timeout_margin() -> None: + """Options flow should reject values outside 0..1440 minutes.""" + flow = OpenDisplayOptionsFlow() + + with patch.object( + OpenDisplayOptionsFlow, + "config_entry", + new_callable=PropertyMock, + return_value=SimpleNamespace(options={}), + ): + result = await flow.async_step_init( + {CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES: 24 * 60 + 1} + ) + + assert result["type"] == "form" + assert result["errors"][CONF_DEEP_SLEEP_TIMEOUT_MARGIN_MINUTES] == ( + "invalid_timeout_margin" + ) + + +def test_options_flow_automatically_reloads_entry() -> None: + """Options flow should let Home Assistant reload the entry after changes.""" + assert OpenDisplayOptionsFlow.automatic_reload is True diff --git a/tests/test_pending_upload.py b/tests/test_pending_upload.py new file mode 100644 index 0000000..3a6bc30 --- /dev/null +++ b/tests/test_pending_upload.py @@ -0,0 +1,455 @@ +"""Tests for pending upload deep-sleep behavior.""" + +import asyncio + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util + +from custom_components.opendisplay.services import ( + PendingDisplayUpload, + _async_queue_or_send_image, + _async_try_pending_upload, + _pending_upload_timeout_seconds, + async_register_pending_upload_listener, +) +from custom_components.opendisplay.deep_sleep import availability_window_seconds + + +def _make_entry(*, available: bool = False, deep_sleep_seconds: int = 300): + runtime_data = SimpleNamespace( + coordinator=SimpleNamespace( + available=available, + expected_wakeup_timestamp=None, + deep_sleep_availability_deadline_timestamp=None, + deep_sleep_availability_window_seconds=availability_window_seconds( + deep_sleep_seconds + ), + async_set_pending_upload=MagicMock(), + ), + pending_upload=None, + pending_upload_task=None, + pending_upload_expiry_unsub=None, + device_config=SimpleNamespace( + power=SimpleNamespace(deep_sleep_time_seconds=deep_sleep_seconds) + ), + ) + entry = MagicMock() + entry.unique_id = "AA:BB:CC:DD:EE:FF" + entry.options = {} + entry.runtime_data = runtime_data + return entry + + +def test_pending_upload_timeout_resets_even_when_old_deadline_is_near(): + entry = _make_entry(available=False, deep_sleep_seconds=120) + entry.runtime_data.coordinator.deep_sleep_availability_deadline_timestamp = ( + dt_util.utcnow() + timedelta(seconds=30) + ) + + assert _pending_upload_timeout_seconds(entry) == availability_window_seconds(120) + + +@pytest.mark.asyncio +async def test_queue_or_send_stores_pending_without_retry_when_device_sleeping(): + hass = MagicMock() + entry = _make_entry(available=False) + cancel_expiry = MagicMock() + pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ), patch( + "custom_components.opendisplay.services._async_send_image_now", + new_callable=AsyncMock, + ) as mock_send_now, patch( + "custom_components.opendisplay.services.async_call_later", + return_value=cancel_expiry, + ) as mock_call_later, patch( + "custom_components.opendisplay.services.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep: + await _async_queue_or_send_image(hass, entry, pending) + + assert entry.runtime_data.pending_upload is pending + assert pending.expires_at is not None + assert entry.runtime_data.pending_upload_expiry_unsub is cancel_expiry + mock_call_later.assert_called_once() + mock_send_now.assert_not_awaited() + mock_sleep.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_queue_or_send_retries_then_stores_pending_when_deep_sleep_awake_fails(): + hass = MagicMock() + entry = _make_entry(available=True) + cancel_expiry = MagicMock() + pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=MagicMock(), + ), patch( + "custom_components.opendisplay.services._async_send_image_now", + new_callable=AsyncMock, + side_effect=HomeAssistantError("not ready"), + ) as mock_send_now, patch( + "custom_components.opendisplay.services.async_call_later", + return_value=cancel_expiry, + ), patch( + "custom_components.opendisplay.services.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep: + await _async_queue_or_send_image(hass, entry, pending) + + assert entry.runtime_data.pending_upload is pending + assert pending.expires_at is not None + assert mock_send_now.await_count == 3 + assert mock_sleep.await_count == 2 + + +@pytest.mark.asyncio +async def test_queue_or_send_replaces_existing_pending_and_resets_ttl(): + hass = MagicMock() + entry = _make_entry(available=False) + old_cancel_expiry = MagicMock() + old_task = MagicMock() + old_task.done.return_value = False + old_pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + old_pending.expires_at = old_pending.created_at + timedelta(seconds=120) + new_pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="drawcustom", + ) + new_cancel_expiry = MagicMock() + entry.runtime_data.pending_upload = old_pending + entry.runtime_data.pending_upload_expiry_unsub = old_cancel_expiry + entry.runtime_data.pending_upload_task = old_task + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ), patch( + "custom_components.opendisplay.services._async_send_image_now", + new_callable=AsyncMock, + side_effect=HomeAssistantError("not connectable"), + ), patch( + "custom_components.opendisplay.services.async_call_later", + return_value=new_cancel_expiry, + ), patch( + "custom_components.opendisplay.services.asyncio.sleep", + new_callable=AsyncMock, + ): + await _async_queue_or_send_image(hass, entry, new_pending) + + assert entry.runtime_data.pending_upload is new_pending + assert entry.runtime_data.pending_upload_expiry_unsub is new_cancel_expiry + assert entry.runtime_data.pending_upload_task is None + assert new_pending.expires_at is not None + old_cancel_expiry.assert_called_once() + old_task.cancel.assert_called_once() + entry.runtime_data.coordinator.async_set_pending_upload.assert_called_with(True) + + +@pytest.mark.asyncio +async def test_immediate_upload_clears_existing_pending(): + hass = MagicMock() + entry = _make_entry(available=True) + old_cancel_expiry = MagicMock() + old_pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + new_pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="drawcustom", + ) + entry.runtime_data.pending_upload = old_pending + entry.runtime_data.pending_upload_expiry_unsub = old_cancel_expiry + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=MagicMock(), + ), patch( + "custom_components.opendisplay.services._async_send_image_now", + new_callable=AsyncMock, + ) as mock_send_now: + await _async_queue_or_send_image(hass, entry, new_pending) + + mock_send_now.assert_awaited_once_with(hass, entry, new_pending) + assert entry.runtime_data.pending_upload is None + assert entry.runtime_data.pending_upload_expiry_unsub is None + old_cancel_expiry.assert_called_once() + entry.runtime_data.coordinator.async_set_pending_upload.assert_called_with(False) + + +@pytest.mark.asyncio +async def test_queue_or_send_does_not_queue_non_deep_sleep_failure(): + hass = MagicMock() + entry = _make_entry(available=True, deep_sleep_seconds=0) + pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=MagicMock(), + ), patch( + "custom_components.opendisplay.services._async_send_image_now", + new_callable=AsyncMock, + side_effect=HomeAssistantError("upload failed"), + ), patch( + "custom_components.opendisplay.services.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep: + with pytest.raises(HomeAssistantError): + await _async_queue_or_send_image(hass, entry, pending) + + assert mock_sleep.await_count == 2 + assert entry.runtime_data.pending_upload is None + assert entry.runtime_data.pending_upload_expiry_unsub is None + + +@pytest.mark.asyncio +async def test_immediate_upload_retries_then_succeeds(): + hass = MagicMock() + entry = _make_entry(available=True, deep_sleep_seconds=0) + pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=MagicMock(), + ), patch( + "custom_components.opendisplay.services._async_send_image_now", + new_callable=AsyncMock, + side_effect=[HomeAssistantError("busy"), None], + ) as mock_send_now, patch( + "custom_components.opendisplay.services.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep: + await _async_queue_or_send_image(hass, entry, pending) + + assert mock_send_now.await_count == 2 + assert mock_sleep.await_count == 1 + assert entry.runtime_data.pending_upload is None + + +@pytest.mark.asyncio +async def test_try_pending_upload_drops_pending_after_retry_failure(): + hass = MagicMock() + hass.async_create_task = lambda coro, name=None: asyncio.create_task(coro) + entry = _make_entry(available=True) + pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + entry.runtime_data.pending_upload = pending + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=MagicMock(), + ), patch( + "custom_components.opendisplay.services._async_send_image_now", + new_callable=AsyncMock, + side_effect=HomeAssistantError("upload failed"), + ), patch( + "custom_components.opendisplay.services.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep: + await _async_try_pending_upload(hass, entry) + if entry.runtime_data.pending_upload_task is not None: + await entry.runtime_data.pending_upload_task + + assert entry.runtime_data.pending_upload is None + entry.runtime_data.coordinator.async_set_pending_upload.assert_called_with(False) + assert mock_sleep.await_count == 3 + + +@pytest.mark.asyncio +async def test_try_pending_upload_clears_pending_on_success(): + hass = MagicMock() + hass.async_create_task = lambda coro, name=None: asyncio.create_task(coro) + entry = _make_entry(available=True) + cancel_expiry = MagicMock() + pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + pending.expires_at = pending.created_at + timedelta(seconds=360) + entry.runtime_data.pending_upload = pending + entry.runtime_data.pending_upload_expiry_unsub = cancel_expiry + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=MagicMock(), + ), patch( + "custom_components.opendisplay.services._async_send_image_now", + new_callable=AsyncMock, + ) as mock_send_now, patch( + "custom_components.opendisplay.services.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep: + await _async_try_pending_upload(hass, entry) + if entry.runtime_data.pending_upload_task is not None: + await entry.runtime_data.pending_upload_task + + mock_send_now.assert_awaited_once_with(hass, entry, pending) + assert mock_sleep.await_count == 1 + assert entry.runtime_data.pending_upload is None + assert entry.runtime_data.pending_upload_expiry_unsub is None + cancel_expiry.assert_called_once() + + +@pytest.mark.asyncio +async def test_try_pending_upload_retries_then_succeeds(): + hass = MagicMock() + hass.async_create_task = lambda coro, name=None: asyncio.create_task(coro) + entry = _make_entry(available=True) + pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + entry.runtime_data.pending_upload = pending + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=MagicMock(), + ), patch( + "custom_components.opendisplay.services._async_send_image_now", + new_callable=AsyncMock, + side_effect=[HomeAssistantError("busy"), None], + ) as mock_send_now, patch( + "custom_components.opendisplay.services.asyncio.sleep", + new_callable=AsyncMock, + ) as mock_sleep: + await _async_try_pending_upload(hass, entry) + if entry.runtime_data.pending_upload_task is not None: + await entry.runtime_data.pending_upload_task + + assert mock_send_now.await_count == 2 + assert mock_sleep.await_count == 2 + assert entry.runtime_data.pending_upload is None + + +@pytest.mark.asyncio +async def test_try_pending_upload_defers_when_not_connectable(): + hass = MagicMock() + hass.async_create_task = lambda coro, name=None: asyncio.create_task(coro) + entry = _make_entry(available=True) + pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + entry.runtime_data.pending_upload = pending + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ), patch( + "custom_components.opendisplay.services._async_send_image_now", + new_callable=AsyncMock, + ) as mock_send: + await _async_try_pending_upload(hass, entry) + + assert entry.runtime_data.pending_upload is pending + assert entry.runtime_data.pending_upload_task is None + mock_send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_pending_upload_expiry_drops_pending(): + hass = MagicMock() + entry = _make_entry(available=False) + captured_callback = None + cancel_expiry = MagicMock() + pending = PendingDisplayUpload( + image=MagicMock(), + dither_mode=MagicMock(), + refresh_mode=MagicMock(), + source="upload_image", + ) + + def _fake_call_later(_hass, _delay, callback): + nonlocal captured_callback + captured_callback = callback + return cancel_expiry + + with patch( + "custom_components.opendisplay.services.async_ble_device_from_address", + return_value=None, + ), patch( + "custom_components.opendisplay.services.async_call_later", + side_effect=_fake_call_later, + ): + await _async_queue_or_send_image(hass, entry, pending) + + assert captured_callback is not None + captured_callback(pending.expires_at) + + assert entry.runtime_data.pending_upload is None + assert entry.runtime_data.pending_upload_expiry_unsub is None + entry.runtime_data.coordinator.async_set_pending_upload.assert_called_with(False) + + +def test_pending_upload_listener_skips_scheduling_without_pending() -> None: + hass = MagicMock() + entry = _make_entry(available=True) + + captured_callback = None + + def _fake_dispatcher_connect(_hass, _signal, callback): + nonlocal captured_callback + captured_callback = callback + return lambda: None + + with patch( + "custom_components.opendisplay.services.async_dispatcher_connect", + side_effect=_fake_dispatcher_connect, + ): + async_register_pending_upload_listener(hass, entry) + + assert captured_callback is not None + captured_callback() + hass.async_create_task.assert_not_called()