diff --git a/custom_components/opendisplay/__init__.py b/custom_components/opendisplay/__init__.py index 3bfb97d..8a0311c 100644 --- a/custom_components/opendisplay/__init__.py +++ b/custom_components/opendisplay/__init__.py @@ -1,9 +1,13 @@ """Integration for OpenDisplay BLE e-paper displays.""" +from __future__ import annotations + import asyncio import contextlib -from dataclasses import dataclass -from typing import TYPE_CHECKING +from collections.abc import Callable, Mapping +from dataclasses import asdict, dataclass, is_dataclass +import logging +from typing import TYPE_CHECKING, Any from opendisplay import ( AuthenticationFailedError, @@ -14,11 +18,17 @@ 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 ( BluetoothReachabilityIntent, async_address_reachability_diagnostics, async_ble_device_from_address, + async_clear_advertisement_history, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -27,18 +37,46 @@ 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 +from homeassistant.util import dt as dt_util if TYPE_CHECKING: from opendisplay.models import FirmwareVersion - -from .const import CONF_ENCRYPTION_KEY, DOMAIN + 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, +) from .coordinator import OpenDisplayCoordinator -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) - -_BASE_PLATFORMS: list[Platform] = [Platform.IMAGE, Platform.SENSOR] -_FLEX_PLATFORMS = [Platform.EVENT, Platform.IMAGE, Platform.SENSOR, Platform.UPDATE] +_LOGGER = logging.getLogger(__name__) + +_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 @@ -50,14 +88,259 @@ class OpenDisplayRuntimeData: device_config: GlobalConfig is_flex: bool upload_task: asyncio.Task | None = None + config_sync_task: asyncio.Task | 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] -def _get_encryption_key(entry: OpenDisplayConfigEntry) -> bytes | None: +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): + 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 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) + 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 _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) + 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: + """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 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( + hass: HomeAssistant, + entry: OpenDisplayConfigEntry, + 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): + 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 + 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] | OpenDisplayConfigEntry, +) -> bytes | None: """Return the encryption key bytes from entry data, or None.""" - raw = entry.data.get(CONF_ENCRYPTION_KEY) + if isinstance(entry_data, ConfigEntry): + entry_data = entry_data.data + raw = _normalize_stored_encryption_key(entry_data.get(CONF_ENCRYPTION_KEY)) if raw is None: return None if len(raw) != 32: @@ -80,47 +363,134 @@ 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_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 + landing_url: str | None = None + startup_from_cache = False + if ble_device is None: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="device_not_found", - translation_placeholders={ - "address": address, - "reason": async_address_reachability_diagnostics( - hass, - address.upper(), - BluetoothReachabilityIntent.CONNECTION, - ), - }, + if cached_runtime is None or _deep_sleep_seconds(cached_runtime[1]) <= 0: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={ + "address": address, + "reason": async_address_reachability_diagnostics( + hass, + address.upper(), + BluetoothReachabilityIntent.CONNECTION, + ), + }, + ) + 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, startup cache fallback)", + address, + _deep_sleep_seconds(device_config), ) - encryption_key = _get_encryption_key(entry) - - 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 - # Capture while connected: landing_url() reads the advertised name. - landing_url = device.landing_url() - 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 - - coordinator = OpenDisplayCoordinator(hass, address) + else: + try: + 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 + landing_url = device.landing_url() + 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 + startup_from_cache = True + _LOGGER.info( + "%s: Startup connection timed out; using cached config " + "(deep sleep=%ss, startup cache fallback)", + 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( + 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, 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, + 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] @@ -158,6 +528,69 @@ 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 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", + address, + err, + ) + return + 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) + + # Register coordinator listener to refresh runtime config when the device wakes. + def _on_coordinator_update() -> None: + """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 + 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 + + entry.async_on_unload(coordinator.async_add_listener(_on_coordinator_update)) + entry.async_on_unload(async_register_pending_upload_listener(hass, entry)) @callback def _schedule_reboot_reload() -> None: @@ -203,6 +636,18 @@ async def async_unload_entry( task.cancel() with contextlib.suppress(asyncio.CancelledError): await task + if (task := entry.runtime_data.pending_upload_task) and not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + 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): + await task return await hass.config_entries.async_unload_platforms( entry, _get_platforms(entry.runtime_data) 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 622723e..362d2fa 100644 --- a/custom_components/opendisplay/config_flow.py +++ b/custom_components/opendisplay/config_flow.py @@ -17,22 +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_ENCRYPTION_KEY, DOMAIN +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 @@ -46,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 @@ -241,3 +286,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 779b74c..71f00ab 100644 --- a/custom_components/opendisplay/const.py +++ b/custom_components/opendisplay/const.py @@ -2,4 +2,15 @@ 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" +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" +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 ec743a0..458c7ba 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 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,24 +69,374 @@ 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, _LOGGER, address, BluetoothScanningMode.PASSIVE, - connectable=True, + connectable=False, ) 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 # Subscribers notified once when the advertised reboot flag goes # False -> True (the device rebooted since we last talked to it). self._reboot_callbacks: set[CALLBACK_TYPE] = set() # Reboot-flag edge detection: the device sets the advertised reboot flag # on boot and clears it on first connect. None until the first v1 advert. self._last_reboot_flag: bool | None = None + _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_subscribe_reboot(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: @@ -78,6 +457,12 @@ 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) @@ -89,11 +474,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: @@ -107,6 +521,7 @@ def _async_handle_bluetooth_event( err, exc_info=True, ) + return else: self._check_reboot_flag(advertisement) button_events = self._tracker.update(service_info.address, advertisement) @@ -115,17 +530,54 @@ 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() + @callback def _check_reboot_flag(self, advertisement: AdvertisementData) -> None: """Notify on a reboot, detected as a reboot-flag False -> True edge. diff --git a/custom_components/opendisplay/deep_sleep.py b/custom_components/opendisplay/deep_sleep.py new file mode 100644 index 0000000..dea5218 --- /dev/null +++ b/custom_components/opendisplay/deep_sleep.py @@ -0,0 +1,70 @@ +"""Deep-sleep capability helpers.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +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: + """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 + + +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 7cd13bc..10044ef 100644 --- a/custom_components/opendisplay/entity.py +++ b/custom_components/opendisplay/entity.py @@ -20,6 +20,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 +36,13 @@ def __init__( self._attr_device_info = DeviceInfo( connections={(CONNECTION_BLUETOOTH, coordinator.address)}, ) + + @property + def available(self) -> bool: + """Return True when coordinator reports device available.""" + return self.coordinator.available + + @property + def assumed_state(self) -> bool: + """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 335f0a0..13ebe37 100644 --- a/custom_components/opendisplay/sensor.py +++ b/custom_components/opendisplay/sensor.py @@ -2,14 +2,15 @@ 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 from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, - SensorEntity, SensorEntityDescription, SensorStateClass, ) @@ -19,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) @@ -78,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, @@ -97,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: @@ -121,14 +146,69 @@ 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._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() + 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.coordinator.data is None: + 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: + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) + elif not self._restore_when_sleeping: return None - return self.entity_description.value_fn(self.coordinator.data) + return self._attr_native_value diff --git a/custom_components/opendisplay/services.py b/custom_components/opendisplay/services.py index daa1d74..d12e9ad 100644 --- a/custom_components/opendisplay/services.py +++ b/custom_components/opendisplay/services.py @@ -1,9 +1,12 @@ """Service registration for the OpenDisplay integration.""" +from __future__ import annotations + import asyncio from collections.abc import Awaitable, Callable import contextlib -from datetime import timedelta +from dataclasses import dataclass, field +from datetime import datetime, timedelta from enum import IntEnum import io import logging @@ -16,6 +19,8 @@ from opendisplay import ( AuthenticationFailedError, AuthenticationRequiredError, + BLEConnectionError, + BLETimeoutError, DitherMode, FitMode, LedFlashConfig, @@ -33,6 +38,7 @@ BluetoothReachabilityIntent, async_address_reachability_diagnostics, 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 @@ -43,14 +49,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 .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" @@ -58,6 +82,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]: @@ -72,6 +114,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 + + def _dither_value(value: Any) -> DitherMode: """Accept new dither names ("ordered") and legacy numeric values (0/1/2...).""" if isinstance(value, (int, float)) or ( @@ -85,16 +138,12 @@ def _dither_value(value: Any) -> DitherMode: def _refresh_type_value(value: Any) -> RefreshMode: - """Accept names ("full"/"fast") and legacy numeric values. - - `partial` is not implemented yet, so it is not offered and any partial-ish - input (legacy 2/3, or an explicit "partial") falls back to fast. - """ + """Accept names ("full"/"fast") and legacy numeric values.""" if isinstance(value, (int, float)) or ( isinstance(value, str) and value.lstrip("-").isdigit() ): n = int(value) - if n in (2, 3): # legacy partial / partial2 -> fast (partial not implemented) + if n in (2, 3): return RefreshMode.FAST try: mode = RefreshMode(n) @@ -102,7 +151,7 @@ def _refresh_type_value(value: Any) -> RefreshMode: raise vol.Invalid(f"Invalid refresh_type: {value}") from err else: mode = _str_to_int_enum(RefreshMode)(value) - if mode is RefreshMode.PARTIAL: # reserved for the future, not implemented yet + if mode is RefreshMode.PARTIAL: return RefreshMode.FAST return mode @@ -114,7 +163,9 @@ def _refresh_type_value(value: Any) -> RefreshMode: cv.url, MediaSelector(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), @@ -133,19 +184,28 @@ def _refresh_type_value(value: Any) -> RefreshMode: 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("dither", default="ordered"): _dither_value, - vol.Optional("refresh_type", default="full"): _refresh_type_value, + 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"): vol.All( + _coerce_none_to_default("ordered"), _dither_value + ), + vol.Optional("refresh_type", default="full"): vol.All( + _coerce_none_to_default("full"), _refresh_type_value + ), vol.Optional("dry-run", default=False): cv.boolean, - }, - extra=vol.REMOVE_EXTRA, # silently drop legacy keys (ttl, preload_type, preload_lut, ...) + } ) 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: @@ -158,24 +218,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), @@ -185,10 +260,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) + ), } ) @@ -222,7 +305,7 @@ def _get_entry_for_device(call: ServiceCall) -> OpenDisplayConfigEntry: if entry is None or entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="config_entry_not_found", + translation_key="device_not_found", translation_placeholders={"address": mac_address}, ) @@ -236,6 +319,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) @@ -274,13 +537,27 @@ 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: """Resolve BLE device, open a connection, run action, handle auth errors.""" address = entry.unique_id 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 + # 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", @@ -295,6 +572,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( @@ -309,6 +588,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, @@ -316,50 +603,512 @@ 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( 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, 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.""" + """Upload image immediately and update image entity cache.""" + address = entry.unique_id + assert address 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, + 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, img) + + 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( + "%s: Upload attempts stopped before attempt %s/%s " + "(context=%s, source=%s)", + address, + attempt, + _UPLOAD_MAX_ATTEMPTS, + context, + pending.source, + ) + return False + + _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( + "%s: Upload failed after %s attempts " + "(context=%s, source=%s, age=%ss, expires_at=%s, error=%s)", + address, + attempt, + context, + pending.source, + _pending_age_seconds(pending), + pending.expires_at.isoformat() if pending.expires_at else None, + err, + ) + 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( + "%s: Upload attempts succeeded " + "(context=%s, source=%s, attempt=%s/%s)", + address, + context, + pending.source, + attempt, + _UPLOAD_MAX_ATTEMPTS, + ) + return True + + 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 ble_device is not None and coordinator_available: + _LOGGER.info( + "%s: Deep-sleep device appears awake; attempting upload before " + "queueing (source=%s)", + address, + pending.source, + ) + _clear_pending_upload( + hass, + entry, + reason="new upload request for awake deep-sleep device", + cancel_task=True, + ) + 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( + "%s: Deep-sleep device appears asleep; queueing without immediate " + "upload attempts (source=%s, connectable=%s, coordinator_available=%s)", + address, + pending.source, + ble_device is not None, + coordinator_available, + ) + + _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", + ) + 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 + 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}", + ) + + return async_dispatcher_connect( + hass, + f"{SIGNAL_DEVICE_SEEN}_{address}", + _schedule_try_pending_upload, + ) async def _async_upload_image(call: ServiceCall) -> None: """Handle the upload_image service call.""" entry = _get_entry_for_device(call) - image_data: dict[str, Any] | str = call.data[ATTR_IMAGE] + image_data: dict[str, Any] = call.data[ATTR_IMAGE] rotation: Rotation = call.data[ATTR_ROTATION] dither_mode: DitherMode = call.data[ATTR_DITHER_MODE] refresh_mode: RefreshMode = call.data[ATTR_REFRESH_MODE] @@ -369,17 +1118,6 @@ async def _async_upload_image(call: ServiceCall) -> None: tone_compression_pct / 100.0 if tone_compression_pct is not None else "auto" ) - # A plain URL (e.g. an automation pushing a rendered snapshot) must be - # explicitly allowlisted; media-source items are already trusted. - if isinstance(image_data, str) and not call.hass.config.is_allowed_external_url( - image_data - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="url_not_allowed", - translation_placeholders={"url": image_data}, - ) - current = asyncio.current_task() if (prev := entry.runtime_data.upload_task) is not None and not prev.done(): prev.cancel() @@ -389,29 +1127,39 @@ async def _async_upload_image(call: ServiceCall) -> None: entry.runtime_data.upload_task = current try: - if isinstance(image_data, str): - pil_image = await _async_download_image(call.hass, image_data) - else: - media = await async_resolve_media( - call.hass, image_data["media_content_id"], None + media = await async_resolve_media( + call.hass, image_data["media_content_id"], None + ) + + if media.path is not None: + pil_image = await call.hass.async_add_executor_job( + _load_image, str(media.path) ) - if media.path is not None: - pil_image = await call.hass.async_add_executor_job( - _load_image, str(media.path) - ) - else: - pil_image = await _async_download_image(call.hass, media.url) + 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: @@ -489,7 +1237,7 @@ def _get_entry_for_device_id( if entry is None or entry.state is not ConfigEntryState.LOADED: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="config_entry_not_found", + translation_key="device_not_found", translation_placeholders={"address": mac_address}, ) return entry @@ -566,12 +1314,8 @@ async def _drawcustom_for_device( color_scheme = cs if isinstance(cs, ColorScheme) else ColorScheme.from_value(cs) rotate: int = call.data["rotate"] - # The payload is authored against the final on-screen orientation. The device - # applies (base + rotate) and fits the result to its native pixel grid, so when - # the effective rotation transposes the axes (90/270) we render the canvas - # transposed too. That keeps the device-side fit a 1:1 no-op instead of - # scaling/centering a mismatched-aspect image. Rotation itself is left to the - # device (consistent with the upload_image path) rather than rotating here. + # Keep generated dimensions aligned with effective display rotation so the + # device-side fit does not rescale unexpectedly for 90/270 paths. base = display.rotation_enum base_deg = base.value if isinstance(base, Rotation) else 0 if (base_deg + rotate) % 360 in (90, 270): @@ -599,14 +1343,22 @@ async def _drawcustom_for_device( dither_mode: DitherMode = call.data["dither"] refresh_mode: RefreshMode = call.data["refresh_type"] - await _async_send_image( - hass, - entry, - img, + pending = PendingDisplayUpload( + image=img, dither_mode=dither_mode, refresh_mode=refresh_mode, rotate=Rotation(rotate), + 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: @@ -681,6 +1433,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 350d82b..af4e5d7 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 ca55005..5553246 100644 --- a/custom_components/opendisplay/translations/en.json +++ b/custom_components/opendisplay/translations/en.json @@ -1,329 +1,356 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured", - "already_in_progress": "Configuration flow is already in progress", - "cannot_connect": "Failed to connect", - "no_devices_found": "No devices found on the network", - "reauth_successful": "Re-authentication was successful", - "unknown": "Unexpected error" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "invalid_key_format": "The encryption key must be exactly 32 hexadecimal characters (0-9, a-f).", - "unknown": "Unexpected error" - }, - "flow_title": "{name}", - "step": { - "bluetooth_confirm": { - "description": "Do you want to set up {name}?" - }, - "encryption_key": { - "data": { - "encryption_key": "Encryption key" - }, - "data_description": { - "encryption_key": "Enter the 32-character hexadecimal AES-128 encryption key for this device." - }, - "description": "{name} requires an encryption key to connect.", - "title": "Encryption required" - }, - "reauth_confirm": { - "data": { - "encryption_key": "Encryption key" - }, - "data_description": { - "encryption_key": "Enter the 32-character hexadecimal AES-128 encryption key for this device." - }, - "description": "Authentication failed for {name}. Enter the correct encryption key, or leave blank if encryption has been disabled on the device.", - "title": "Re-authentication required" - }, - "user": { - "data": { - "address": "Device" - }, - "data_description": { - "address": "Select the Bluetooth device to set up." - }, - "description": "Choose a device to set up" + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", + "no_devices_found": "No devices found on the network", + "reauth_successful": "Re-authentication was successful", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "invalid_key_format": "The encryption key must be exactly 32 hexadecimal characters (0-9, a-f).", + "unknown": "Unexpected error" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Do you want to set up {name}?" + }, + "encryption_key": { + "data": { + "encryption_key": "Encryption key" + }, + "data_description": { + "encryption_key": "Enter the 32-character hexadecimal AES-128 encryption key for this device." + }, + "description": "{name} requires an encryption key to connect.", + "title": "Encryption required" + }, + "reauth_confirm": { + "data": { + "encryption_key": "Encryption key" + }, + "data_description": { + "encryption_key": "Enter the 32-character hexadecimal AES-128 encryption key for this device." + }, + "description": "Authentication failed for {name}. Enter the correct encryption key, or leave blank if encryption has been disabled on the device.", + "title": "Re-authentication required" + }, + "user": { + "data": { + "address": "Device" + }, + "data_description": { + "address": "Select the Bluetooth device to set up." + }, + "description": "Choose a device to set up" + } + } + }, + "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" + } + }, + "event": { + "button": { + "name": "Button {number}", + "state_attributes": { + "event_type": { + "state": { + "button_down": "Button down", + "button_up": "Button up" } + } } + }, + "touch": { + "name": "Touch {number}", + "state_attributes": { + "event_type": { + "state": { + "touch_down": "Touch down", + "touch_move": "Touch move", + "touch_up": "Touch up" + } + } + } + } + }, + "sensor": { + "battery_voltage": { + "name": "Battery voltage" + }, + "rssi": { + "name": "Signal strength (RSSI)" + }, + "last_seen": { + "name": "Last seen" + }, + "deep_sleep_time": { + "name": "Deep sleep time" + }, + "expected_wakeup": { + "name": "Expected wake-up" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + } + }, + "exceptions": { + "authentication_error": { + "message": "Authentication failed. Please update the encryption key." + }, + "device_not_found": { + "message": "Could not find Bluetooth device with address `{address}`." + }, + "invalid_device_id": { + "message": "Device `{device_id}` is not a valid OpenDisplay device." + }, + "media_download_error": { + "message": "Failed to download media: {error}" + }, + "multiple_errors": { + "message": "Errors occurred for one or more devices:\n{errors}" + }, + "no_buzzers": { + "message": "Device `{device_id}` has no buzzer configured." + }, + "no_leds": { + "message": "Device `{device_id}` has no LEDs configured." + }, + "no_targets_specified": { + "message": "No target devices specified." + }, + "upload_error": { + "message": "Failed to upload to the display: {error}" + }, + "url_not_allowed": { + "message": "URL `{url}` is not allowed. Add it to `allowlist_external_urls` in your configuration.yaml." + } + }, + "selector": { + "dither_mode": { + "options": { + "atkinson": "Atkinson", + "burkes": "Burkes", + "floyd_steinberg": "Floyd-Steinberg", + "jarvis_judice_ninke": "Jarvis, Judice & Ninke", + "none": "None", + "ordered": "Ordered", + "sierra": "Sierra", + "sierra_lite": "Sierra Lite", + "stucki": "Stucki" + } + }, + "fit_mode": { + "options": { + "contain": "Contain", + "cover": "Cover", + "crop": "Crop", + "stretch": "Stretch" + } }, - "entity": { + "refresh_mode": { + "options": { + "fast": "Fast", + "full": "Full" + } + } + }, + "services": { + "upload_image": { + "description": "Uploads an image to an OpenDisplay device.", + "fields": { + "device_id": { + "description": "The OpenDisplay device to upload the image to.", + "name": "Device" + }, + "dither_mode": { + "description": "The dithering algorithm to use for converting the image to the display's color palette.", + "name": "Dither mode" + }, + "fit_mode": { + "description": "How the image is fitted to the display dimensions.", + "name": "Fit mode" + }, "image": { - "content": { - "name": "Display content" - } + "description": "The image to upload to the display. Pick a media item, or provide a direct image URL (the URL must be added to allowlist_external_urls).", + "name": "Image" }, - "event": { - "button": { - "name": "Button {number}", - "state_attributes": { - "event_type": { - "state": { - "button_down": "Button down", - "button_up": "Button up" - } - } - } - }, - "touch": { - "name": "Touch {number}", - "state_attributes": { - "event_type": { - "state": { - "touch_down": "Touch down", - "touch_move": "Touch move", - "touch_up": "Touch up" - } - } - } - } + "refresh_mode": { + "description": "The display refresh mode. Full refresh clears ghosting but is slower. Fast refresh is not supported on all displays.", + "name": "Refresh mode" }, - "sensor": { - "battery_voltage": { - "name": "Battery voltage" - }, - "rssi": { - "name": "Signal strength (RSSI)" - }, - "last_seen": { - "name": "Last seen" - } + "rotation": { + "description": "The rotation angle in degrees, applied clockwise.", + "name": "Rotation" }, - "update": { - "firmware": { - "name": "Firmware" - } + "tone_compression": { + "description": "Dynamic range compression strength. Leave empty for automatic.", + "name": "Tone compression" + } + }, + "name": "Upload image", + "sections": { + "additional_fields": { + "name": "Additional options" } + } }, - "exceptions": { - "authentication_error": { - "message": "Authentication failed. Please update the encryption key." + "activate_led": { + "name": "Activate LED", + "description": "Triggers an LED flash pattern on an OpenDisplay device.", + "fields": { + "device_id": { + "name": "Device", + "description": "The OpenDisplay device." + }, + "instance": { + "name": "LED instance", + "description": "LED instance index (0-based)." + }, + "brightness": { + "name": "Brightness", + "description": "LED brightness (1-16)." + }, + "repeats": { + "name": "Repeats", + "description": "Number of times to repeat the full pattern (0 = infinite)." + }, + "color1": { + "name": "Color 1", + "description": "First step color." }, - "device_not_found": { - "message": "Could not find Bluetooth device with address `{address}`." + "flash_count1": { + "name": "Flash count 1", + "description": "Number of flashes for the first step (0 skips this step)." }, - "invalid_device_id": { - "message": "Device `{device_id}` is not a valid OpenDisplay device." + "loop_delay1": { + "name": "Flash delay 1", + "description": "Delay between flashes in the first step." }, - "media_download_error": { - "message": "Failed to download media: {error}" + "inter_delay1": { + "name": "Step delay 1", + "description": "Delay after the first step before moving to the next." }, - "multiple_errors": { - "message": "Errors occurred for one or more devices:\n{errors}" + "color2": { + "name": "Color 2", + "description": "Second step color (omit or set flash count to 0 to skip)." }, - "no_buzzers": { - "message": "Device `{device_id}` has no buzzer configured." + "flash_count2": { + "name": "Flash count 2", + "description": "Number of flashes for the second step (0 skips this step)." }, - "no_leds": { - "message": "Device `{device_id}` has no LEDs configured." + "loop_delay2": { + "name": "Flash delay 2", + "description": "Delay between flashes in the second step." }, - "no_targets_specified": { - "message": "No target devices specified." + "inter_delay2": { + "name": "Step delay 2", + "description": "Delay after the second step before moving to the next." }, - "upload_error": { - "message": "Failed to upload to the display: {error}" + "color3": { + "name": "Color 3", + "description": "Third step color (omit or set flash count to 0 to skip)." }, - "url_not_allowed": { - "message": "URL `{url}` is not allowed. Add it to `allowlist_external_urls` in your configuration.yaml." + "flash_count3": { + "name": "Flash count 3", + "description": "Number of flashes for the third step (0 skips this step)." + }, + "loop_delay3": { + "name": "Flash delay 3", + "description": "Delay between flashes in the third step." + }, + "inter_delay3": { + "name": "Step delay 3", + "description": "Delay after the third step before repeating." } + } }, - "selector": { - "dither_mode": { - "options": { - "atkinson": "Atkinson", - "burkes": "Burkes", - "floyd_steinberg": "Floyd-Steinberg", - "jarvis_judice_ninke": "Jarvis, Judice & Ninke", - "none": "None", - "ordered": "Ordered", - "sierra": "Sierra", - "sierra_lite": "Sierra Lite", - "stucki": "Stucki" - } + "activate_buzzer": { + "name": "Activate buzzer", + "description": "Triggers a buzzer tone on an OpenDisplay device.", + "fields": { + "device_id": { + "name": "Device", + "description": "The OpenDisplay device." }, - "fit_mode": { - "options": { - "contain": "Contain", - "cover": "Cover", - "crop": "Crop", - "stretch": "Stretch" - } + "instance": { + "name": "Buzzer instance", + "description": "Buzzer instance index (0-based)." }, - "refresh_mode": { - "options": { - "fast": "Fast", - "full": "Full" - } + "frequency_hz": { + "name": "Frequency", + "description": "Tone frequency in Hz (0 = silence, 400-12000)." + }, + "duration_ms": { + "name": "Duration", + "description": "Tone duration in milliseconds (5-1275)." + }, + "repeats": { + "name": "Repeats", + "description": "Number of times to repeat the tone (1-255)." } + } }, - "services": { - "upload_image": { - "description": "Uploads an image to an OpenDisplay device.", - "fields": { - "device_id": { - "description": "The OpenDisplay device to upload the image to.", - "name": "Device" - }, - "dither_mode": { - "description": "The dithering algorithm to use for converting the image to the display's color palette.", - "name": "Dither mode" - }, - "fit_mode": { - "description": "How the image is fitted to the display dimensions.", - "name": "Fit mode" - }, - "image": { - "description": "The image to upload to the display. Pick a media item, or provide a direct image URL (the URL must be added to allowlist_external_urls).", - "name": "Image" - }, - "refresh_mode": { - "description": "The display refresh mode. Full refresh clears ghosting but is slower. Fast refresh is not supported on all displays.", - "name": "Refresh mode" - }, - "rotation": { - "description": "The rotation angle in degrees, applied clockwise.", - "name": "Rotation" - }, - "tone_compression": { - "description": "Dynamic range compression strength. Leave empty for automatic.", - "name": "Tone compression" - } - }, - "name": "Upload image", - "sections": { - "additional_fields": { - "name": "Additional options" - } - } + "drawcustom": { + "description": "Draws a custom image on one or more OpenDisplay devices.", + "fields": { + "payload": { + "description": "Array of drawing elements.", + "name": "Payload" }, - "activate_led": { - "name": "Activate LED", - "description": "Triggers an LED flash pattern on an OpenDisplay device.", - "fields": { - "device_id": { - "name": "Device", - "description": "The OpenDisplay device." - }, - "instance": { - "name": "LED instance", - "description": "LED instance index (0-based)." - }, - "brightness": { - "name": "Brightness", - "description": "LED brightness (1-16)." - }, - "repeats": { - "name": "Repeats", - "description": "Number of times to repeat the full pattern (0 = infinite)." - }, - "color1": { - "name": "Color 1", - "description": "First step color." - }, - "flash_count1": { - "name": "Flash count 1", - "description": "Number of flashes for the first step (0 skips this step)." - }, - "loop_delay1": { - "name": "Flash delay 1", - "description": "Delay between flashes in the first step." - }, - "inter_delay1": { - "name": "Step delay 1", - "description": "Delay after the first step before moving to the next." - }, - "color2": { - "name": "Color 2", - "description": "Second step color (omit or set flash count to 0 to skip)." - }, - "flash_count2": { - "name": "Flash count 2", - "description": "Number of flashes for the second step (0 skips this step)." - }, - "loop_delay2": { - "name": "Flash delay 2", - "description": "Delay between flashes in the second step." - }, - "inter_delay2": { - "name": "Step delay 2", - "description": "Delay after the second step before moving to the next." - }, - "color3": { - "name": "Color 3", - "description": "Third step color (omit or set flash count to 0 to skip)." - }, - "flash_count3": { - "name": "Flash count 3", - "description": "Number of flashes for the third step (0 skips this step)." - }, - "loop_delay3": { - "name": "Flash delay 3", - "description": "Delay between flashes in the third step." - }, - "inter_delay3": { - "name": "Step delay 3", - "description": "Delay after the third step before repeating." - } - } + "background": { + "description": "Background fill color.", + "name": "Background color" }, - "activate_buzzer": { - "name": "Activate buzzer", - "description": "Triggers a buzzer tone on an OpenDisplay device.", - "fields": { - "device_id": { - "name": "Device", - "description": "The OpenDisplay device." - }, - "instance": { - "name": "Buzzer instance", - "description": "Buzzer instance index (0-based)." - }, - "frequency_hz": { - "name": "Frequency", - "description": "Tone frequency in Hz (0 = silence, 400-12000)." - }, - "duration_ms": { - "name": "Duration", - "description": "Tone duration in milliseconds (5-1275)." - }, - "repeats": { - "name": "Repeats", - "description": "Number of times to repeat the tone (1-255)." - } - } + "rotate": { + "description": "Clockwise rotation in degrees.", + "name": "Rotation" + }, + "dither": { + "description": "Dithering algorithm for color palette conversion.", + "name": "Dither mode" + }, + "refresh_type": { + "description": "Display refresh mode.", + "name": "Refresh type" }, - "drawcustom": { - "description": "Draws a custom image on one or more OpenDisplay devices.", - "fields": { - "payload": { - "description": "Array of drawing elements.", - "name": "Payload" - }, - "background": { - "description": "Background fill color.", - "name": "Background color" - }, - "rotate": { - "description": "Clockwise rotation in degrees.", - "name": "Rotation" - }, - "dither": { - "description": "Dithering algorithm for color palette conversion.", - "name": "Dither mode" - }, - "refresh_type": { - "description": "Display refresh mode.", - "name": "Refresh type" - }, - "dry-run": { - "description": "Generate image without uploading to the device.", - "name": "Dry run" - } - }, - "name": "Draw custom image" + "dry-run": { + "description": "Generate image without uploading to the device.", + "name": "Dry run" } + }, + "name": "Draw custom image" } + } } diff --git a/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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..116b137 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e87a9e0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,298 @@ +"""Shared pytest fixtures and configuration. + +Stubs out heavy Home Assistant selector / media components that are not +needed for unit tests and may cause import errors due to version +mismatches in the test environment. +""" + +from __future__ import annotations + +import sys +import types +from enum import IntEnum +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: + 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 + FAST = 1 + PARTIAL = 2 + + class FitMode(IntEnum): + STRETCH = 0 + CONTAIN = 1 + COVER = 2 + CROP = 3 + + 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, capacity_estimator=None): + 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): + LI_ION = 1 + LIFEPO4 = 2 + SUPERCAP = 3 + LITHIUM_PRIMARY = 4 + + class PowerMode(IntEnum): + BATTERY = 1 + USB = 2 + SOLAR = 3 + + 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() + + + +def _stub_ha_selector() -> None: + """Stub out homeassistant.helpers.selector to avoid voluptuous schema errors.""" + selector_mod = sys.modules.get("homeassistant.helpers.selector") + if selector_mod is None: + return + + class _NumberSelectorMode: + BOX = "box" + + class _NumberSelectorConfig: + def __init__(self, **kwargs): + pass + + class _NumberSelector: + def __init__(self, config=None): + pass + + def __call__(self, value): + return value + + class _MediaSelectorConfig: + def __init__(self, **kwargs): + pass + + class _MediaSelector: + def __init__(self, config=None): + pass + + def __call__(self, value): + return value + + selector_mod.NumberSelectorMode = _NumberSelectorMode + selector_mod.NumberSelectorConfig = _NumberSelectorConfig + selector_mod.NumberSelector = _NumberSelector + selector_mod.MediaSelectorConfig = _MediaSelectorConfig + selector_mod.MediaSelector = _MediaSelector + + +_stub_ha_selector() diff --git a/tests/test_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 new file mode 100644 index 0000000..a51e4a5 --- /dev/null +++ b/tests/test_coordinator_connectable.py @@ -0,0 +1,765 @@ +"""Tests for OpenDisplayCoordinator connectable=False fix. + +Verifies that the coordinator uses connectable=False so that non-connectable +advertisements (e.g. from a device waking from deep sleep) are processed and +the entity is correctly reported as available. +""" + +from 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, +) + + +def _make_coordinator(address: str = "AA:BB:CC:DD:EE:FF") -> OpenDisplayCoordinator: + """Create a minimal OpenDisplayCoordinator with a mock hass.""" + hass = MagicMock() + with patch( + "custom_components.opendisplay.coordinator.PassiveBluetoothDataUpdateCoordinator.__init__", + return_value=None, + ) as mock_super: + coord = OpenDisplayCoordinator(hass, address) + mock_super.assert_called_once_with( + hass, + coord._LOGGER if hasattr(coord, "_LOGGER") else MagicMock(), + address, + BluetoothScanningMode.PASSIVE, + connectable=False, + ) + return coord + + +def test_coordinator_registers_with_connectable_false() -> None: + """Coordinator must pass connectable=False to PassiveBluetoothDataUpdateCoordinator. + + This ensures the coordinator receives BLE advertisements from non-connectable + devices, which is the state a device is in immediately after waking from deep + sleep before it is ready to accept a connection. + """ + hass = MagicMock() + + init_kwargs: dict = {} + + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + + def _capture_init(self, *args, **kwargs): + init_kwargs.update(kwargs) + init_kwargs["args"] = args + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + + with patch(original_init, _capture_init): + OpenDisplayCoordinator(hass, "AA:BB:CC:DD:EE:FF") + + assert init_kwargs.get("connectable") is False, ( + "connectable must be False so non-connectable deep-sleep wake advertisements " + "are processed by the coordinator" + ) + + +def test_coordinator_connectable_true_would_miss_deep_sleep_wakeup() -> None: + """Document that connectable=True would cause missed deep-sleep wakeup events. + + When a device wakes from deep sleep it first broadcasts a non-connectable + advertisement. A coordinator registered with connectable=True would not + receive that event, making the entity appear unavailable during the wakeup + window when the upload should be flushed. + """ + hass = MagicMock() + captured: dict = {} + + def _capture_init(self, *args, **kwargs): + captured["connectable"] = kwargs.get("connectable") + self._LOGGER = MagicMock() + self.hass = hass + self.address = args[2] if len(args) > 2 else kwargs.get("address") + + original_init = ( + "homeassistant.components.bluetooth.passive_update_coordinator" + ".PassiveBluetoothDataUpdateCoordinator.__init__" + ) + with patch(original_init, _capture_init): + OpenDisplayCoordinator(hass, "AA:BB:CC:DD:EE:FF") + + # The fix: must NOT be True (which would exclude non-connectable advertisements) + assert captured.get("connectable") is not True, ( + "connectable=True would exclude non-connectable advertisements from deep-sleep " + "devices; the coordinator must use connectable=False" + ) + + +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_entity_sleep_restore.py b/tests/test_entity_sleep_restore.py new file mode 100644 index 0000000..f850b06 --- /dev/null +++ b/tests/test_entity_sleep_restore.py @@ -0,0 +1,210 @@ +"""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 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.""" + return SimpleNamespace( + available=available, + data=data, + address="AA:BB:CC:DD:EE:FF", + deep_sleep_time_seconds=deep_sleep_seconds, + expected_wakeup_timestamp=None, + async_restore_last_seen=MagicMock(), + ) + + +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( + _COORDINATOR_ENTITY_INIT, + lambda self, coordinator: setattr(self, "coordinator", coordinator), + ): + return OpenDisplaySensorEntity(coordinator, _make_description()) + + +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 False + assert entity.assumed_state is False + + +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._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._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()